| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- /**
- * JKES 校园跑:GPS / calc / pause / end(对齐 jkes_test/simulateRun.js)
- * - 默认按「目标距离 + 配速」由闭合轨迹几何展开时间与速度,不依赖 path_data 中存的 distance/点内配速
- * - 结束前必须先 pause,否则服务端记为无效
- */
- const axios = require('axios')
- const https = require('https')
- const { getJkesSettings, normalizeApiBase } = require('./jkesSettings')
- const {
- isJkesLoginExpiredPayload,
- makeJkesLoginExpiredError
- } = require('./request')
- const CALC_INTERVAL_MS = 50000
- const R_EARTH = 6371000
- function toRad(d) {
- return (d * Math.PI) / 180
- }
- function haversineM(lat1, lon1, lat2, lon2) {
- const la1 = toRad(lat1)
- const la2 = toRad(lat2)
- const dLat = toRad(lat2 - lat1)
- const dLon = toRad(lon2 - lon1)
- const h =
- Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2
- return 2 * R_EARTH * Math.asin(Math.min(1, Math.sqrt(h)))
- }
- function interpolateLngLat(a, b, t) {
- return {
- latitude: a.latitude + (b.latitude - a.latitude) * t,
- longitude: a.longitude + (b.longitude - a.longitude) * t
- }
- }
- /** 首末点距离超过约 3m 时视为未闭合,补上一段回到起点 */
- function ensureClosedLoop(loop) {
- if (loop.length < 2) {
- throw new Error('闭合轨迹至少 2 个点')
- }
- const first = loop[0]
- const last = loop[loop.length - 1]
- const gap = haversineM(first.latitude, first.longitude, last.latitude, last.longitude)
- if (gap > 3) {
- return loop.concat([{ latitude: first.latitude, longitude: first.longitude }])
- }
- return loop
- }
- /** 库内 path_data:支持 latitude/longitude 或 a/o */
- function pathRawToLoop(raw) {
- if (!Array.isArray(raw) || raw.length === 0) {
- throw new Error('轨迹数据应为非空数组')
- }
- const loop = raw.map((p, i) => {
- if (typeof (p.latitude ?? p.lat) === 'number') {
- return {
- latitude: p.latitude ?? p.lat,
- longitude: p.longitude ?? p.lon ?? p.o
- }
- }
- if (typeof p.a === 'number' && typeof p.o === 'number') {
- return { latitude: p.a, longitude: p.o }
- }
- throw new Error(`第 ${i + 1} 个点无有效坐标(需 latitude/longitude 或 a/o)`)
- })
- return ensureClosedLoop(loop)
- }
- /**
- * 沿闭合环路走够 targetM 米;点序列含起点,末点为终点(可能落在边上插值)
- * (与 simulateRun.js 一致)
- */
- function expandLoopToDistance(loop, targetM) {
- const n = loop.length
- if (n < 2) throw new Error('闭合轨迹至少 2 个点')
- const out = [{ latitude: loop[0].latitude, longitude: loop[0].longitude }]
- let cum = 0
- let vi = 0
- let guard = 0
- const maxGuard = Math.ceil(targetM * 3) + n * 200
- while (cum < targetM - 1e-6 && guard++ < maxGuard) {
- const next = (vi + 1) % n
- const a = loop[vi]
- const b = loop[next]
- const edge = haversineM(a.latitude, a.longitude, b.latitude, b.longitude)
- if (edge < 1e-9) {
- vi = next
- continue
- }
- const remain = targetM - cum
- if (edge <= remain + 1e-9) {
- cum += edge
- vi = next
- out.push({ latitude: b.latitude, longitude: b.longitude })
- if (cum >= targetM - 1e-9) break
- } else {
- const t = remain / edge
- const p = interpolateLngLat(a, b, t)
- out.push({ latitude: p.latitude, longitude: p.longitude })
- cum = targetM
- break
- }
- }
- if (guard >= maxGuard) {
- throw new Error('展开轨迹失败:边长过短或无法沿环前进,请检查轨迹是否闭合')
- }
- return out
- }
- /** 按配速生成 deviceTimeRaw(ms) 与 speed(m/s) */
- function scheduleByPace(points, paceSecPerKm) {
- const secPerM = paceSecPerKm / 1000
- const rows = []
- let tMs = 0
- for (let i = 0; i < points.length; i++) {
- const p = points[i]
- if (i === 0) {
- rows.push({
- latitude: p.latitude,
- longitude: p.longitude,
- deviceTimeRaw: 0,
- speed: -1,
- steps: 0
- })
- continue
- }
- const prev = points[i - 1]
- const dM = haversineM(prev.latitude, prev.longitude, p.latitude, p.longitude)
- const dtMs = Math.max(0, dM * secPerM * 1000)
- tMs += dtMs
- const v = dtMs > 0 ? dM / (dtMs / 1000) : -1
- rows.push({
- latitude: p.latitude,
- longitude: p.longitude,
- deviceTimeRaw: Math.round(tMs),
- speed: v >= 0 && v < 0.05 ? -1 : Math.round(v * 100) / 100,
- steps: 0
- })
- }
- return rows
- }
- function buildPointsFromDistanceAndPace(raw, distanceM, paceSecPerKm) {
- const dm = Number(distanceM)
- const pace = Number(paceSecPerKm)
- if (!Number.isFinite(dm) || dm < 1) {
- throw new Error('distanceM 无效')
- }
- if (!Number.isFinite(pace) || pace < 10) {
- throw new Error('paceSecPerKm 无效(每公里秒数,建议 ≥120)')
- }
- const loop = pathRawToLoop(raw)
- const expanded = expandLoopToDistance(loop, dm)
- return scheduleByPace(expanded, pace)
- }
- /** 旧版 path.json:点内自带 d 时间轴与 s 速度 */
- function normalizePathPointsLegacy(raw) {
- if (!Array.isArray(raw) || raw.length === 0) {
- throw new Error('轨迹数据应为非空数组')
- }
- return raw.map((p, i) => {
- const d = String(p.d || '')
- const ts = parseInt(d.split(/\s+/)[0], 10)
- if (!Number.isFinite(ts)) {
- throw new Error(`第 ${i + 1} 个点缺少有效 d 字段时间戳(legacy 模式)`)
- }
- return {
- latitude: p.a,
- longitude: p.o,
- deviceTimeRaw: ts,
- speed: typeof p.s === 'number' ? p.s : -1,
- steps: typeof p.b === 'number' ? p.b : 0
- }
- })
- }
- function remapDeviceTimes(points, runStartMs) {
- const t0 = points[0].deviceTimeRaw
- return points.map((p) => ({
- ...p,
- deviceTime: runStartMs + (p.deviceTimeRaw - t0)
- }))
- }
- function toGpsPayloadPoint(p, opts) {
- const acc = opts.defaultAccuracy
- const spd = p.speed >= 0 ? p.speed : -1
- return {
- verticalAccuracy: 30,
- speed: spd,
- longitude: p.longitude,
- horizontalAccuracy: acc,
- provider: 'gps',
- steps: p.steps,
- latitude: p.latitude,
- accuracy: acc,
- direction: -1,
- altitude: opts.altitude,
- type: 'gcj02',
- deviceTime: p.deviceTime
- }
- }
- function chunkPoints(points, firstN, restN) {
- if (points.length === 0) return []
- const chunks = []
- const first = points.slice(0, firstN)
- if (first.length) chunks.push(first)
- let i = first.length
- while (i < points.length) {
- chunks.push(points.slice(i, i + restN))
- i += restN
- }
- return chunks
- }
- function sleep(ms) {
- return new Promise((r) => setTimeout(r, ms))
- }
- /** 根据轨迹首尾 deviceTime 得到预计跑完全程的等待时长(与循环内 sleep 总和一致,不含网络请求) */
- function estimateRunWallClockMs(points) {
- if (!Array.isArray(points) || points.length < 2) {
- return 0
- }
- return Math.max(0, points[points.length - 1].deviceTime - points[0].deviceTime)
- }
- function formatMinutesSeconds(ms) {
- const totalSec = Math.max(0, Math.round(ms / 1000))
- const m = Math.floor(totalSec / 60)
- const s = totalSec % 60
- if (m <= 0) {
- return `${s}秒`
- }
- return `${m}分${s}秒`
- }
- function buildAxiosConfig(headers) {
- const s = getJkesSettings()
- const agent = new https.Agent({
- keepAlive: true,
- minVersion: 'TLSv1.2',
- rejectUnauthorized: s.tlsRejectUnauthorized !== false
- })
- const cfg = {
- headers,
- timeout: Math.max(120000, Number(s.requestTimeoutMs) || 0),
- validateStatus: () => true,
- httpsAgent: agent,
- beforeRedirect: (options) => {
- if (options.protocol !== 'https:') {
- throw new Error('JKES 重定向目标必须为 https')
- }
- }
- }
- if (!s.useSystemProxy) {
- cfg.proxy = false
- }
- return cfg
- }
- /**
- * @param {object} opts
- * @param {string} opts.token
- * @param {Array} opts.pathPoints path_data.data:经纬度环(a/o 或 latitude/longitude)
- * @param {number} [opts.distanceM] 目标跑步距离(米);与 paceSecPerKm 同时传入则走新版调度
- * @param {number} [opts.paceSecPerKm] 每公里用时(秒),如 390 ≈ 6:30/km
- * @param {string} [opts.baseUrl]
- * @param {number} [opts.batchSize=5]
- * @param {number} [opts.firstBatchSize=1]
- * @param {number} [opts.altitude]
- * @param {number} [opts.defaultAccuracy]
- * @param {function} [opts.log]
- */
- async function runJkesRecord(opts) {
- const s = getJkesSettings()
- const {
- token,
- pathPoints: rawPoints,
- distanceM,
- paceSecPerKm,
- baseUrl = normalizeApiBase(s.apiBase),
- batchSize = 5,
- firstBatchSize = 1,
- altitude = s.gpsAltitude,
- defaultAccuracy = s.gpsDefaultAccuracy,
- log = () => { }
- } = opts
- if (!token || String(token).trim() === '') {
- throw new Error('缺少 JKES token')
- }
- const useScheduled =
- distanceM != null &&
- paceSecPerKm != null &&
- Number.isFinite(Number(distanceM)) &&
- Number.isFinite(Number(paceSecPerKm))
- const pointsRaw = useScheduled
- ? buildPointsFromDistanceAndPace(rawPoints, distanceM, paceSecPerKm)
- : normalizePathPointsLegacy(rawPoints)
- const runStartMs = Date.now()
- const points = remapDeviceTimes(pointsRaw, runStartMs)
- if (useScheduled) {
- log(
- `配速模式 目标 ${(Number(distanceM) / 1000).toFixed(2)}km pace=${paceSecPerKm}s/km 点数=${points.length}`
- )
- }
- const headers = {
- 'content-type': 'application/json',
- 'x-auth-token': String(token).trim(),
- 'user-agent': s.userAgent,
- referer: s.referer,
- 'Accept-Encoding': 'gzip,compress,br,deflate'
- }
- const axiosBase = buildAxiosConfig(headers)
- const postJson = async (pathSuffix, body) => {
- const url = `${baseUrl.replace(/\/$/, '')}${pathSuffix.startsWith('/') ? '' : '/'}${pathSuffix}`
- const res = await axios.post(url, body, axiosBase)
- const text = typeof res.data === 'object' ? JSON.stringify(res.data) : String(res.data)
- let json = res.data
- if (typeof json !== 'object' || json === null) {
- try {
- json = JSON.parse(text)
- } catch {
- throw new Error(`JKES 非 JSON 响应 ${res.status}: ${String(text).slice(0, 200)}`)
- }
- }
- if (isJkesLoginExpiredPayload(json)) {
- throw makeJkesLoginExpiredError(json)
- }
- if (res.status !== 200 || json.code !== 0) {
- const err = new Error(`JKES 请求失败 ${res.status} ${pathSuffix}: ${String(text).slice(0, 500)}`)
- err.retryable = res.status >= 500 || res.status === 0
- throw err
- }
- return json
- }
- const startJson = await postJson('/health/runRecord/startRecord/0', {
- deviceTime: runStartMs,
- latitude: points[0].latitude,
- longitude: points[0].longitude,
- accuracy: defaultAccuracy,
- speed: points[0].speed >= 0 ? points[0].speed : -1
- })
- const recordId = startJson.data.info.id
- const chunks = chunkPoints(points, firstBatchSize, batchSize)
- const estMs = estimateRunWallClockMs(points)
- log(`已开始跑步 recordId=${recordId} 预计耗时${formatMinutesSeconds(estMs)}`)
- const calcState = {
- runStartMs,
- nextCalcDueDeviceTime: runStartMs + CALC_INTERVAL_MS
- }
- async function flushCalcThroughDeviceTime(throughDeviceTime) {
- while (calcState.nextCalcDueDeviceTime <= throughDeviceTime) {
- await postJson(`/health/runRecord/calc/${recordId}`, {})
- calcState.nextCalcDueDeviceTime += CALC_INTERVAL_MS
- }
- }
- const uploadedPayloadPoints = []
- let prevSegmentEndDeviceTime = null
- for (let c = 0; c < chunks.length; c++) {
- const chunk = chunks[c]
- if (c > 0) {
- const gap = chunk[0].deviceTime - prevSegmentEndDeviceTime
- const waitMs = Math.max(0, gap)
- if (waitMs > 0) {
- await sleep(waitMs)
- }
- }
- const tStart = chunk[0].deviceTime
- const tEnd = chunk[chunk.length - 1].deviceTime
- await flushCalcThroughDeviceTime(tStart)
- const batch = chunk.map((p) => toGpsPayloadPoint(p, { altitude, defaultAccuracy }))
- await postJson(`/health/runRecord/gps/${recordId}`, batch)
- uploadedPayloadPoints.push(...batch)
- await flushCalcThroughDeviceTime(tEnd)
- const intraMs = Math.max(0, tEnd - tStart)
- if (intraMs > 0) {
- await sleep(intraMs)
- }
- prevSegmentEndDeviceTime = tEnd
- }
- await postJson(`/health/runRecord/pause/${recordId}`, {})
- const endJson = await postJson(`/health/runRecord/end/${recordId}`, {})
- log(`ID:${recordId} 跑步已结束`)
- return { recordId, endJson, runStartMs, uploadedPayloadPoints }
- }
- module.exports = {
- runJkesRecord,
- /** @deprecated 仅兼容旧脚本;Worker 已改用 distanceM + paceSecPerKm */
- normalizePathPoints: normalizePathPointsLegacy,
- buildPointsFromDistanceAndPace,
- get DEFAULT_BASE() {
- return normalizeApiBase(getJkesSettings().apiBase)
- }
- }
|