|
|
@@ -0,0 +1,403 @@
|
|
|
+/**
|
|
|
+ * 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 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))
|
|
|
+}
|
|
|
+
|
|
|
+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 (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
|
|
|
+ log(`已开始跑步 recordId=${recordId}`)
|
|
|
+
|
|
|
+ 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 chunks = chunkPoints(points, firstBatchSize, batchSize)
|
|
|
+ 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) {
|
|
|
+ log(`等待 ${waitMs}ms(批次间隔)`)
|
|
|
+ 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)
|
|
|
+ log(`已上传 GPS 批次 ${c + 1}/${chunks.length}`)
|
|
|
+
|
|
|
+ await flushCalcThroughDeviceTime(tEnd)
|
|
|
+
|
|
|
+ const intraMs = Math.max(0, tEnd - tStart)
|
|
|
+ if (intraMs > 0) {
|
|
|
+ log(`等待 ${intraMs}ms(批内时长)`)
|
|
|
+ await sleep(intraMs)
|
|
|
+ }
|
|
|
+
|
|
|
+ prevSegmentEndDeviceTime = tEnd
|
|
|
+ }
|
|
|
+
|
|
|
+ await postJson(`/health/runRecord/pause/${recordId}`, {})
|
|
|
+ log('已暂停(结束跑步前必须暂停)')
|
|
|
+
|
|
|
+ const endJson = await postJson(`/health/runRecord/end/${recordId}`, {})
|
|
|
+ log('跑步已结束')
|
|
|
+ return { recordId, endJson, runStartMs, uploadedPayloadPoints }
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = {
|
|
|
+ runJkesRecord,
|
|
|
+ /** @deprecated 仅兼容旧脚本;Worker 已改用 distanceM + paceSecPerKm */
|
|
|
+ normalizePathPoints: normalizePathPointsLegacy,
|
|
|
+ buildPointsFromDistanceAndPace,
|
|
|
+ get DEFAULT_BASE() {
|
|
|
+ return normalizeApiBase(getJkesSettings().apiBase)
|
|
|
+ }
|
|
|
+}
|