runRecord.js 14 KB

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