runRecord.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. /**
  2. * JKES 校园跑:GPS / calc / pause / end(对齐 jkes_test/simulateRun.js)
  3. * - 默认按「目标距离 + 配速」由闭合轨迹几何展开时间与速度,不依赖 path_data 中存的 distance/点内配速
  4. * - 结束前必须先 pause,否则服务端记为无效
  5. */
  6. const Redis = require('../DataBase/Redis.js')
  7. const jkesRedisKeys = require('./redisKeys.js')
  8. const { getJkesSettings, normalizeApiBase } = require('./jkesSettings')
  9. const { postJkes } = require('./jkesHttp')
  10. const {
  11. isJkesLoginExpiredPayload,
  12. makeJkesLoginExpiredError
  13. } = require('./request')
  14. const CALC_INTERVAL_MS = 50000
  15. const R_EARTH = 6371000
  16. function toRad(d) {
  17. return (d * Math.PI) / 180
  18. }
  19. function haversineM(lat1, lon1, lat2, lon2) {
  20. const la1 = toRad(lat1)
  21. const la2 = toRad(lat2)
  22. const dLat = toRad(lat2 - lat1)
  23. const dLon = toRad(lon2 - lon1)
  24. const h =
  25. Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2
  26. return 2 * R_EARTH * Math.asin(Math.min(1, Math.sqrt(h)))
  27. }
  28. function interpolateLngLat(a, b, t) {
  29. return {
  30. latitude: a.latitude + (b.latitude - a.latitude) * t,
  31. longitude: a.longitude + (b.longitude - a.longitude) * t
  32. }
  33. }
  34. /** 首末点距离超过约 3m 时视为未闭合,补上一段回到起点 */
  35. function ensureClosedLoop(loop) {
  36. if (loop.length < 2) {
  37. throw new Error('闭合轨迹至少 2 个点')
  38. }
  39. const first = loop[0]
  40. const last = loop[loop.length - 1]
  41. const gap = haversineM(first.latitude, first.longitude, last.latitude, last.longitude)
  42. if (gap > 3) {
  43. return loop.concat([{ latitude: first.latitude, longitude: first.longitude }])
  44. }
  45. return loop
  46. }
  47. /** 库内 path_data:支持 latitude/longitude 或 a/o */
  48. function pathRawToLoop(raw) {
  49. if (!Array.isArray(raw) || raw.length === 0) {
  50. throw new Error('轨迹数据应为非空数组')
  51. }
  52. const loop = raw.map((p, i) => {
  53. if (typeof (p.latitude ?? p.lat) === 'number') {
  54. return {
  55. latitude: p.latitude ?? p.lat,
  56. longitude: p.longitude ?? p.lon ?? p.o
  57. }
  58. }
  59. if (typeof p.a === 'number' && typeof p.o === 'number') {
  60. return { latitude: p.a, longitude: p.o }
  61. }
  62. throw new Error(`第 ${i + 1} 个点无有效坐标(需 latitude/longitude 或 a/o)`)
  63. })
  64. return ensureClosedLoop(loop)
  65. }
  66. /**
  67. * 沿闭合环路走够 targetM 米;点序列含起点,末点为终点(可能落在边上插值)
  68. * (与 simulateRun.js 一致)
  69. */
  70. function expandLoopToDistance(loop, targetM) {
  71. const n = loop.length
  72. if (n < 2) throw new Error('闭合轨迹至少 2 个点')
  73. const out = [{ latitude: loop[0].latitude, longitude: loop[0].longitude }]
  74. let cum = 0
  75. let vi = 0
  76. let guard = 0
  77. const maxGuard = Math.ceil(targetM * 3) + n * 200
  78. while (cum < targetM - 1e-6 && guard++ < maxGuard) {
  79. const next = (vi + 1) % n
  80. const a = loop[vi]
  81. const b = loop[next]
  82. const edge = haversineM(a.latitude, a.longitude, b.latitude, b.longitude)
  83. if (edge < 1e-9) {
  84. vi = next
  85. continue
  86. }
  87. const remain = targetM - cum
  88. if (edge <= remain + 1e-9) {
  89. cum += edge
  90. vi = next
  91. out.push({ latitude: b.latitude, longitude: b.longitude })
  92. if (cum >= targetM - 1e-9) break
  93. } else {
  94. const t = remain / edge
  95. const p = interpolateLngLat(a, b, t)
  96. out.push({ latitude: p.latitude, longitude: p.longitude })
  97. cum = targetM
  98. break
  99. }
  100. }
  101. if (guard >= maxGuard) {
  102. throw new Error('展开轨迹失败:边长过短或无法沿环前进,请检查轨迹是否闭合')
  103. }
  104. return out
  105. }
  106. /** 按配速生成 deviceTimeRaw(ms) 与 speed(m/s) */
  107. function scheduleByPace(points, paceSecPerKm) {
  108. const secPerM = paceSecPerKm / 1000
  109. const rows = []
  110. let tMs = 0
  111. for (let i = 0; i < points.length; i++) {
  112. const p = points[i]
  113. if (i === 0) {
  114. rows.push({
  115. latitude: p.latitude,
  116. longitude: p.longitude,
  117. deviceTimeRaw: 0,
  118. speed: -1,
  119. steps: 0
  120. })
  121. continue
  122. }
  123. const prev = points[i - 1]
  124. const dM = haversineM(prev.latitude, prev.longitude, p.latitude, p.longitude)
  125. const dtMs = Math.max(0, dM * secPerM * 1000)
  126. tMs += dtMs
  127. const v = dtMs > 0 ? dM / (dtMs / 1000) : -1
  128. rows.push({
  129. latitude: p.latitude,
  130. longitude: p.longitude,
  131. deviceTimeRaw: Math.round(tMs),
  132. speed: v >= 0 && v < 0.05 ? -1 : Math.round(v * 100) / 100,
  133. steps: 0
  134. })
  135. }
  136. return rows
  137. }
  138. function buildPointsFromDistanceAndPace(raw, distanceM, paceSecPerKm) {
  139. const dm = Number(distanceM)
  140. const pace = Number(paceSecPerKm)
  141. if (!Number.isFinite(dm) || dm < 1) {
  142. throw new Error('distanceM 无效')
  143. }
  144. if (!Number.isFinite(pace) || pace < 10) {
  145. throw new Error('paceSecPerKm 无效(每公里秒数,建议 ≥120)')
  146. }
  147. const loop = pathRawToLoop(raw)
  148. const expanded = expandLoopToDistance(loop, dm)
  149. return scheduleByPace(expanded, pace)
  150. }
  151. /** 旧版 path.json:点内自带 d 时间轴与 s 速度 */
  152. function normalizePathPointsLegacy(raw) {
  153. if (!Array.isArray(raw) || raw.length === 0) {
  154. throw new Error('轨迹数据应为非空数组')
  155. }
  156. return raw.map((p, i) => {
  157. const d = String(p.d || '')
  158. const ts = parseInt(d.split(/\s+/)[0], 10)
  159. if (!Number.isFinite(ts)) {
  160. throw new Error(`第 ${i + 1} 个点缺少有效 d 字段时间戳(legacy 模式)`)
  161. }
  162. return {
  163. latitude: p.a,
  164. longitude: p.o,
  165. deviceTimeRaw: ts,
  166. speed: typeof p.s === 'number' ? p.s : -1,
  167. steps: typeof p.b === 'number' ? p.b : 0
  168. }
  169. })
  170. }
  171. function remapDeviceTimes(points, runStartMs) {
  172. const t0 = points[0].deviceTimeRaw
  173. return points.map((p) => ({
  174. ...p,
  175. deviceTime: runStartMs + (p.deviceTimeRaw - t0)
  176. }))
  177. }
  178. function toGpsPayloadPoint(p, opts) {
  179. const acc = opts.defaultAccuracy
  180. const spd = p.speed >= 0 ? p.speed : -1
  181. return {
  182. verticalAccuracy: 30,
  183. speed: spd,
  184. longitude: p.longitude,
  185. horizontalAccuracy: acc,
  186. provider: 'gps',
  187. steps: p.steps,
  188. latitude: p.latitude,
  189. accuracy: acc,
  190. direction: -1,
  191. altitude: opts.altitude,
  192. type: 'gcj02',
  193. deviceTime: p.deviceTime
  194. }
  195. }
  196. function chunkPoints(points, firstN, restN) {
  197. if (points.length === 0) return []
  198. const chunks = []
  199. const first = points.slice(0, firstN)
  200. if (first.length) chunks.push(first)
  201. let i = first.length
  202. while (i < points.length) {
  203. chunks.push(points.slice(i, i + restN))
  204. i += restN
  205. }
  206. return chunks
  207. }
  208. function sleep(ms) {
  209. return new Promise((r) => setTimeout(r, ms))
  210. }
  211. /** 根据轨迹首尾 deviceTime 得到预计跑完全程的等待时长(与循环内 sleep 总和一致,不含网络请求) */
  212. function estimateRunWallClockMs(points) {
  213. if (!Array.isArray(points) || points.length < 2) {
  214. return 0
  215. }
  216. return Math.max(0, points[points.length - 1].deviceTime - points[0].deviceTime)
  217. }
  218. function formatMinutesSeconds(ms) {
  219. const totalSec = Math.max(0, Math.round(ms / 1000))
  220. const m = Math.floor(totalSec / 60)
  221. const s = totalSec % 60
  222. if (m <= 0) {
  223. return `${s}秒`
  224. }
  225. return `${m}分${s}秒`
  226. }
  227. /**
  228. * @param {object} opts
  229. * @param {string} opts.token
  230. * @param {Array} opts.pathPoints path_data.data:经纬度环(a/o 或 latitude/longitude)
  231. * @param {number} [opts.distanceM] 目标跑步距离(米);与 paceSecPerKm 同时传入则走新版调度
  232. * @param {number} [opts.paceSecPerKm] 每公里用时(秒),如 390 ≈ 6:30/km
  233. * @param {string} [opts.baseUrl]
  234. * @param {number} [opts.batchSize=5]
  235. * @param {number} [opts.firstBatchSize=1]
  236. * @param {number} [opts.altitude]
  237. * @param {number} [opts.defaultAccuracy]
  238. * @param {function} [opts.log]
  239. * @param {'auto'|'direct'|'proxy'} [opts.outboundMode='auto']
  240. * @param {string} [opts.traceId]
  241. * @param {string} [opts.taskId]
  242. */
  243. async function runJkesRecord(opts) {
  244. const s = getJkesSettings()
  245. const {
  246. token,
  247. recordDbId,
  248. pathPoints: rawPoints,
  249. distanceM,
  250. paceSecPerKm,
  251. baseUrl = normalizeApiBase(s.apiBase),
  252. batchSize = 5,
  253. firstBatchSize = 1,
  254. altitude = s.gpsAltitude,
  255. defaultAccuracy = s.gpsDefaultAccuracy,
  256. log = () => { },
  257. outboundMode = 'auto',
  258. traceId = null,
  259. taskId = null
  260. } = opts
  261. if (!token || String(token).trim() === '') {
  262. throw new Error('缺少 JKES token')
  263. }
  264. const useScheduled =
  265. distanceM != null &&
  266. paceSecPerKm != null &&
  267. Number.isFinite(Number(distanceM)) &&
  268. Number.isFinite(Number(paceSecPerKm))
  269. const pointsRaw = useScheduled
  270. ? buildPointsFromDistanceAndPace(rawPoints, distanceM, paceSecPerKm)
  271. : normalizePathPointsLegacy(rawPoints)
  272. const runStartMs = Date.now()
  273. const points = remapDeviceTimes(pointsRaw, runStartMs)
  274. if (useScheduled) {
  275. log(
  276. `配速模式 目标 ${(Number(distanceM) / 1000).toFixed(2)}km pace=${paceSecPerKm}s/km 点数=${points.length}`
  277. )
  278. }
  279. const headers = {
  280. 'content-type': 'application/json',
  281. 'x-auth-token': String(token).trim(),
  282. 'user-agent': s.userAgent,
  283. referer: s.referer,
  284. 'Accept-Encoding': 'gzip,compress,br,deflate'
  285. }
  286. const requestTimeout = Math.max(120000, Number(s.requestTimeoutMs) || 0)
  287. const httpLogger = { info: (msg) => log(String(msg).replace(/^\[jkesHttp\]\s*/, '')) }
  288. const postJson = async (pathSuffix, body) => {
  289. const url = `${baseUrl.replace(/\/$/, '')}${pathSuffix.startsWith('/') ? '' : '/'}${pathSuffix}`
  290. const res = await postJkes(url, body, {
  291. headers,
  292. timeout: requestTimeout,
  293. validateStatus: () => true,
  294. outboundMode,
  295. traceId,
  296. taskId,
  297. logger: httpLogger
  298. })
  299. const text = typeof res.data === 'object' ? JSON.stringify(res.data) : String(res.data)
  300. let json = res.data
  301. if (typeof json !== 'object' || json === null) {
  302. try {
  303. json = JSON.parse(text)
  304. } catch {
  305. throw new Error(`JKES 非 JSON 响应 ${res.status}: ${String(text).slice(0, 200)}`)
  306. }
  307. }
  308. if (isJkesLoginExpiredPayload(json)) {
  309. throw makeJkesLoginExpiredError(json)
  310. }
  311. if (res.status !== 200 || json.code !== 0) {
  312. const err = new Error(`JKES 请求失败 ${res.status} ${pathSuffix}: ${String(text).slice(0, 500)}`)
  313. err.retryable = res.status >= 500 || res.status === 0
  314. throw err
  315. }
  316. return json
  317. }
  318. const startJson = await postJson('/health/runRecord/startRecord/0', {
  319. deviceTime: runStartMs,
  320. latitude: points[0].latitude,
  321. longitude: points[0].longitude,
  322. accuracy: defaultAccuracy,
  323. speed: points[0].speed >= 0 ? points[0].speed : -1
  324. })
  325. const recordId = startJson.data.info.id
  326. const chunks = chunkPoints(points, firstBatchSize, batchSize)
  327. const estMs = estimateRunWallClockMs(points)
  328. log(`已开始跑步 recordId=${recordId} 预计耗时${formatMinutesSeconds(estMs)}`)
  329. const calcState = {
  330. runStartMs,
  331. nextCalcDueDeviceTime: runStartMs + CALC_INTERVAL_MS
  332. }
  333. async function flushCalcThroughDeviceTime(throughDeviceTime) {
  334. while (calcState.nextCalcDueDeviceTime <= throughDeviceTime) {
  335. await postJson(`/health/runRecord/calc/${recordId}`, {})
  336. calcState.nextCalcDueDeviceTime += CALC_INTERVAL_MS
  337. }
  338. }
  339. const uploadedPayloadPoints = []
  340. let prevSegmentEndDeviceTime = null
  341. for (let c = 0; c < chunks.length; c++) {
  342. const chunk = chunks[c]
  343. if (c > 0) {
  344. const gap = chunk[0].deviceTime - prevSegmentEndDeviceTime
  345. const waitMs = Math.max(0, gap)
  346. if (waitMs > 0) {
  347. await sleep(waitMs)
  348. }
  349. }
  350. const tStart = chunk[0].deviceTime
  351. const tEnd = chunk[chunk.length - 1].deviceTime
  352. await flushCalcThroughDeviceTime(tStart)
  353. const batch = chunk.map((p) => toGpsPayloadPoint(p, { altitude, defaultAccuracy }))
  354. await postJson(`/health/runRecord/gps/${recordId}`, batch)
  355. uploadedPayloadPoints.push(...batch)
  356. await flushCalcThroughDeviceTime(tEnd)
  357. await Redis.set(jkesRedisKeys.lepaoSchedule(recordDbId), JSON.stringify({current: c, total: chunks.length}), { EX: 60 * 60 * 3 })
  358. const intraMs = Math.max(0, tEnd - tStart)
  359. if (intraMs > 0) {
  360. await sleep(intraMs)
  361. }
  362. prevSegmentEndDeviceTime = tEnd
  363. }
  364. await postJson(`/health/runRecord/pause/${recordId}`, {})
  365. const endJson = await postJson(`/health/runRecord/end/${recordId}`, {})
  366. log(`ID:${recordId} 跑步已结束`)
  367. await Redis.del(jkesRedisKeys.lepaoSchedule(recordDbId))
  368. return { recordId, endJson, runStartMs, uploadedPayloadPoints }
  369. }
  370. module.exports = {
  371. runJkesRecord,
  372. /** @deprecated 仅兼容旧脚本;Worker 已改用 distanceM + paceSecPerKm */
  373. normalizePathPoints: normalizePathPointsLegacy,
  374. buildPointsFromDistanceAndPace,
  375. get DEFAULT_BASE() {
  376. return normalizeApiBase(getJkesSettings().apiBase)
  377. }
  378. }