runRecord.js 13 KB

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