runRecord.js 13 KB

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