/** * JKES 校园跑:GPS / calc / pause / end(对齐 jkes_test/simulateRun.js) * - 默认按「目标距离 + 配速」由闭合轨迹几何展开时间与速度,不依赖 path_data 中存的 distance/点内配速 * - 结束前必须先 pause,否则服务端记为无效 */ const axios = require('axios') const https = require('https') const Redis = require('../DataBase/Redis.js') const jkesRedisKeys = require('./redisKeys.js') 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) await Redis.set(jkesRedisKeys.lepaoSchedule(recordId), JSON.stringify({current: c, total: chunks.length}), { EX: 60 * 60 * 3 }) 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) } }