Browse Source

✨ feat: 完善重工乐跑

Pchen. 1 month ago
parent
commit
49be1702b0
35 changed files with 2279 additions and 1145 deletions
  1. 18 6
      apis/Corn/StartAutoLepao.js
  2. 17 5
      apis/Corn/StartLepao.js
  3. 12 8
      apis/Lepao/Account/AddAccount.js
  4. 4 0
      apis/Lepao/Account/Admin/GetAccountList.js
  5. 12 0
      apis/Lepao/Account/DeleteAccount.js
  6. 6 0
      apis/Lepao/Account/GetAccount.js
  7. 10 152
      apis/Lepao/Account/UpdateAccount/UpdateAccount.js
  8. 10 154
      apis/Lepao/Account/UpdateAccount/UpdateAccountAndroidApp.js
  9. 10 156
      apis/Lepao/Account/UpdateAccount/UpdateAccountiPhone.js
  10. 21 64
      apis/Lepao/Account/UpdateSelfAccount.js
  11. 99 0
      apis/Lepao/GetJkesRunOverview.js
  12. 4 1
      apis/Lepao/Record/Admin/GetLepaoRecords.js
  13. 74 16
      apis/Lepao/Record/Admin/GetRecordDetail.js
  14. 4 1
      apis/Lepao/Record/GetLepaoRecords.js
  15. 75 16
      apis/Lepao/Record/GetRecordDetail.js
  16. 38 8
      apis/Lepao/SingleRun.js
  17. 10 10
      apis/User/Admin/GetQueueTasks.js
  18. 430 338
      lib/Lepao/Worker.js
  19. 5 63
      plugin/Lepao/Crypto.js
  20. 0 115
      plugin/Lepao/runforgeSetZoneProbe.js
  21. 32 0
      plugin/jkes/formatRecordRow.js
  22. 47 0
      plugin/jkes/jkesSettings.js
  23. 204 0
      plugin/jkes/monthPolicy.js
  24. 65 0
      plugin/jkes/paceUtils.js
  25. 39 0
      plugin/jkes/redisKeys.js
  26. 119 0
      plugin/jkes/request.js
  27. 403 0
      plugin/jkes/runRecord.js
  28. 207 0
      plugin/jkes/stats.js
  29. 140 0
      plugin/jkes/syncLepaoAccountFromToken.js
  30. 123 0
      plugin/jkes/updateAccountCore.js
  31. 15 7
      plugin/mq/enqueueLepaoStartRun.js
  32. 16 0
      plugin/mq/jkesMqNames.js
  33. 3 14
      plugin/mq/lepaoAutoScheduleRedis.js
  34. 5 4
      plugin/mq/lepaoSchedulePublisher.js
  35. 2 7
      plugin/mq/runforgeTaskMq.js

+ 18 - 6
apis/Corn/StartAutoLepao.js

@@ -4,8 +4,10 @@ const Redis = require('../../plugin/DataBase/Redis')
 const mq = require('../../plugin/mq')
 const mq = require('../../plugin/mq')
 const { assertRunforgeTaskIngress, publishRunforgeTask } = require('../../plugin/mq/runforgeTaskMq')
 const { assertRunforgeTaskIngress, publishRunforgeTask } = require('../../plugin/mq/runforgeTaskMq')
 const { scheduleDelayedRunforgeTask } = require('../../plugin/mq/lepaoAutoScheduleRedis')
 const { scheduleDelayedRunforgeTask } = require('../../plugin/mq/lepaoAutoScheduleRedis')
+const jkesRedisKeys = require('../../plugin/jkes/redisKeys')
 
 
 const { BaseStdResponse } = require("../../BaseStdResponse");
 const { BaseStdResponse } = require("../../BaseStdResponse");
+const { planJkesAutoRun } = require('../../plugin/jkes/monthPolicy')
 
 
 class StartAutoLepao extends API {
 class StartAutoLepao extends API {
     constructor() {
     constructor() {
@@ -26,7 +28,7 @@ class StartAutoLepao extends API {
             const hour = new Date().getHours()
             const hour = new Date().getHours()
             this.logger.info('开始执行自动乐跑任务')
             this.logger.info('开始执行自动乐跑任务')
             let sql = `
             let sql = `
-                        SELECT name, student_num
+                        SELECT name, student_num, auto_day, token, target_count
                         FROM lepao_account
                         FROM lepao_account
                         WHERE auto_run = 1
                         WHERE auto_run = 1
                             AND (auto_time = ? OR (auto_time = -1 AND today_auto_time = ?))
                             AND (auto_time = ? OR (auto_time = -1 AND today_auto_time = ?))
@@ -36,7 +38,6 @@ class StartAutoLepao extends API {
             if (!r)
             if (!r)
                 return this.logger.error('获取自动乐跑账号失败!')
                 return this.logger.error('获取自动乐跑账号失败!')
 
 
-            // 为本小时内随机打散投递时间,减轻瞬时并发(0 ~ 当前小时剩余毫秒数)
             const nowMs = Date.now()
             const nowMs = Date.now()
             const hourEnd = new Date()
             const hourEnd = new Date()
             hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0)
             hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0)
@@ -52,14 +53,23 @@ class StartAutoLepao extends API {
             }
             }
 
 
             for (const item of r) {
             for (const item of r) {
-                const { name, student_num } = item
+                const { name, student_num, auto_day, token, target_count } = item
 
 
-                const isSuccess = await Redis.get(`lepaoSuccess:${student_num}`)
+                const isSuccess = await Redis.get(jkesRedisKeys.lepaoSuccess(student_num))
                 if (isSuccess) {
                 if (isSuccess) {
                     this.logger.info(`${name}(${student_num})当天已乐跑成功,不执行自动乐跑`)
                     this.logger.info(`${name}(${student_num})当天已乐跑成功,不执行自动乐跑`)
                     continue
                     continue
                 }
                 }
 
 
+                const plan = await planJkesAutoRun(student_num, auto_day, token, {
+                    monthTargetKm: target_count,
+                    stopAfterMinimum: true
+                })
+                if (!plan.run) {
+                    this.logger.info(`${name}(${student_num}) JKES 自动乐跑跳过:${plan.reason}`)
+                    continue
+                }
+
                 const delayMs = spreadWindowMs > 0 ? Math.floor(Math.random() * spreadWindowMs) : 0
                 const delayMs = spreadWindowMs > 0 ? Math.floor(Math.random() * spreadWindowMs) : 0
                 const fireAt = nowMs + delayMs
                 const fireAt = nowMs + delayMs
                 const taskId = `lepao:auto:${fireAt}:${student_num}`
                 const taskId = `lepao:auto:${fireAt}:${student_num}`
@@ -68,7 +78,9 @@ class StartAutoLepao extends API {
                     type: 'lepao.startRun',
                     type: 'lepao.startRun',
                     data: {
                     data: {
                         taskId,
                         taskId,
-                        account: student_num
+                        account: student_num,
+                        targetKm: plan.targetKm,
+                        autoDoubleSlot: plan.targetKm >= 2
                     },
                     },
                     retry: 0
                     retry: 0
                 }
                 }
@@ -81,7 +93,7 @@ class StartAutoLepao extends API {
                             delayMs
                             delayMs
                         })
                         })
                         this.logger.info(
                         this.logger.info(
-                            `${name}(${student_num})已写入 Redis 调度(约 ${Math.round(delayMs / 1000)}s 后进 MQ 主队列)`
+                            `${name}(${student_num})已写入 Redis 调度(约 ${Math.round(delayMs / 1000)}s 后进 MQ)`
                         )
                         )
                     } catch (err) {
                     } catch (err) {
                         this.logger.error(`${name}(${student_num})Redis 调度失败:${err.message || err}`)
                         this.logger.error(`${name}(${student_num})Redis 调度失败:${err.message || err}`)

+ 17 - 5
apis/Corn/StartLepao.js

@@ -7,8 +7,9 @@ const {
     publishRunforgeTask
     publishRunforgeTask
 } = require('../../plugin/mq/runforgeTaskMq')
 } = require('../../plugin/mq/runforgeTaskMq')
 const { BaseStdResponse } = require("../../BaseStdResponse")
 const { BaseStdResponse } = require("../../BaseStdResponse")
+const { planJkesAutoRun } = require('../../plugin/jkes/monthPolicy')
+const jkesRedisKeys = require('../../plugin/jkes/redisKeys')
 
 
-// 出现异常情况时补充乐跑
 class StartLepao extends API {
 class StartLepao extends API {
     constructor() {
     constructor() {
         super();
         super();
@@ -35,7 +36,7 @@ class StartLepao extends API {
 
 
             const day = new Date().getDay()
             const day = new Date().getDay()
             let sql = `
             let sql = `
-                        SELECT name, student_num
+                        SELECT name, student_num, auto_day, token, target_count
                         FROM lepao_account
                         FROM lepao_account
                         WHERE auto_run = 1 AND state = 1
                         WHERE auto_run = 1 AND state = 1
                             AND JSON_CONTAINS(auto_day, CAST(? AS JSON))
                             AND JSON_CONTAINS(auto_day, CAST(? AS JSON))
@@ -46,15 +47,24 @@ class StartLepao extends API {
                 return this.logger.error('获取补充乐跑账号失败!')
                 return this.logger.error('获取补充乐跑账号失败!')
 
 
             for (const item of r) {
             for (const item of r) {
-                const { name, student_num } = item
+                const { name, student_num, auto_day, token, target_count } = item
                 this.logger.info(`${name}(${student_num})开始补充乐跑`)
                 this.logger.info(`${name}(${student_num})开始补充乐跑`)
 
 
-                const isSuccess = await Redis.get(`lepaoSuccess:${student_num}`)
+                const isSuccess = await Redis.get(jkesRedisKeys.lepaoSuccess(student_num))
                 if (isSuccess) {
                 if (isSuccess) {
                     this.logger.info(`${name}(${student_num})当天已乐跑成功,不执行补充乐跑`)
                     this.logger.info(`${name}(${student_num})当天已乐跑成功,不执行补充乐跑`)
                     continue
                     continue
                 }
                 }
 
 
+                const plan = await planJkesAutoRun(student_num, auto_day, token, {
+                    monthTargetKm: target_count,
+                    stopAfterMinimum: true
+                })
+                if (!plan.run) {
+                    this.logger.info(`${name}(${student_num}) JKES 补充乐跑跳过:${plan.reason}`)
+                    continue
+                }
+
                 try {
                 try {
                     const channel = await mq.getChannel('lepao_corn')
                     const channel = await mq.getChannel('lepao_corn')
                     await assertRunforgeTaskIngress(channel, this.logger)
                     await assertRunforgeTaskIngress(channel, this.logger)
@@ -65,7 +75,9 @@ class StartLepao extends API {
                         type: 'lepao.startRun',
                         type: 'lepao.startRun',
                         data: {
                         data: {
                             taskId,
                             taskId,
-                            account: student_num
+                            account: student_num,
+                            targetKm: plan.targetKm,
+                            autoDoubleSlot: plan.targetKm >= 2
                         },
                         },
                         retry: 0
                         retry: 0
                     }
                     }

+ 12 - 8
apis/Lepao/Account/AddAccount.js

@@ -43,10 +43,11 @@ class AddAccount extends API {
                 ...BaseStdResponse.MISSING_PARAMETER
                 ...BaseStdResponse.MISSING_PARAMETER
             })
             })
 
 
-        if (isNaN(target_count) || target_count < 0 || target_count > 99) {
+        const targetKm = Number(target_count)
+        if (!Number.isFinite(targetKm) || targetKm < 0 || targetKm > 200) {
             return res.json({
             return res.json({
                 ...BaseStdResponse.ERR,
                 ...BaseStdResponse.ERR,
-                msg: '乐跑目标次数不在合法范围内'
+                msg: '乐跑目标里程(公里)不在合法范围内(0–200)'
             })
             })
         }
         }
 
 
@@ -93,8 +94,11 @@ class AddAccount extends API {
         }
         }
 
 
         if (countRows.length !== 0) {
         if (countRows.length !== 0) {
-            if (auto_run === 1 && countRows[0].total_num >= target_count && target_count !== 0)
-                return res.json({ ...BaseStdResponse.ERR, msg: '该账号累计跑步次数已达到预设目标次数,请尝试增大目标次数后再试' })
+            if (auto_run === 1 && countRows[0].total_num >= targetKm && targetKm !== 0)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '该账号累计跑步里程已达到或超过预设目标里程,请增大目标后再试'
+                })
         }
         }
 
 
         const time = new Date().getTime()
         const time = new Date().getTime()
@@ -104,13 +108,13 @@ class AddAccount extends API {
         if (!id) {
         if (!id) {
             if (countRows.length !== 0) {
             if (countRows.length !== 0) {
                 sql = 'UPDATE lepao_account SET create_user = ?, email = ?, area = ?, auto_time = ?, auto_run = ?, target_count = ?, create_time = ?, update_time = ?, notes = ?, auto_day = ?, notice_type = ? WHERE id = ?'
                 sql = 'UPDATE lepao_account SET create_user = ?, email = ?, area = ?, auto_time = ?, auto_run = ?, target_count = ?, create_time = ?, update_time = ?, notes = ?, auto_day = ?, notice_type = ? WHERE id = ?'
-                r = await db.query(sql, [uuid, email ?? '', area, auto_time, auto_run, target_count, time, time, notes ?? '', JSON.stringify(auto_day), notice_type, countRows[0].id])
+                r = await db.query(sql, [uuid, email ?? '', area, auto_time, auto_run, targetKm, time, time, notes ?? '', JSON.stringify(auto_day), notice_type, countRows[0].id])
             }
             }
             else {
             else {
                 const bind_code = await this.generateCode()
                 const bind_code = await this.generateCode()
 
 
                 sql = 'INSERT INTO lepao_account (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, notes, auto_day, notice_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
                 sql = 'INSERT INTO lepao_account (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, notes, auto_day, notice_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
-                r = await db.query(sql, [student_num, email ?? '', area, auto_time, auto_run, target_count, uuid, time, notes ?? '', JSON.stringify(auto_day), notice_type])
+                r = await db.query(sql, [student_num, email ?? '', area, auto_time, auto_run, targetKm, uuid, time, notes ?? '', JSON.stringify(auto_day), notice_type])
 
 
                 let faceSql = 'INSERT INTO lepao_extra (student_num, bind_code) VALUES (?, ?)'
                 let faceSql = 'INSERT INTO lepao_extra (student_num, bind_code) VALUES (?, ?)'
                 let faceRows = await db.query(faceSql, [student_num, bind_code])
                 let faceRows = await db.query(faceSql, [student_num, bind_code])
@@ -119,7 +123,7 @@ class AddAccount extends API {
             }
             }
         } else {
         } else {
             sql = 'UPDATE lepao_account SET student_num = ?, email = ?, area = ?, auto_time = ?, target_count = ?, auto_run = ?, notes = ?, auto_day = ?, update_time = ?, notice_type = ? WHERE id = ?'
             sql = 'UPDATE lepao_account SET student_num = ?, email = ?, area = ?, auto_time = ?, target_count = ?, auto_run = ?, notes = ?, auto_day = ?, update_time = ?, notice_type = ? WHERE id = ?'
-            r = await db.query(sql, [student_num, email ?? '', area, auto_time, target_count, auto_run, notes ?? '', JSON.stringify(auto_day), time, notice_type, id])
+            r = await db.query(sql, [student_num, email ?? '', area, auto_time, targetKm, auto_run, notes ?? '', JSON.stringify(auto_day), time, notice_type, id])
         }
         }
 
 
         try {
         try {
@@ -144,7 +148,7 @@ class AddAccount extends API {
                     ...BaseStdResponse.OK,
                     ...BaseStdResponse.OK,
                     id: r.insertId,
                     id: r.insertId,
                     data: {
                     data: {
-                        student_num, email, id, area, auto_time, auto_run, target_count, auto_day, notice_type, notes,
+                        student_num, email, id, area, auto_time, auto_run, target_count: targetKm, auto_day, notice_type, notes,
                         bind_code: selectRows.length !== 0 ? selectRows[0].bind_code : undefined,
                         bind_code: selectRows.length !== 0 ? selectRows[0].bind_code : undefined,
                         bot_account: selectRows.length !== 0 ? selectRows[0].bot_account : undefined
                         bot_account: selectRows.length !== 0 ? selectRows[0].bot_account : undefined
                     }
                     }

+ 4 - 0
apis/Lepao/Account/Admin/GetAccountList.js

@@ -65,6 +65,10 @@ class GetAccountList extends API {
                     l.sex,
                     l.sex,
                     l.area,
                     l.area,
                     l.grade_id,
                     l.grade_id,
+                    l.class_id,
+                    l.identifyCode,
+                    l.mobileNumber,
+                    l.homeAddr,
                     l.total_num,
                     l.total_num,
                     l.term_num,
                     l.term_num,
                     l.auto_run,
                     l.auto_run,

+ 12 - 0
apis/Lepao/Account/DeleteAccount.js

@@ -1,5 +1,7 @@
 const API = require("../../../lib/API");
 const API = require("../../../lib/API");
 const db = require("../../../plugin/DataBase/db");
 const db = require("../../../plugin/DataBase/db");
+const Redis = require("../../../plugin/DataBase/Redis");
+const jkesRedisKeys = require("../../../plugin/jkes/redisKeys");
 const AccessControl = require("../../../lib/AccessControl");
 const AccessControl = require("../../../lib/AccessControl");
 const { BaseStdResponse } = require("../../../BaseStdResponse");
 const { BaseStdResponse } = require("../../../BaseStdResponse");
 
 
@@ -42,11 +44,21 @@ class DeleteAccount extends API {
                 })
                 })
         }
         }
 
 
+        let snRows = await db.query('SELECT student_num FROM lepao_account WHERE id = ?', [id])
+        const sn = snRows?.[0]?.student_num
+
         let sql = 'UPDATE lepao_account SET create_user = NULL, auto_run = 0 WHERE id = ?'
         let sql = 'UPDATE lepao_account SET create_user = NULL, auto_run = 0 WHERE id = ?'
         let r = await db.query(sql, [id])
         let r = await db.query(sql, [id])
 
 
         try {
         try {
             if (r && r.affectedRows > 0) {
             if (r && r.affectedRows > 0) {
+                if (sn) {
+                    try {
+                        await Redis.del(jkesRedisKeys.runnerFlag(sn))
+                    } catch (e) {
+                        this.logger.error(`解绑清理 jkes_runner 失败 ${sn}: ${e.message || e}`)
+                    }
+                }
                 res.json({
                 res.json({
                     ...BaseStdResponse.OK
                     ...BaseStdResponse.OK
                 })
                 })

+ 6 - 0
apis/Lepao/Account/GetAccount.js

@@ -58,6 +58,10 @@ class GetAccount extends API {
                     l.sex,
                     l.sex,
                     l.user_avatar,
                     l.user_avatar,
                     l.grade_id,
                     l.grade_id,
+                    l.class_id,
+                    l.identifyCode,
+                    l.mobileNumber,
+                    l.homeAddr,
                     l.email,
                     l.email,
                     l.auto_run,
                     l.auto_run,
                     l.today_auto_time,
                     l.today_auto_time,
@@ -65,6 +69,8 @@ class GetAccount extends API {
                     l.target_count,
                     l.target_count,
                     l.auto_day,
                     l.auto_day,
                     l.notice_type,
                     l.notice_type,
+                    l.userAgent,
+                    l.deviceModel,
                     f.bind_code,
                     f.bind_code,
                     f.bot_account,
                     f.bot_account,
                     f.create_time AS face_time,
                     f.create_time AS face_time,

+ 10 - 152
apis/Lepao/Account/UpdateAccount/UpdateAccount.js

@@ -1,17 +1,12 @@
-const API = require("../../../../lib/API.js")
-const db = require("../../../../plugin/DataBase/db.js")
-const EmailTemplate = require('../../../../plugin/Email/emailTemplate.js')
-const { enqueueLepaoStartRun } = require('../../../../plugin/mq/enqueueLepaoStartRun')
-const mq = require('../../../../plugin/mq')
-const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
-const { dataDecrypt } = require('../../../../plugin/Lepao/Crypto')
+const API = require('../../../../lib/API.js')
+const mqNames = require('../../../../plugin/mq/jkesMqNames.js')
+const { executeLepaoTokenUpdate } = require('../../../../plugin/jkes/updateAccountCore.js')
 
 
-// 客户端上传数据接口
 class UpdateAccount extends API {
 class UpdateAccount extends API {
     constructor() {
     constructor() {
         super()
         super()
 
 
-        this.messageQueue = 'runforge_message_queue'
+        this.messageQueue = mqNames.queueNotify
 
 
         this.noEncrypt()
         this.noEncrypt()
         this.setPath('/Lepao/UpdateAccount')
         this.setPath('/Lepao/UpdateAccount')
@@ -19,149 +14,12 @@ class UpdateAccount extends API {
     }
     }
 
 
     async onRequest(req, res) {
     async onRequest(req, res) {
-        let { reqData, resData } = req.body
-
-        if ([reqData, resData].some(value => value === '' || value === null || value === undefined))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER
-            })
-
-        try {
-            const userData = JSON.parse(dataDecrypt(reqData) || '{}')
-            if (!userData || Object.keys(userData).length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            const { token } = userData
-            if ([token].some(value => value === '' || value === null || value === undefined))
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '未提取出用户登录信息,请重试'
-                })
-
-            const userData2 = JSON.parse(dataDecrypt(resData) || '{}')
-            if (!userData2 || Object.keys(userData2).length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            const { uid, user_avatar, student_num, school_id, grade_id, class_id, sex, name, academy_name } = userData2
-            if ([uid, student_num, school_id, grade_id, class_id, sex, name, academy_name].some(value => value === '' || value === null || value === undefined))
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '未提取出用户登录信息,请重试'
-                })
-
-            let findSql = `
-                SELECT 
-                    a.email, a.create_user, a.auto_run, a.auto_day, a.notice_type, e.bot_umo
-                FROM
-                    lepao_account a
-                LEFT JOIN
-                    lepao_extra e
-                ON
-                    a.student_num = e.student_num
-                WHERE
-                    a.student_num = ? AND a.create_user IS NOT NULL
-                `
-
-            let findRows = await db.query(findSql, [student_num])
-            if (!findRows)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            if (findRows.length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '该乐跑账号尚未在RunForge系统中添加,请先前往 https://xxoo365.top/ 添加你的账户'
-                })
-            const time = new Date().getTime()
-            let updateSql = 'UPDATE lepao_account SET uid = ?, token = ?, school_id = ?, name = ?, grade_id = ?, class_id = ?, sex = ?, academy_name = ?, update_time = ?, user_avatar = ?, state = 1 WHERE student_num = ?'
-            let updateRows = await db.query(updateSql, [uid, token, school_id, name, grade_id, class_id, sex, academy_name, time, user_avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png', student_num])
-
-            if (updateRows && updateRows.affectedRows > 0) {
-                let msg
-                if (findRows[0].auto_run === 1) {
-                    msg = `当前已开启自动乐跑,系统随后将自动进行乐跑。后续通知将发送到您预留的联系方式,请留意。`
-                }
-                else {
-                    msg = `当前未开启自动乐跑,如需进行乐跑,请前往 RunForge 手动执行乐跑操作。后续通知将发送到您预留的联系方式,请留意。`
-                }
-                res.json({
-                    ...BaseStdResponse.OK,
-                    data: {
-                        name,
-                        academy_name,
-                        grade_id,
-                        auto_run: findRows[0].auto_run,
-                        account: student_num,
-                        msg
-                    }
-                })
-                let emailData = {
-                    name,
-                    type: 'lepao_update',
-                    umo: findRows[0].bot_umo,
-                    account: student_num,
-                    academy_name,
-                    grade_id,
-                    auto_run: findRows[0].auto_run
-                }
-
-                if (findRows[0].notice_type === 'bot' && findRows[0].bot_umo) {
-                        this.logger.info(`${student_num}发送乐跑更新Bot通知,UMO=${rows[0].bot_umo}`)
-                    const ch = await mq.getChannel(this.messageQueue)
-
-                    await ch.assertQueue(this.messageQueue, {
-                        durable: true
-                    })
-
-                    ch.sendToQueue(
-                        this.messageQueue,
-                        Buffer.from(JSON.stringify(emailData)),
-                        {
-                            persistent: true,
-                            contentType: 'application/json'
-                        }
-                    )
-
-                    this.logger.info(`${student_num}乐跑更新Bot通知发送完成`)
-                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
-                    await EmailTemplate.updateSuccess(findRows[0].email, emailData)
-                    this.logger.info(`${student_num}乐跑更新邮件发送完成`)
-                }
-
-                if (findRows[0].auto_run === 1 && Array.isArray(findRows[0].auto_day) && findRows[0].auto_day.includes(new Date().getDay())) {
-                    enqueueLepaoStartRun(student_num, this.logger)
-                }
-            }
-
-            // 获取新加账号中存在的路径
-            // try {
-            //     let sql = 'SELECT id FROM lepao_record WHERE lepao_account = ?'
-            //     let rows = await db.query(sql, [student_num])
-
-            //     // 不是老帐号就不获取
-            //     if (!rows || rows.length !== 0) return
-
-            //     const reqData = { uid, token, school_id, student_id: student_num }
-            //     this.logger.info(`开始请求获取跑步记录 uid=${uid} student_id=${student_num}`)
-                
-
-            // } catch (error) {
-            //     this.logger.info(`获取跑步记录出错 ${error.stack}`)
-            // }
-
-        } catch (error) {
-            this.logger.error(`更新用户信息时出错。${error.stack}`)
-            return res.json({
-                ...BaseStdResponse.ERR,
-                msg: '更新用户信息失败,请重试'
-            })
-        }
+        await executeLepaoTokenUpdate(
+            { logger: this.logger, messageQueue: this.messageQueue },
+            req,
+            res
+        )
     }
     }
 }
 }
 
 
-module.exports.UpdateAccount = UpdateAccount;
+module.exports.UpdateAccount = UpdateAccount

+ 10 - 154
apis/Lepao/Account/UpdateAccount/UpdateAccountAndroidApp.js

@@ -1,168 +1,24 @@
-const API = require("../../../../lib/API.js")
-const db = require("../../../../plugin/DataBase/db.js")
-const EmailTemplate = require('../../../../plugin/Email/emailTemplate.js')
-const { enqueueLepaoStartRun } = require('../../../../plugin/mq/enqueueLepaoStartRun')
-const mq = require('../../../../plugin/mq')
-const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
-const { dataDecrypt } = require('../../../../plugin/Lepao/Crypto')
+const API = require('../../../../lib/API.js')
+const mqNames = require('../../../../plugin/mq/jkesMqNames.js')
+const { executeLepaoTokenUpdate } = require('../../../../plugin/jkes/updateAccountCore.js')
 
 
-// 客户端上传数据接口
 class UpdateAccountAndroidApp extends API {
 class UpdateAccountAndroidApp extends API {
     constructor() {
     constructor() {
         super()
         super()
 
 
-        this.messageQueue = 'runforge_message_queue'
+        this.messageQueue = mqNames.queueNotify
 
 
+        this.noEncrypt()
         this.setPath('/Lepao/UpdateAccountAndroidApp')
         this.setPath('/Lepao/UpdateAccountAndroidApp')
         this.setMethod('POST')
         this.setMethod('POST')
     }
     }
 
 
     async onRequest(req, res) {
     async onRequest(req, res) {
-        let { reqData, resData, userAgent, deviceModel } = req.body
-
-        if ([reqData, resData, userAgent, deviceModel].some(value => value === '' || value === null || value === undefined))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER,
-                msg: '账号信息不完整,请稍后再试获联系客服处理'
-            })
-
-        try {
-            const userData = JSON.parse(dataDecrypt(reqData) || '{}')
-            if (!userData || Object.keys(userData).length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            const { token } = userData
-            if ([token].some(value => value === '' || value === null || value === undefined))
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '未提取出用户登录信息,请重试'
-                })
-
-            const userData2 = JSON.parse(dataDecrypt(resData) || '{}')
-            if (!userData2 || Object.keys(userData2).length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            const { uid, user_avatar, student_num, school_id, grade_id, class_id, sex, name, academy_name } = userData2
-            if ([uid, student_num, school_id, grade_id, class_id, sex, name, academy_name].some(value => value === '' || value === null || value === undefined))
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '未提取出用户登录信息,请重试'
-                })
-
-            let findSql = `
-                SELECT 
-                    a.email, a.create_user, a.auto_run, a.auto_day, a.notice_type, e.bot_umo
-                FROM
-                    lepao_account a
-                LEFT JOIN
-                    lepao_extra e
-                ON
-                    a.student_num = e.student_num
-                WHERE
-                    a.student_num = ? AND a.create_user IS NOT NULL
-                `
-            let findRows = await db.query(findSql, [student_num])
-            if (!findRows)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            if (findRows.length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '该乐跑账号尚未在RunForge系统中添加,请先添加你的账户'
-                })
-            const time = new Date().getTime()
-            let updateSql = 'UPDATE lepao_account SET uid = ?, token = ?, school_id = ?, name = ?, grade_id = ?, class_id = ?, sex = ?, academy_name = ?, update_time = ?, user_avatar = ?, state = 1, userAgent = ?, deviceModel = ? WHERE student_num = ?'
-            let updateRows = await db.query(updateSql, [uid, token, school_id, name, grade_id, class_id, sex, academy_name, time, user_avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png', userAgent ?? '', deviceModel ?? '', student_num])
-
-            if (updateRows && updateRows.affectedRows > 0) {
-                let msg
-                if (findRows[0].auto_run === 1) {
-                    msg = `当前已开启自动乐跑,系统将自动进行乐跑。后续通知将发送到您预留的联系方式,请留意。`
-                }
-                else {
-                    msg = `当前未开启自动乐跑,如需进行乐跑,请前往 RunForge 手动执行乐跑操作。后续通知将发送到您预留的联系方式,请留意。`
-                }
-                res.json({
-                    ...BaseStdResponse.OK,
-                    data: {
-                        name,
-                        user_avatar,
-                        sex,
-                        academy_name,
-                        grade_id,
-                        auto_run: findRows[0].auto_run,
-                        account: student_num,
-                        msg
-                    }
-                })
-
-                let emailData = {
-                    name,
-                    type: 'lepao_update',
-                    umo: findRows[0].bot_umo,
-                    account: student_num,
-                    academy_name,
-                    grade_id,
-                    auto_run: findRows[0].auto_run
-                }
-
-                if (findRows[0].notice_type === 'bot' && findRows[0].bot_umo) {
-                    this.logger.info(`${student_num}发送乐跑更新Bot通知,UMO=${findRows[0].bot_umo}`)
-                    const ch = await mq.getChannel(this.messageQueue)
-
-                    await ch.assertQueue(this.messageQueue, {
-                        durable: true
-                    })
-
-                    ch.sendToQueue(
-                        this.messageQueue,
-                        Buffer.from(JSON.stringify(emailData)),
-                        {
-                            persistent: true,
-                            contentType: 'application/json'
-                        }
-                    )
-
-                    this.logger.info(`${student_num}乐跑更新Bot通知发送完成`)
-                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
-                    await EmailTemplate.updateSuccess(findRows[0].email, emailData)
-                    this.logger.info(`${student_num}乐跑更新邮件发送完成`)
-                }
-
-                if (findRows[0].auto_run === 1 && Array.isArray(findRows[0].auto_day) && findRows[0].auto_day.includes(new Date().getDay())) {
-                    enqueueLepaoStartRun(student_num, this.logger)
-                }
-            }
-
-            // 获取新加账号中存在的路径
-            // try {
-            //     let sql = 'SELECT id FROM lepao_record WHERE lepao_account = ?'
-            //     let rows = await db.query(sql, [student_num])
-
-            //     // 不是老帐号就不获取
-            //     if (!rows || rows.length !== 0) return
-
-            //     const reqData = { uid, token, school_id, student_id: student_num }
-            //     this.logger.info(`开始请求获取跑步记录 uid=${uid} student_id=${student_num}`)
-                
-
-            // } catch (error) {
-            //     this.logger.info(`获取跑步记录出错 ${error.stack}`)
-            // }
-
-        } catch (error) {
-            this.logger.error(`更新用户信息时出错。${error.stack}`)
-            return res.json({
-                ...BaseStdResponse.ERR,
-                msg: '更新用户信息失败,请重试'
-            })
-        }
+        await executeLepaoTokenUpdate(
+            { logger: this.logger, messageQueue: this.messageQueue },
+            req,
+            res
+        )
     }
     }
 }
 }
 
 

+ 10 - 156
apis/Lepao/Account/UpdateAccount/UpdateAccountiPhone.js

@@ -1,170 +1,24 @@
-const API = require("../../../../lib/API.js")
-const db = require("../../../../plugin/DataBase/db.js")
-const EmailTemplate = require('../../../../plugin/Email/emailTemplate.js')
-const { enqueueLepaoStartRun } = require('../../../../plugin/mq/enqueueLepaoStartRun')
-const mq = require('../../../../plugin/mq')
-const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
-const { dataDecrypt } = require('../../../../plugin/Lepao/Crypto')
+const API = require('../../../../lib/API.js')
+const mqNames = require('../../../../plugin/mq/jkesMqNames.js')
+const { executeLepaoTokenUpdate } = require('../../../../plugin/jkes/updateAccountCore.js')
 
 
-// 客户端上传数据接口
 class UpdateAccountiPhone extends API {
 class UpdateAccountiPhone extends API {
     constructor() {
     constructor() {
         super()
         super()
 
 
-        this.messageQueue = 'runforge_message_queue'
+        this.messageQueue = mqNames.queueNotify
+
         this.noEncrypt()
         this.noEncrypt()
         this.setPath('/Lepao/UpdateAccountiPhone')
         this.setPath('/Lepao/UpdateAccountiPhone')
         this.setMethod('POST')
         this.setMethod('POST')
     }
     }
 
 
     async onRequest(req, res) {
     async onRequest(req, res) {
-        let { reqData, resData, headers } = req.body
-
-        if ([reqData, resData, headers].some(value => value === '' || value === null || value === undefined))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER,
-                msg: '账号信息不完整,请稍后再试获联系客服处理'
-            })
-
-        try {
-            const userData = JSON.parse(dataDecrypt(reqData) || '{}')
-            if (!userData || Object.keys(userData).length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            const { token } = userData
-            if ([token].some(value => value === '' || value === null || value === undefined))
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '未提取出用户登录信息,请重试'
-                })
-
-            const userData2 = JSON.parse(dataDecrypt(resData) || '{}')
-            if (!userData2 || Object.keys(userData2).length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-
-            const { uid, user_avatar, student_num, school_id, grade_id, class_id, sex, name, academy_name } = userData2
-            if ([uid, student_num, school_id, grade_id, class_id, sex, name, academy_name].some(value => value === '' || value === null || value === undefined))
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '未提取出用户登录信息,请重试'
-                })
-
-            let findSql = `
-                SELECT 
-                    a.email, a.create_user, a.auto_run, a.auto_day, a.notice_type, e.bot_umo
-                FROM
-                    lepao_account a
-                LEFT JOIN
-                    lepao_extra e
-                ON
-                    a.student_num = e.student_num
-                WHERE
-                    a.student_num = ? AND a.create_user IS NOT NULL
-                `
-            let findRows = await db.query(findSql, [student_num])
-            if (!findRows)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '无法解析用户数据,请重试'
-                })
-            if (findRows.length === 0)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '该乐跑账号尚未在RunForge系统中添加,请先添加你的账户'
-                })
-
-            const time = new Date().getTime()
-            let updateSql = 'UPDATE lepao_account SET uid = ?, token = ?, school_id = ?, name = ?, grade_id = ?, class_id = ?, sex = ?, academy_name = ?, update_time = ?, user_avatar = ?, state = 1, userAgent = ?, deviceModel = ? WHERE student_num = ?'
-            let updateRows = await db.query(updateSql, [uid, token, school_id, name, grade_id, class_id, sex, academy_name, time, user_avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png', headers["User-Agent"] ?? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.29(0x18001d21) NetType/WIFI Language/zh_CN wxwork/5.0.6', 'unknown<iPhone18,3>', student_num])
-
-            if (updateRows && updateRows.affectedRows > 0) {
-                let msg
-                if (findRows[0].auto_run === 1) {
-                    msg = `当前已开启自动乐跑,系统将自动进行乐跑。后续通知将发送到您的邮箱:${findRows[0].email}。请留意邮箱提醒。`
-                }
-                else {
-                    msg = `当前未开启自动乐跑,如需进行乐跑,请前往 RunForge 手动执行乐跑操作。后续通知将发送到您的邮箱:${findRows[0].email},请留意邮箱提醒。`
-                }
-                res.json({
-                    ...BaseStdResponse.OK,
-                    data: {
-                        name,
-                        user_avatar,
-                        sex,
-                        academy_name,
-                        grade_id,
-                        auto_run: findRows[0].auto_run,
-                        account: student_num,
-                        msg
-                    }
-                })
-
-                let emailData = {
-                    name,
-                    type: 'lepao_update',
-                    umo: findRows[0].bot_umo,
-                    account: student_num,
-                    academy_name,
-                    grade_id,
-                    auto_run: findRows[0].auto_run
-                }
-
-                if (findRows[0].notice_type === 'bot' && findRows[0].bot_umo) {
-                    this.logger.info(`${student_num}发送乐跑更新Bot通知,UMO=${findRows[0].bot_umo}`)
-                    const ch = await mq.getChannel(this.messageQueue)
-
-                    await ch.assertQueue(this.messageQueue, {
-                        durable: true
-                    })
-
-                    ch.sendToQueue(
-                        this.messageQueue,
-                        Buffer.from(JSON.stringify(emailData)),
-                        {
-                            persistent: true,
-                            contentType: 'application/json'
-                        }
-                    )
-
-                    this.logger.info(`${student_num}乐跑更新Bot通知发送完成`)
-                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
-                    await EmailTemplate.updateSuccess(findRows[0].email, emailData)
-                    this.logger.info(`${student_num}乐跑更新邮件发送完成`)
-                }
-
-                if (findRows[0].auto_run === 1 && Array.isArray(findRows[0].auto_day) && findRows[0].auto_day.includes(new Date().getDay())) {
-                    enqueueLepaoStartRun(student_num, this.logger)
-                }
-            }
-
-            // 获取新加账号中存在的路径
-            // try {
-            //     let sql = 'SELECT id FROM lepao_record WHERE lepao_account = ?'
-            //     let rows = await db.query(sql, [student_num])
-
-            //     // 不是老帐号就不获取
-            //     if (!rows || rows.length !== 0) return
-
-            //     const reqData = { uid, token, school_id, student_id: student_num }
-            //     this.logger.info(`开始请求获取跑步记录 uid=${uid} student_id=${student_num}`)
-                
-
-            // } catch (error) {
-            //     this.logger.info(`获取跑步记录出错 ${error.stack}`)
-            // }
-
-        } catch (error) {
-            this.logger.error(`更新用户信息时出错。${error.stack}`)
-            return res.json({
-                ...BaseStdResponse.ERR,
-                msg: '更新用户信息失败,请重试'
-            })
-        }
+        await executeLepaoTokenUpdate(
+            { logger: this.logger, messageQueue: this.messageQueue },
+            req,
+            res
+        )
     }
     }
 }
 }
 
 

+ 21 - 64
apis/Lepao/Account/UpdateSelfAccount.js

@@ -1,11 +1,13 @@
 const API = require("../../../lib/API.js")
 const API = require("../../../lib/API.js")
 const db = require("../../../plugin/DataBase/db.js")
 const db = require("../../../plugin/DataBase/db.js")
-const axios = require("axios")
 const AccessControl = require("../../../lib/AccessControl.js")
 const AccessControl = require("../../../lib/AccessControl.js")
 const { BaseStdResponse } = require("../../../BaseStdResponse.js")
 const { BaseStdResponse } = require("../../../BaseStdResponse.js")
-const { dataEncrypt, dataDecrypt, dataSign } = require("../../../plugin/Lepao/Crypto")
-const { URLSearchParams } = require("url")
+const { fetchJkesMonthKm, fetchJkesTotalKm } = require("../../../plugin/jkes/stats")
+const { readState, writeState } = require("../../../plugin/jkes/monthPolicy")
 
 
+/**
+ * 使用库内 JKES token 从官方接口同步本月/累计里程到 lepao_account(term_num / total_num)
+ */
 class UpdateSelfAccount extends API {
 class UpdateSelfAccount extends API {
     constructor() {
     constructor() {
         super()
         super()
@@ -17,7 +19,7 @@ class UpdateSelfAccount extends API {
     async onRequest(req, res) {
     async onRequest(req, res) {
         const { uuid, session, student_num } = req.body
         const { uuid, session, student_num } = req.body
 
 
-        if ([uuid, session, student_num].some(v => v === '' || v === null || v === undefined)) {
+        if ([uuid, session, student_num].some((v) => v === '' || v === null || v === undefined)) {
             return res.json({
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
                 ...BaseStdResponse.MISSING_PARAMETER
             })
             })
@@ -31,7 +33,7 @@ class UpdateSelfAccount extends API {
 
 
         try {
         try {
             const rows = await db.query(
             const rows = await db.query(
-                'SELECT uid, token, school_id, userAgent, state FROM lepao_account WHERE student_num = ? AND create_user = ?',
+                'SELECT token, state FROM lepao_account WHERE student_num = ? AND create_user = ?',
                 [student_num, uuid]
                 [student_num, uuid]
             )
             )
             if (!rows || rows.length !== 1) {
             if (!rows || rows.length !== 1) {
@@ -42,71 +44,23 @@ class UpdateSelfAccount extends API {
             }
             }
 
 
             const account = rows[0]
             const account = rows[0]
-            const raw = {
-                uid: account.uid,
-                token: account.token,
-                school_id: account.school_id,
-                term_id: 0,
-                course_id: 0,
-                class_id: 0,
-                student_num,
-                card_id: student_num,
-                timestamp: Number((Date.now() / 1000).toFixed(3)),
-                version: 1,
-                nonce: String(Math.floor(Math.random() * 900000 + 100000)),
-                ostype: 5
-            }
-            raw.sign = dataSign(raw)
-
-            const form = new URLSearchParams()
-            form.append('ostype', '5')
-            form.append('data', dataEncrypt(JSON.stringify(raw)))
-
-            const headers = {
-                'Content-Type': 'application/x-www-form-urlencoded',
-                'Accept': '*/*',
-                'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
-                'Accept-Encoding': 'gzip, deflate, br',
-                'Referer': 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
-                'User-Agent': account.userAgent || 'Mozilla/5.0 (Linux; Android 16; 2211133C Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'
-            }
-
-            const apiRes = await axios.post(
-                'https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260',
-                form,
-                { headers, proxy: false }
-            )
-
-            let result = apiRes.data
-            if (result?.data && result?.is_encrypt === 1) {
-                result.data = JSON.parse(dataDecrypt(result.data))
-            }
-
-            const info = result?.info || result?.msg || '系统繁忙,请稍后再试'
-            const updateTime = Date.now()
-
-            // 登录失效:更新 state=0
-            if (String(info).includes('重新登录') || Number(result?.status) === 101) {
-                await db.query('UPDATE lepao_account SET state = 0, update_time = ? WHERE student_num = ? AND create_user = ?', [updateTime, student_num, uuid])
+            if (!account.token || String(account.token).trim() === '') {
                 return res.json({
                 return res.json({
                     ...BaseStdResponse.ERR,
                     ...BaseStdResponse.ERR,
-                    msg: info
+                    msg: '账号未登录,请先在客户端更新 JKES 登录信息'
                 })
                 })
             }
             }
 
 
-            if (!result || Number(result.status) !== 1 || !result.data) {
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: info
-                })
-            }
-
-            const term_num = Number(result.data.term_num ?? 0)
-            const total_num = Number(result.data.total_num ?? 30)
+            const now = new Date()
+            const y = now.getFullYear()
+            const m = now.getMonth() + 1
+            const monthKm = await fetchJkesMonthKm(account.token, y, m)
+            const totalKm = await fetchJkesTotalKm(account.token)
 
 
+            const updateTime = Date.now()
             const updateRows = await db.query(
             const updateRows = await db.query(
                 'UPDATE lepao_account SET term_num = ?, total_num = ?, state = 1, update_time = ? WHERE student_num = ? AND create_user = ?',
                 'UPDATE lepao_account SET term_num = ?, total_num = ?, state = 1, update_time = ? WHERE student_num = ? AND create_user = ?',
-                [term_num, total_num, updateTime, student_num, uuid]
+                [monthKm, totalKm, updateTime, student_num, uuid]
             )
             )
             if (!updateRows || updateRows.affectedRows !== 1) {
             if (!updateRows || updateRows.affectedRows !== 1) {
                 return res.json({
                 return res.json({
@@ -114,11 +68,14 @@ class UpdateSelfAccount extends API {
                 })
                 })
             }
             }
 
 
+            const prev = await readState(student_num, now)
+            await writeState(student_num, { km: monthKm, doubles: prev.doubles }, now)
+
             return res.json({
             return res.json({
                 ...BaseStdResponse.OK,
                 ...BaseStdResponse.OK,
                 data: {
                 data: {
-                    term_num,
-                    total_num,
+                    term_num: monthKm,
+                    total_num: totalKm,
                     state: 1
                     state: 1
                 }
                 }
             })
             })

+ 99 - 0
apis/Lepao/GetJkesRunOverview.js

@@ -0,0 +1,99 @@
+const API = require('../../lib/API.js')
+const db = require('../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../BaseStdResponse.js')
+const AccessControl = require('../../lib/AccessControl.js')
+const { fetchJkesMonthKm, fetchJkesTotalKm } = require('../../plugin/jkes/stats')
+const {
+    readState,
+    DOUBLE_RUNS_CAP,
+    refreshMonthKmFromApi,
+    normalizeMonthTargetKm
+} = require('../../plugin/jkes/monthPolicy')
+/**
+ * 从 JKES 官方接口拉取本月/累计里程,并返回本地月度缓存。
+ * monthTargetKm 为学校要求的「本月最低」参考,非可跑里程上限。
+ */
+class GetJkesRunOverview extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Lepao/JkesRunOverview')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, student_num } = req.query
+
+        if ([uuid, session, student_num].some((v) => v === '' || v == null))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        try {
+            const rows = await db.query(
+                `SELECT student_num, token, create_user, target_count
+                 FROM lepao_account WHERE student_num = ?`,
+                [student_num]
+            )
+            if (!rows || rows.length === 0)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '未找到乐跑账号'
+                })
+
+            const acc = rows[0]
+            if (acc.create_user !== uuid) {
+                const permission = await AccessControl.getPermission(uuid)
+                if (!permission.includes('admin') && !permission.includes('service'))
+                    return res.json({
+                        ...BaseStdResponse.ERR,
+                        msg: '无权查看该账号'
+                    })
+            }
+
+            if (!acc.token)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '账号未登录或 token 无效,请先更新账号'
+                })
+
+            const now = new Date()
+            const y = now.getFullYear()
+            const m = now.getMonth() + 1
+
+            await refreshMonthKmFromApi(student_num, acc.token, now)
+
+            const monthKm = await fetchJkesMonthKm(acc.token, y, m)
+            const totalKm = await fetchJkesTotalKm(acc.token)
+            const local = await readState(student_num, now)
+            const monthTargetKm = normalizeMonthTargetKm(acc.target_count)
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    student_num,
+                    monthKmOfficial: monthKm,
+                    totalKmOfficial: totalKm,
+                    monthKmCached: local.km,
+                    doubleRunsUsed: local.doubles,
+                    monthTargetKm,
+                    monthTargetIsMinimumNotCap: true,
+                    plannedDoubleRunDays: DOUBLE_RUNS_CAP
+                }
+            })
+        } catch (e) {
+            this.logger.error(`JkesRunOverview ${e.stack || e}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '获取 JKES 跑步数据失败'
+            })
+        }
+    }
+}
+
+module.exports.GetJkesRunOverview = GetJkesRunOverview

+ 4 - 1
apis/Lepao/Record/Admin/GetLepaoRecords.js

@@ -2,6 +2,7 @@ const API = require("../../../../lib/API.js");
 const db = require("../../../../plugin/DataBase/db.js");
 const db = require("../../../../plugin/DataBase/db.js");
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
 const AccessControl = require("../../../../lib/AccessControl.js");
 const AccessControl = require("../../../../lib/AccessControl.js");
+const { enrichLepaoRecordRow } = require("../../../../plugin/jkes/formatRecordRow.js");
 
 
 class AdminGetLepaoRecords extends API {
 class AdminGetLepaoRecords extends API {
     constructor() {
     constructor() {
@@ -55,7 +56,9 @@ class AdminGetLepaoRecords extends API {
                     r.id,
                     r.id,
                     r.time,
                     r.time,
                     r.result,
                     r.result,
+                    r.state,
                     r.lepao_account,
                     r.lepao_account,
+                    r.path_id,
                     a.name,
                     a.name,
                     a.user_avatar,
                     a.user_avatar,
                     u.username,
                     u.username,
@@ -137,7 +140,7 @@ class AdminGetLepaoRecords extends API {
 
 
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,
-            data: rows,
+            data: rows.map(enrichLepaoRecordRow),
             pagination: {
             pagination: {
                 current,
                 current,
                 pagesize,
                 pagesize,

+ 74 - 16
apis/Lepao/Record/Admin/GetRecordDetail.js

@@ -2,6 +2,7 @@ const API = require("../../../../lib/API")
 const db = require("../../../../plugin/DataBase/db")
 const db = require("../../../../plugin/DataBase/db")
 const AccessControl = require("../../../../lib/AccessControl")
 const AccessControl = require("../../../../lib/AccessControl")
 const { BaseStdResponse } = require("../../../../BaseStdResponse")
 const { BaseStdResponse } = require("../../../../BaseStdResponse")
+const { enrichLepaoRecordRow } = require("../../../../plugin/jkes/formatRecordRow.js")
 
 
 class AdminGetRecordDetail extends API {
 class AdminGetRecordDetail extends API {
     constructor() {
     constructor() {
@@ -37,29 +38,51 @@ class AdminGetRecordDetail extends API {
             })
             })
 
 
 
 
-        let sql = `
+        const baseWhere = `
+            FROM 
+                lepao_record r
+            LEFT JOIN 
+                lepao_account a
+            ON 
+                r.lepao_account = a.student_num
+            LEFT JOIN
+                path_data p
+            ON 
+                r.path_id = p.id
+            WHERE 
+                r.id = ?
+        `
+        let rows
+        try {
+            const sql = `
                 SELECT 
                 SELECT 
                     r.time,
                     r.time,
                     r.result,
                     r.result,
+                    r.state,
                     r.lepao_account,
                     r.lepao_account,
-                    r.point_data,
                     r.path_id,
                     r.path_id,
+                    r.path_data AS run_path_data,
                     a.name,
                     a.name,
                     p.data
                     p.data
-                FROM 
-                    lepao_record r
-                LEFT JOIN 
-                    lepao_account a
-                ON 
-                    r.lepao_account = a.student_num
-                LEFT JOIN
-                    path_data p
-                ON 
-                    r.path_id = p.id
-                WHERE 
-                    r.id = ?
+                ${baseWhere}
             `
             `
-        let rows = await db.query(sql, [id])
+            rows = await db.query(sql, [id])
+        } catch (e) {
+            if (!(e?.message || '').includes("Unknown column 'r.path_data'")) throw e
+            const sql = `
+                SELECT 
+                    r.time,
+                    r.result,
+                    r.state,
+                    r.lepao_account,
+                    r.path_id,
+                    r.point_data AS run_path_data,
+                    a.name,
+                    p.data
+                ${baseWhere}
+            `
+            rows = await db.query(sql, [id])
+        }
 
 
         if (!rows)
         if (!rows)
             return res.json({
             return res.json({
@@ -74,7 +97,42 @@ class AdminGetRecordDetail extends API {
             }) 
             }) 
 
 
         let data = rows[0]
         let data = rows[0]
-        data.data = data.data.map(point => [point.o, point.a])
+        let pathLine = []
+        const rawRunPath = data.run_path_data
+        if (rawRunPath != null) {
+            let arr = rawRunPath
+            if (typeof rawRunPath === 'string') {
+                try {
+                    arr = JSON.parse(rawRunPath)
+                } catch {
+                    arr = null
+                }
+            }
+            if (Array.isArray(arr)) {
+                pathLine = arr.map((point) => [point.longitude ?? point.o, point.latitude ?? point.a])
+            }
+        }
+        if (!pathLine.length) {
+            const rawPath = data.data
+            if (rawPath != null) {
+                let arr = rawPath
+                if (typeof rawPath === 'string') {
+                    try {
+                        arr = JSON.parse(rawPath)
+                    } catch {
+                        arr = null
+                    }
+                }
+                if (Array.isArray(arr)) {
+                    pathLine = arr.map((point) => [point.o, point.a])
+                }
+            }
+        }
+        data.path_polyline = pathLine
+        delete data.data
+        delete data.run_path_data
+
+        data.jkes_record = enrichLepaoRecordRow({ result: data.result }).jkes_record
 
 
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,

+ 4 - 1
apis/Lepao/Record/GetLepaoRecords.js

@@ -2,6 +2,7 @@ const API = require("../../../lib/API.js");
 const db = require("../../../plugin/DataBase/db.js");
 const db = require("../../../plugin/DataBase/db.js");
 const { BaseStdResponse } = require("../../../BaseStdResponse.js");
 const { BaseStdResponse } = require("../../../BaseStdResponse.js");
 const AccessControl = require("../../../lib/AccessControl.js");
 const AccessControl = require("../../../lib/AccessControl.js");
+const { enrichLepaoRecordRow } = require("../../../plugin/jkes/formatRecordRow.js");
 
 
 class GetLepaoRecords extends API {
 class GetLepaoRecords extends API {
     constructor() {
     constructor() {
@@ -48,7 +49,9 @@ class GetLepaoRecords extends API {
                     r.uuid,
                     r.uuid,
                     r.time,
                     r.time,
                     r.result,
                     r.result,
+                    r.state,
                     r.lepao_account,
                     r.lepao_account,
+                    r.path_id,
                     a.name,
                     a.name,
                     a.user_avatar
                     a.user_avatar
                 FROM 
                 FROM 
@@ -117,7 +120,7 @@ class GetLepaoRecords extends API {
 
 
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,
-            data: rows,
+            data: rows.map(enrichLepaoRecordRow),
             pagination: {
             pagination: {
                 current,
                 current,
                 pagesize,
                 pagesize,

+ 75 - 16
apis/Lepao/Record/GetRecordDetail.js

@@ -2,6 +2,7 @@ const API = require("../../../lib/API")
 const db = require("../../../plugin/DataBase/db")
 const db = require("../../../plugin/DataBase/db")
 const AccessControl = require("../../../lib/AccessControl")
 const AccessControl = require("../../../lib/AccessControl")
 const { BaseStdResponse } = require("../../../BaseStdResponse")
 const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { enrichLepaoRecordRow } = require("../../../plugin/jkes/formatRecordRow.js")
 
 
 class GetRecordDetail extends API {
 class GetRecordDetail extends API {
     constructor() {
     constructor() {
@@ -29,28 +30,51 @@ class GetRecordDetail extends API {
                 ...BaseStdResponse.ACCESS_DENIED
                 ...BaseStdResponse.ACCESS_DENIED
             })
             })
 
 
-        let sql = `
+        const baseWhere = `
+            FROM 
+                lepao_record r
+            LEFT JOIN 
+                lepao_account a
+            ON 
+                r.lepao_account = a.student_num
+            LEFT JOIN
+                path_data p
+            ON 
+                r.path_id = p.id
+            WHERE 
+                (r.uuid = ? OR (a.create_user IS NOT NULL AND a.create_user = ?)) AND r.id = ?
+        `
+        let rows
+        try {
+            const sql = `
                 SELECT 
                 SELECT 
                     r.time,
                     r.time,
                     r.result,
                     r.result,
+                    r.state,
                     r.lepao_account,
                     r.lepao_account,
-                    r.point_data,
+                    r.path_id,
+                    r.path_data AS run_path_data,
                     a.name,
                     a.name,
                     p.data
                     p.data
-                FROM 
-                    lepao_record r
-                LEFT JOIN 
-                    lepao_account a
-                ON 
-                    r.lepao_account = a.student_num
-                LEFT JOIN
-                    path_data p
-                ON 
-                    r.path_id = p.id
-                WHERE 
-                    (r.uuid = ? OR (a.create_user IS NOT NULL AND a.create_user = ?)) AND r.id = ?
+                ${baseWhere}
             `
             `
-        let rows = await db.query(sql, [uuid, uuid, id])
+            rows = await db.query(sql, [uuid, uuid, id])
+        } catch (e) {
+            if (!(e?.message || '').includes("Unknown column 'r.path_data'")) throw e
+            const sql = `
+                SELECT 
+                    r.time,
+                    r.result,
+                    r.state,
+                    r.lepao_account,
+                    r.path_id,
+                    r.point_data AS run_path_data,
+                    a.name,
+                    p.data
+                ${baseWhere}
+            `
+            rows = await db.query(sql, [uuid, uuid, id])
+        }
 
 
         if (!rows)
         if (!rows)
             return res.json({
             return res.json({
@@ -65,7 +89,42 @@ class GetRecordDetail extends API {
             }) 
             }) 
 
 
         let data = rows[0]
         let data = rows[0]
-        data.data = data.data.map(point => [point.o, point.a])
+        let pathLine = []
+        const rawRunPath = data.run_path_data
+        if (rawRunPath != null) {
+            let arr = rawRunPath
+            if (typeof rawRunPath === 'string') {
+                try {
+                    arr = JSON.parse(rawRunPath)
+                } catch {
+                    arr = null
+                }
+            }
+            if (Array.isArray(arr)) {
+                pathLine = arr.map((point) => [point.longitude ?? point.o, point.latitude ?? point.a])
+            }
+        }
+        if (!pathLine.length) {
+            const rawPath = data.data
+            if (rawPath != null) {
+                let arr = rawPath
+                if (typeof rawPath === 'string') {
+                    try {
+                        arr = JSON.parse(rawPath)
+                    } catch {
+                        arr = null
+                    }
+                }
+                if (Array.isArray(arr)) {
+                    pathLine = arr.map((point) => [point.o, point.a])
+                }
+            }
+        }
+        data.path_polyline = pathLine
+        delete data.data
+        delete data.run_path_data
+
+        data.jkes_record = enrichLepaoRecordRow({ result: data.result }).jkes_record
 
 
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,

+ 38 - 8
apis/Lepao/SingleRun.js

@@ -8,8 +8,11 @@ const {
     assertRunforgeTaskIngress,
     assertRunforgeTaskIngress,
     publishRunforgeTask
     publishRunforgeTask
 } = require('../../plugin/mq/runforgeTaskMq')
 } = require('../../plugin/mq/runforgeTaskMq')
+const { planJkesManualRun } = require('../../plugin/jkes/monthPolicy')
+const { parsePaceToSecPerKm, clampManualPaceSec } = require('../../plugin/jkes/paceUtils')
+const jkesRedisKeys = require('../../plugin/jkes/redisKeys')
+const mqNames = require('../../plugin/mq/jkesMqNames')
 
 
-// 单次乐跑
 class SingleRun extends API {
 class SingleRun extends API {
     constructor() {
     constructor() {
         super()
         super()
@@ -19,7 +22,7 @@ class SingleRun extends API {
     }
     }
 
 
     async onRequest(req, res) {
     async onRequest(req, res) {
-        let { uuid, session, student_num } = req.query
+        let { uuid, session, student_num, distance_km, pace } = req.query
 
 
         if ([uuid, session, student_num].some(value => value === '' || value === null || value === undefined))
         if ([uuid, session, student_num].some(value => value === '' || value === null || value === undefined))
             return res.json({
             return res.json({
@@ -39,15 +42,14 @@ class SingleRun extends API {
             })
             })
 
 
         try {
         try {
-            // 检查redis是否存在当天乐跑成功记录
-            const isSuccess = await Redis.get(`lepaoSuccess:${student_num}`)
+            const isSuccess = await Redis.get(jkesRedisKeys.lepaoSuccess(student_num))
             if (isSuccess)
             if (isSuccess)
                 return res.json({
                 return res.json({
                     ...BaseStdResponse.ERR,
                     ...BaseStdResponse.ERR,
                     msg: '该账号当天已乐跑成功!请勿重复乐跑'
                     msg: '该账号当天已乐跑成功!请勿重复乐跑'
                 })
                 })
 
 
-            const isProgress = await Redis.get(`lepaoProgress:${student_num}`)
+            const isProgress = await Redis.get(jkesRedisKeys.lepaoProgress(student_num))
             if (isProgress)
             if (isProgress)
                 return res.json({
                 return res.json({
                     ...BaseStdResponse.ERR,
                     ...BaseStdResponse.ERR,
@@ -71,7 +73,7 @@ class SingleRun extends API {
                     })
                     })
             }
             }
 
 
-            let sql = 'SELECT token, uid, school_id, state FROM lepao_account WHERE student_num = ?'
+            let sql = 'SELECT token, state FROM lepao_account WHERE student_num = ?'
             let rows = await db.query(sql, [student_num])
             let rows = await db.query(sql, [student_num])
             if (!rows || rows.length === 0)
             if (!rows || rows.length === 0)
                 return res.json({
                 return res.json({
@@ -85,12 +87,37 @@ class SingleRun extends API {
                     msg: '账号状态为未登录,请使用登录器更新账号信息后乐跑'
                     msg: '账号状态为未登录,请使用登录器更新账号信息后乐跑'
                 })
                 })
 
 
+            if ([distance_km, pace].some((v) => v === '' || v === null || v === undefined))
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '请传入单次距离 distance_km(1–5)与配速 pace(如 5:30、5\'30" 或秒/公里)'
+                })
+
+            const dist = Number(distance_km)
+            let paceSecPerKm
+            try {
+                paceSecPerKm = clampManualPaceSec(parsePaceToSecPerKm(pace))
+            } catch (e) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: e.message || '配速无效'
+                })
+            }
+
+            const plan = await planJkesManualRun(student_num, rows[0].token, dist)
+            if (!plan.run) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: plan.reason || '当前无法发起单次乐跑'
+                })
+            }
+
             res.json({
             res.json({
                 ...BaseStdResponse.OK
                 ...BaseStdResponse.OK
             })
             })
 
 
             try {
             try {
-                const channel = await mq.getChannel('lepao_api')
+                const channel = await mq.getChannel(mqNames.channelLepaoApi)
                 await assertRunforgeTaskIngress(channel, this.logger)
                 await assertRunforgeTaskIngress(channel, this.logger)
 
 
                 const taskId = `lepao:${Date.now()}:${student_num}`
                 const taskId = `lepao:${Date.now()}:${student_num}`
@@ -99,7 +126,10 @@ class SingleRun extends API {
                     type: 'lepao.startRun',
                     type: 'lepao.startRun',
                     data: {
                     data: {
                         taskId,
                         taskId,
-                        account: student_num
+                        account: student_num,
+                        targetKm: plan.targetKm,
+                        manual: true,
+                        paceSecPerKm
                     },
                     },
                     retry: 0
                     retry: 0
                 }
                 }

+ 10 - 10
apis/User/Admin/GetQueueTasks.js

@@ -12,7 +12,7 @@ const {
 
 
 /** 允许通过管理接口查看的队列(防任意队列名探测) */
 /** 允许通过管理接口查看的队列(防任意队列名探测) */
 const ALLOWED_QUEUES = [
 const ALLOWED_QUEUES = [
-    'runforge_task_queue',
+    'jkes_runforge_task_queue',
     'runforge_task_result_queue',
     'runforge_task_result_queue',
     'runforge_task_dead_queue',
     'runforge_task_dead_queue',
     'runforge_message_queue',
     'runforge_message_queue',
@@ -185,9 +185,9 @@ class GetQueueTasks extends API {
                         summary: true,
                         summary: true,
                         queues,
                         queues,
                         redisScheduler: {
                         redisScheduler: {
-                            key: SCHEDULE_KEY,
+                            keys: [SCHEDULE_KEY],
                             pendingCount,
                             pendingCount,
-                            note: '到期任务由本服务定时写入 runforge_task_queue;多实例共享同一 Redis ZSET。'
+                            note: '到期任务由本服务定时写入 jkes_runforge_task_queue。'
                         },
                         },
                         autoRunScheduledMirror: {
                         autoRunScheduledMirror: {
                             pendingCount,
                             pendingCount,
@@ -199,7 +199,7 @@ class GetQueueTasks extends API {
                 })
                 })
             }
             }
 
 
-            const queueName = queue || 'runforge_task_queue'
+            const queueName = queue || 'jkes_runforge_task_queue'
             if (!ALLOWED_QUEUES.includes(queueName))
             if (!ALLOWED_QUEUES.includes(queueName))
                 return res.json({
                 return res.json({
                     ...BaseStdResponse.ERR,
                     ...BaseStdResponse.ERR,
@@ -240,11 +240,11 @@ class GetQueueTasks extends API {
             const wantScheduled =
             const wantScheduled =
                 includeScheduled !== '0' &&
                 includeScheduled !== '0' &&
                 includeScheduled !== 'false' &&
                 includeScheduled !== 'false' &&
-                queueName === 'runforge_task_queue'
+                queueName === 'jkes_runforge_task_queue'
 
 
             let autoRunScheduledMirror = null
             let autoRunScheduledMirror = null
             let pendingScheduledCount = null
             let pendingScheduledCount = null
-            if (queueName === 'runforge_task_queue') {
+            if (queueName === 'jkes_runforge_task_queue') {
                 pendingScheduledCount = await countPendingScheduled(Date.now())
                 pendingScheduledCount = await countPendingScheduled(Date.now())
                 if (wantScheduled) {
                 if (wantScheduled) {
                     const slimit = Math.min(
                     const slimit = Math.min(
@@ -263,18 +263,18 @@ class GetQueueTasks extends API {
                 tasks,
                 tasks,
                 managementError,
                 managementError,
                 redisScheduler:
                 redisScheduler:
-                    queueName === 'runforge_task_queue'
+                    queueName === 'jkes_runforge_task_queue'
                         ? {
                         ? {
-                              key: SCHEDULE_KEY,
+                              keys: [SCHEDULE_KEY],
                               pendingCount: pendingScheduledCount
                               pendingCount: pendingScheduledCount
                           }
                           }
                         : undefined,
                         : undefined,
                 autoRunScheduledMirror,
                 autoRunScheduledMirror,
                 fetchedAt: Date.now()
                 fetchedAt: Date.now()
             }
             }
-            if (queueName === 'runforge_task_queue') {
+            if (queueName === 'jkes_runforge_task_queue') {
                 detail.peekNote =
                 detail.peekNote =
-                    'tasks:已在主队列中的消息;autoRunScheduledMirror:尚未到 fireAt、仍只在 Redis 中的调度(到期后由服务写入 MQ)。'
+                    'tasks:已在主队列中的消息;autoRunScheduledMirror:尚未到 fireAt、仍只在 Redis 中的调度(到期后由服务写入 jkes_runforge_task_queue)。'
             }
             }
 
 
             return res.json({
             return res.json({

File diff suppressed because it is too large
+ 430 - 338
lib/Lepao/Worker.js


+ 5 - 63
plugin/Lepao/Crypto.js

@@ -1,32 +1,13 @@
 const crypto = require('crypto')
 const crypto = require('crypto')
 
 
-const KEY_STR = "Wet2C8d34f62ndi3"
-const IV_STR = "K6iv85jBD8jgf32D"
-const SALT = "rDJiNB9j7vD2"
+const KEY_STR = 'Wet2C8d34f62ndi3'
+const IV_STR = 'K6iv85jBD8jgf32D'
 
 
 const KEY = Buffer.from(KEY_STR, 'utf-8')
 const KEY = Buffer.from(KEY_STR, 'utf-8')
 const IV = Buffer.from(IV_STR, 'utf-8')
 const IV = Buffer.from(IV_STR, 'utf-8')
 
 
 /**
 /**
- * AES 加密
- */
-function dataEncrypt(plainText) {
-    try {
-        const cipher = crypto.createCipheriv('aes-128-cbc', KEY, IV)
-        cipher.setAutoPadding(true)
-
-        let encrypted = cipher.update(plainText, 'utf-8')
-        encrypted = Buffer.concat([encrypted, cipher.final()])
-
-        return encrypted.toString('base64')
-    } catch (err) {
-        console.error('加密失败:', err.message)
-        return null
-    }
-}
-
-/**
- * AES 解密
+ * AES 解密(客户端上传的 reqData / resData)
  */
  */
 function dataDecrypt(encryptedBase64Text) {
 function dataDecrypt(encryptedBase64Text) {
     try {
     try {
@@ -45,45 +26,6 @@ function dataDecrypt(encryptedBase64Text) {
     }
     }
 }
 }
 
 
-/**
- * MD5 签名
- */
-function dataSign(dataObj) {
-    if (typeof dataObj !== 'object') {
-        throw new TypeError('data must be object')
-    }
-
-    const sortedKeys = Object.keys(dataObj).sort()
-
-    let str = ''
-    for (const key of sortedKeys) {
-        str += key + String(dataObj[key])
-    }
-
-    str += SALT
-
-    return crypto.createHash('md5').update(str, 'utf-8').digest('hex')
-}
-
-/**
- * OSS POST 签名
- */
-function ossPostSign(accessKeySecret, policyDocument) {
-    const policyStr = JSON.stringify(policyDocument)
-
-    const base64Policy = Buffer.from(policyStr).toString('base64')
-
-    const signature = crypto
-        .createHmac('sha1', accessKeySecret)
-        .update(base64Policy)
-        .digest('base64')
-
-    return [base64Policy, signature]
-}
-
 module.exports = {
 module.exports = {
-    dataEncrypt,
-    dataDecrypt,
-    dataSign,
-    ossPostSign
-}
+    dataDecrypt
+}

+ 0 - 115
plugin/Lepao/runforgeSetZoneProbe.js

@@ -1,115 +0,0 @@
-/**
- * 直连 RunForge 切换跑区(与 Worker lepao.setZone 一致),用于 token 探活,不经过 runpy。
- */
-const axios = require('axios')
-const { URLSearchParams } = require('url')
-const db = require('../DataBase/db')
-const { dataEncrypt, dataDecrypt, dataSign } = require('./Crypto')
-
-const BASE_URL = 'https://lepao.ctbu.edu.cn/v3/api.php'
-
-const DEFAULT_UA =
-    'Mozilla/5.0 (Linux; Android 16; 2211133C Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'
-
-const runZoneMap = {
-    兰花湖校区跑区: 2,
-    主校区北跑区: 3,
-    主校区南跑区: 5,
-    重庆工商大学茶园校区: 6
-}
-
-function lepaoTimestamp() {
-    return Number((Date.now() / 1000).toFixed(3))
-}
-
-/**
- * @param {object} p
- * @param {string} p.uid
- * @param {string} p.token
- * @param {string|number} p.school_id
- * @param {string} p.student_num
- * @param {number} [p.random_id=1] path_data.id,需存在且 run_zone_name 可映射
- * @param {string} [p.userAgent]
- * @returns {Promise<object>} RunForge 响应体(与 Worker request 解密后形态一致)
- */
-async function probeSetZone(p) {
-    const { uid, token, school_id, student_num, random_id = 1, userAgent } = p
-
-    const record = await db.query('SELECT run_zone_name FROM path_data WHERE id = ?', [random_id])
-    if (!record || record.length === 0) {
-        throw new Error('跑区不存在')
-    }
-
-    const runZoneId = runZoneMap[record[0].run_zone_name]
-    if (!runZoneId) throw new Error('跑区不存在')
-
-    const raw = {
-        uid,
-        token,
-        school_id,
-        term_id: 0,
-        course_id: 0,
-        class_id: 0,
-        student_num,
-        card_id: student_num,
-        timestamp: lepaoTimestamp(),
-        version: 1,
-        nonce: String(Math.floor(Math.random() * 900000 + 100000)),
-        ostype: 5,
-        run_zone_id: String(runZoneId)
-    }
-    raw.sign = dataSign(raw)
-
-    const form = new URLSearchParams()
-    form.append('ostype', '5')
-    form.append('data', dataEncrypt(JSON.stringify(raw)))
-
-    const res = await axios.post(`${BASE_URL}/Run/setRunZone`, form, {
-        headers: {
-            'Content-Type': 'application/x-www-form-urlencoded',
-            Accept: '*/*',
-            'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
-            'User-Agent': userAgent || DEFAULT_UA,
-            Referer: 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
-            charset: 'utf-8'
-        },
-        timeout: 20000,
-        proxy: false
-    })
-
-    let result = res.data
-    if (result?.data && result?.is_encrypt === 1) {
-        result = { ...result, data: JSON.parse(dataDecrypt(result.data)) }
-    }
-    return result
-}
-
-function isProbeSetZoneOk(result) {
-    if (!result) return false
-    const hasData = result.data != null && result.data !== ''
-    if (!hasData) return false
-    if (Number(result.status) === 1) return true
-    const cd = Number(result.code)
-    if (cd === 1 || cd === 200) return true
-    return false
-}
-
-function getProbeFailMessage(result) {
-    if (!result) return ''
-    return (
-        result.info ||
-        result.msg ||
-        result.message ||
-        (typeof result.data === 'object' && result.data
-            ? result.data.info || result.data.msg || result.data.message
-            : '') ||
-        ''
-    )
-}
-
-module.exports = {
-    probeSetZone,
-    isProbeSetZoneOk,
-    getProbeFailMessage,
-    BASE_URL
-}

+ 32 - 0
plugin/jkes/formatRecordRow.js

@@ -0,0 +1,32 @@
+/**
+ * 将 lepao_record.result(JKES end 接口 info)解析为列表/详情用的摘要字段
+ */
+function enrichLepaoRecordRow(row) {
+    const out = { ...row }
+    let jkes = null
+    try {
+        const r =
+            typeof row.result === 'object' && row.result !== null
+                ? row.result
+                : JSON.parse(row.result || '{}')
+        const rawDistance = r.distance != null ? Number(r.distance) : NaN
+        const distanceKm =
+            Number.isFinite(rawDistance) && rawDistance > 0
+                ? Math.round((rawDistance / 1000) * 1000) / 1000
+                : null
+        jkes = {
+            distance_km: distanceKm,
+            use_time_sec: r.useTime != null ? Number(r.useTime) : null,
+            run_status: r.status?.value ?? r.status,
+            campus_status: r.dataStatus?.value ?? r.dataStatus,
+            begin_time: r.beginTime,
+            end_time: r.endTime
+        }
+    } catch {
+        jkes = null
+    }
+    out.jkes_record = jkes
+    return out
+}
+
+module.exports = { enrichLepaoRecordRow }

+ 47 - 0
plugin/jkes/jkesSettings.js

@@ -0,0 +1,47 @@
+const path = require('path')
+
+const defaultJkes = {
+    apiBase: 'https://jkes.smart-campus.com.cn:50077/api',
+    userAgent:
+        'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.69(0x18004539) NetType/WIFI Language/zh_CN',
+    referer: 'https://servicewechat.com/wxbd35ecd93e780082/11/page-frame.html',
+    tlsRejectUnauthorized: true,
+    requestTimeoutMs: 30000,
+    useSystemProxy: false,
+    gpsAltitude: 269.75,
+    gpsDefaultAccuracy: 6,
+    /** 自动乐跑随机配速:每公里秒数 */
+    paceRandomMinSecPerKm: 180,
+    paceRandomMaxSecPerKm: 600,
+    /** 自动乐跑单次最大公里(用于月底追目标) */
+    autoSingleRunMaxKm: 5,
+    /** 自动乐跑距离步进(公里) */
+    autoDistanceStepKm: 0.5
+}
+
+let cached
+
+function getJkesSettings() {
+    if (cached) return cached
+    try {
+        const cfg = require(path.join(__dirname, '../../config.json'))
+        cached = { ...defaultJkes, ...(cfg.jkes || {}) }
+    } catch {
+        cached = { ...defaultJkes }
+    }
+    return cached
+}
+
+function normalizeApiBase(raw) {
+    let u = String(raw || defaultJkes.apiBase).trim()
+    if (u.startsWith('http://')) {
+        u = `https://${u.slice('http://'.length)}`
+    }
+    return u.replace(/\/$/, '')
+}
+
+module.exports = {
+    getJkesSettings,
+    normalizeApiBase,
+    defaultJkes
+}

+ 204 - 0
plugin/jkes/monthPolicy.js

@@ -0,0 +1,204 @@
+/**
+ * 自动乐跑月度规则:在 auto_day 中均匀挑选最多 8 天跑 2 公里,其余自动日为 1 公里。
+ * 【30 公里】仅为学校规定的「本月最低乐跑里程」参考值(用于进度展示等),不是可跑里程上限。
+ */
+const Redis = require('../DataBase/Redis.js')
+const { fetchJkesMonthKm } = require('./stats.js')
+const jkesRedisKeys = require('./redisKeys.js')
+const { getJkesSettings } = require('./jkesSettings')
+
+/** 学校要求的本月最低乐跑总距离(公里),仅作展示/达标参考,不限制继续跑步 */
+const MONTH_TARGET_KM = 30
+const DOUBLE_RUNS_CAP = 8
+
+function parseAutoDay(raw) {
+    if (Array.isArray(raw)) return raw.map(Number).filter((n) => n >= 0 && n <= 6)
+    if (typeof raw === 'string') {
+        try {
+            const a = JSON.parse(raw)
+            return parseAutoDay(a)
+        } catch {
+            return []
+        }
+    }
+    return []
+}
+
+function normalizeMonthTargetKm(targetKm) {
+    const n = Number(targetKm)
+    if (!Number.isFinite(n) || n <= 0) return MONTH_TARGET_KM
+    return Math.max(1, Math.round(n * 100) / 100)
+}
+
+/** 本月所有「落在 auto_day 星期」的日历日(1..31) */
+function candidateRunDates(year, month1to12, autoDays) {
+    const set = new Set(autoDays.map(Number))
+    const last = new Date(year, month1to12, 0).getDate()
+    const out = []
+    for (let dom = 1; dom <= last; dom++) {
+        const wd = new Date(year, month1to12 - 1, dom).getDay()
+        if (set.has(wd)) out.push(dom)
+    }
+    return out
+}
+
+/** 在候选日中均匀挑选最多 8 个作为 2 公里日 */
+function pickDoubleDays(candidates) {
+    if (candidates.length === 0) return new Set()
+    if (candidates.length <= DOUBLE_RUNS_CAP) return new Set(candidates)
+    const out = []
+    for (let i = 0; i < DOUBLE_RUNS_CAP; i++) {
+        const idx = Math.min(candidates.length - 1, Math.floor((i + 0.5) * candidates.length / DOUBLE_RUNS_CAP))
+        out.push(candidates[idx])
+    }
+    return new Set(out)
+}
+
+function monthKey(account, d = new Date()) {
+    return jkesRedisKeys.monthState(account, d.getFullYear(), d.getMonth() + 1)
+}
+
+function monthTtlSec(d = new Date()) {
+    const y = d.getFullYear()
+    const m = d.getMonth()
+    const end = new Date(y, m + 1, 7, 23, 59, 59)
+    return Math.max(86400, Math.floor((end.getTime() - Date.now()) / 1000))
+}
+
+async function readState(account, d = new Date()) {
+    const key = monthKey(account, d)
+    const raw = await Redis.get(key)
+    if (!raw) {
+        return { km: 0, doubles: 0 }
+    }
+    try {
+        const o = JSON.parse(raw)
+        return {
+            km: Number(o.km) || 0,
+            doubles: Number(o.doubles) || 0
+        }
+    } catch {
+        return { km: 0, doubles: 0 }
+    }
+}
+
+async function writeState(account, state, d = new Date()) {
+    const key = monthKey(account, d)
+    const ttl = monthTtlSec(d)
+    await Redis.set(key, JSON.stringify(state), { EX: ttl })
+}
+
+/**
+ * 用官方列表刷新本月已跑千米数(不覆盖 doubles 计数)
+ */
+async function refreshMonthKmFromApi(account, token, d = new Date()) {
+    if (!token) return
+    const y = d.getFullYear()
+    const m = d.getMonth() + 1
+    const apiKm = await fetchJkesMonthKm(token, y, m)
+    const prev = await readState(account, d)
+    /** 官方 mylist 里程、配速往往延迟数分钟~数十分钟才更新,取较大值避免覆盖 recordSuccess/本地进度 */
+    const km = Math.max(apiKm, Number(prev.km) || 0)
+    await writeState(account, { km, doubles: prev.doubles }, d)
+    return km
+}
+
+/**
+ * 成功跑完后更新本地月度缓存(千米,来自 end 接口或回退目标距离)
+ * @param {number} actualKm 本次计入里程
+ * @param {{ autoDoubleSlot?: boolean }} [options] 自动任务且为计划中的 2 公里日时递增 doubles
+ */
+async function recordSuccess(account, actualKm, options = {}, d = new Date()) {
+    const prev = await readState(account, d)
+    const add = Math.max(0, Number(actualKm) || 0)
+    const km = prev.km + add
+    let doubles = prev.doubles
+    if (options.autoDoubleSlot) {
+        doubles = prev.doubles + 1
+    }
+    await writeState(account, { km, doubles }, d)
+}
+
+function isDoubleKmDom(year, month1to12, autoDays, dom) {
+    const c = candidateRunDates(year, month1to12, autoDays)
+    const doubles = pickDoubleDays(c)
+    return doubles.has(dom)
+}
+
+function roundToStep(value, step = 0.5) {
+    const s = Number(step)
+    if (!Number.isFinite(s) || s <= 0) return Math.round(value * 100) / 100
+    return Math.round(Math.ceil(value / s) * s * 100) / 100
+}
+
+/**
+ * 自动任务:在 auto_day 且当日可跑时投递;2 公里日最多占满 DOUBLE_RUNS_CAP 次,之后均为 1 公里。
+ * 不因已达 30km 而停止(30km 为最低要求参考,非额度)。
+ */
+async function planJkesAutoRun(account, autoDayRaw, token, options = {}, d = new Date()) {
+    const cfg = getJkesSettings()
+    const monthTargetKm = normalizeMonthTargetKm(options.monthTargetKm)
+    const stopAfterMinimum = options.stopAfterMinimum !== false
+    const maxAutoSingleKm = Math.max(2, Number(cfg.autoSingleRunMaxKm) || 5)
+    const stepKm = Number(cfg.autoDistanceStepKm) || 0.5
+
+    const autoDays = parseAutoDay(autoDayRaw)
+    const wd = d.getDay()
+    if (autoDays.length > 0 && !autoDays.includes(wd)) {
+        return { run: false, reason: '非自动乐跑星期' }
+    }
+
+    await refreshMonthKmFromApi(account, token, d)
+    const state = await readState(account, d)
+    if (stopAfterMinimum && state.km >= monthTargetKm) {
+        return { run: false, reason: `本月已达目标 ${monthTargetKm}km` }
+    }
+
+    const y = d.getFullYear()
+    const m = d.getMonth() + 1
+    const dom = d.getDate()
+    const candidates = candidateRunDates(y, m, autoDays).filter((x) => x >= dom)
+    const remainingDays = candidates.length || 1
+    const remainingKm = Math.max(0, monthTargetKm - state.km)
+
+    // 不补跑前提下,为保证月底前达标,按剩余自动日动态放大单次公里
+    const requiredBySchedule = remainingKm > 0 ? remainingKm / remainingDays : 1
+    let targetKm = Math.max(1, roundToStep(requiredBySchedule, stepKm))
+
+    // 至少 8 天达到 >=2km:按均匀双倍日 + 末期兜底两层保障
+    const doubleRemaining = Math.max(0, DOUBLE_RUNS_CAP - (Number(state.doubles) || 0))
+    const mustDoubleToday = doubleRemaining > 0 && doubleRemaining >= remainingDays
+    const plannedDoubleToday = isDoubleKmDom(y, m, autoDays, dom)
+    if (mustDoubleToday || plannedDoubleToday) {
+        targetKm = Math.max(targetKm, 2)
+    }
+    targetKm = Math.min(maxAutoSingleKm, Math.max(1, Math.round(targetKm * 100) / 100))
+
+    return { run: true, targetKm, monthTargetKm }
+}
+
+/**
+ * 手动发起:不校验星期;单次 distanceKm 为 1–5。本月已跑里程不设上限(30km 仅为学校最低要求参考)。
+ */
+async function planJkesManualRun(account, token, distanceKm = 1, d = new Date()) {
+    const kmReq = Number(distanceKm)
+    if (!Number.isFinite(kmReq) || kmReq < 1 || kmReq > 5) {
+        return { run: false, reason: '单次距离需在 1–5 公里' }
+    }
+    await refreshMonthKmFromApi(account, token, d)
+    return { run: true, targetKm: Math.round(kmReq * 1000) / 1000 }
+}
+
+module.exports = {
+    MONTH_TARGET_KM,
+    DOUBLE_RUNS_CAP,
+    parseAutoDay,
+    normalizeMonthTargetKm,
+    monthKey,
+    readState,
+    writeState,
+    refreshMonthKmFromApi,
+    recordSuccess,
+    planJkesAutoRun,
+    planJkesManualRun
+}

+ 65 - 0
plugin/jkes/paceUtils.js

@@ -0,0 +1,65 @@
+/**
+ * 配速:每公里用时(秒)
+ * 支持 390、6:30、6'30"、6′30″、3"00"(分+秒直引号)
+ */
+
+const MANUAL_PACE_MIN_SEC = 180 // 3'00"/km
+const MANUAL_PACE_MAX_SEC = 600 // 10'00"/km
+
+function parsePaceToSecPerKm(input) {
+    if (input === '' || input === null || input === undefined) {
+        throw new Error('缺少配速')
+    }
+    const s = String(input).trim()
+
+    if (/^\d+(\.\d+)?$/.test(s)) {
+        const n = parseFloat(s)
+        if (n < 10) {
+            throw new Error('纯数字配速表示每公里秒数,例如 390 表示 6:30/km')
+        }
+        return n
+    }
+
+    const colon = s.match(/^(\d+)[::](\d{1,2}(?:\.\d+)?)$/)
+    if (colon) {
+        return parseInt(colon[1], 10) * 60 + parseFloat(colon[2])
+    }
+
+    const quote = s.match(/^(\d+)['′『](\d{1,2}(?:\.\d+)?)[""″』]?$/)
+    if (quote) {
+        return parseInt(quote[1], 10) * 60 + parseFloat(quote[2])
+    }
+
+    const weirdQuote = s.match(/^(\d+)"(\d{2})"$/)
+    if (weirdQuote) {
+        return parseInt(weirdQuote[1], 10) * 60 + parseInt(weirdQuote[2], 10)
+    }
+
+    throw new Error(`无法解析配速「${s}」,请使用 5:30、5'30" 或每公里秒数如 330`)
+}
+
+function clampManualPaceSec(sec) {
+    const n = Number(sec)
+    if (!Number.isFinite(n)) {
+        throw new Error('配速无效')
+    }
+    if (n < MANUAL_PACE_MIN_SEC || n > MANUAL_PACE_MAX_SEC) {
+        throw new Error(`单次乐跑配速需在 3:00–10:00 /km(${MANUAL_PACE_MIN_SEC}–${MANUAL_PACE_MAX_SEC} 秒/公里)`)
+    }
+    return n
+}
+
+/** 自动乐跑:闭区间随机秒/公里 */
+function randomPaceSecPerKm(minSec, maxSec) {
+    const lo = Math.max(120, Math.min(minSec, maxSec))
+    const hi = Math.max(lo, Math.max(minSec, maxSec))
+    return Math.round(lo + Math.random() * (hi - lo))
+}
+
+module.exports = {
+    parsePaceToSecPerKm,
+    clampManualPaceSec,
+    randomPaceSecPerKm,
+    MANUAL_PACE_MIN_SEC,
+    MANUAL_PACE_MAX_SEC
+}

+ 39 - 0
plugin/jkes/redisKeys.js

@@ -0,0 +1,39 @@
+/**
+ * JKES 相关 Redis 键(与旧系统 lepao* 隔离,统一 jkes_ / jkes_* 前缀)
+ * 账户以学号 student_num(JKES 的 code)为主键。
+ */
+
+function runnerFlag(studentNum) {
+    return `jkes_runner:${studentNum}`
+}
+
+function lepaoSuccess(studentNum) {
+    return `jkes_lepaoSuccess:${studentNum}`
+}
+
+function lepaoProgress(studentNum) {
+    return `jkes_lepaoProgress:${studentNum}`
+}
+
+/** @param {number} month1to12 */
+function monthState(studentNum, year, month1to12) {
+    const m = String(month1to12).padStart(2, '0')
+    return `jkes_month:${studentNum}:${year}-${m}`
+}
+
+function consume(baseKey) {
+    return `jkes_lepao:consume:${baseKey}`
+}
+
+function refund(baseKey) {
+    return `jkes_lepao:refund:${baseKey}`
+}
+
+module.exports = {
+    runnerFlag,
+    lepaoSuccess,
+    lepaoProgress,
+    monthState,
+    consume,
+    refund
+}

+ 119 - 0
plugin/jkes/request.js

@@ -0,0 +1,119 @@
+const axios = require('axios')
+const https = require('https')
+const path = require('path')
+const Logger = require('../../lib/Logger')
+const { getJkesSettings, normalizeApiBase } = require('./jkesSettings')
+
+const logger = new Logger(path.join(__dirname, '../../logs/JKES.log'), 'INFO')
+
+let jkesHttpsAgent
+function getJkesHttpsAgent() {
+    if (!jkesHttpsAgent) {
+        const s = getJkesSettings()
+        jkesHttpsAgent = new https.Agent({
+            keepAlive: true,
+            minVersion: 'TLSv1.2',
+            rejectUnauthorized: s.tlsRejectUnauthorized !== false
+        })
+    }
+    return jkesHttpsAgent
+}
+
+function buildJkesHeaders(token) {
+    const tokenClean = String(token ?? '').trim()
+    const s = getJkesSettings()
+    return {
+        'x-auth-token': tokenClean,
+        'content-type': 'application/json',
+        'Accept-Encoding': 'gzip,compress,br,deflate',
+        'User-Agent': s.userAgent,
+        Referer: s.referer
+    }
+}
+
+/**
+ * @param {string} url 如 /sys/user/getMyInfo
+ * @param {object} [data] POST body
+ * @param {string} token
+ */
+async function jkesRequest(url, data, token) {
+    const tokenClean = String(token ?? '').trim()
+    if (!tokenClean) {
+        logger.error('[JKES] 缺少 token')
+        return null
+    }
+
+    const s = getJkesSettings()
+    const pathPart = url.startsWith('/') ? url : `/${url}`
+    const fullUrl = `${normalizeApiBase(s.apiBase)}${pathPart}`
+    const body = data === undefined || data === null ? {} : data
+
+    let parsed
+    try {
+        parsed = new URL(fullUrl)
+    } catch (e) {
+        logger.error(`[JKES] 非法 URL: ${fullUrl}`)
+        return null
+    }
+    if (parsed.protocol !== 'https:') {
+        logger.error(`[JKES] 必须使用 https: ${fullUrl}`)
+        return null
+    }
+
+    try {
+        logger.info(`[JKES] POST ${pathPart}`)
+        const axiosOpts = {
+            headers: buildJkesHeaders(tokenClean),
+            timeout: Number(s.requestTimeoutMs) || 30000,
+            validateStatus: () => true,
+            httpsAgent: getJkesHttpsAgent(),
+            beforeRedirect: (options) => {
+                if (options.protocol !== 'https:') {
+                    logger.error(`[JKES] 拒绝跟随非 HTTPS 重定向: ${options.href}`)
+                    throw new Error('JKES 重定向目标必须为 https')
+                }
+            }
+        }
+        if (!s.useSystemProxy) {
+            axiosOpts.proxy = false
+        }
+
+        const res = await axios.post(fullUrl, body, axiosOpts)
+
+        const payload = res.data
+        const payloadPreview =
+            typeof payload === 'object' && payload !== null
+                ? JSON.stringify(payload)
+                : String(payload ?? '')
+
+        if (res.status !== 200) {
+            logger.error(
+                `[JKES] HTTP ${res.status} ${pathPart} 响应: ${payloadPreview.slice(0, 1000)}`
+            )
+            if (typeof payload === 'object' && payload !== null) {
+                return payload
+            }
+            return null
+        }
+
+        return payload
+    } catch (error) {
+        const st = error.response?.status
+        const dataErr = error.response?.data
+        const errStr =
+            typeof dataErr === 'object' && dataErr !== null
+                ? JSON.stringify(dataErr)
+                : String(dataErr ?? '')
+        logger.error(
+            `[JKES] 请求异常 ${pathPart} http=${st ?? 'n/a'} body=${errStr.slice(0, 500)} ${error.message}`
+        )
+        return null
+    }
+}
+
+module.exports = {
+    jkesRequest,
+    get BASE_URL() {
+        return normalizeApiBase(getJkesSettings().apiBase)
+    }
+}

+ 403 - 0
plugin/jkes/runRecord.js

@@ -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)
+    }
+}

+ 207 - 0
plugin/jkes/stats.js

@@ -0,0 +1,207 @@
+/**
+ * JKES 跑步记录汇总(mylist 分页)
+ */
+const { jkesRequest } = require('./request.js')
+
+function normStatusValue(v) {
+    if (v == null) return ''
+    if (typeof v === 'object' && v.value != null) return String(v.value).toUpperCase()
+    return String(v).toUpperCase()
+}
+
+function isEndRecord(r) {
+    const s = normStatusValue(r.status)
+    return s === 'END'
+}
+
+function isIncampusRecord(r) {
+    const s = normStatusValue(r.dataStatus)
+    return s === 'INCAMPUS'
+}
+
+function parseOneTime(v) {
+    if (v == null) return NaN
+    if (typeof v === 'number' && v > 1e12) return v
+    if (typeof v === 'number' && v > 1e9 && v < 1e12) return v * 1000
+    const str = String(v).trim()
+    if (/^\d+$/.test(str)) {
+        const n = parseInt(str, 10)
+        if (n > 1e12) return n
+        if (n > 1e9) return n * 1000
+    }
+    const t = Date.parse(str.replace(/-/g, '/'))
+    return Number.isFinite(t) ? t : NaN
+}
+
+/** 归属月份:优先结束时间,否则开始时间 */
+function parseRecordMonthTime(r) {
+    const tEnd = parseOneTime(r.endTime ?? r.end_time ?? r.finishTime)
+    if (Number.isFinite(tEnd)) return tEnd
+    const tBegin = parseOneTime(r.beginTime ?? r.startTime ?? r.createTime ?? r.deviceTime)
+    return Number.isFinite(tBegin) ? tBegin : NaN
+}
+
+/**
+ * 单条记录距离 → 千米(真机 mylist / end 返回的 distance 为米,如 6324.01≈6.324km)。
+ * END+校内且 distance 为 0:官方尚未写入里程,返回 0(不计入汇总,待列表更新后再算)。
+ */
+function recordDistanceKm(r) {
+    const raw =
+        r.distance ??
+        r.info?.distance ??
+        r.runDistance ??
+        r.mileage ??
+        r.totalDistance ??
+        r.length
+    let d = typeof raw === 'string' ? parseFloat(raw.trim()) : Number(raw)
+    if (!Number.isFinite(d) || d <= 0) return 0
+    const km = d / 1000
+    if (!Number.isFinite(km) || km <= 0) return 0
+    return Math.round(km * 1000) / 1000
+}
+
+function extractPage(apiData) {
+    if (!apiData || apiData.code !== 0 || !apiData.data) return null
+    const d = apiData.data
+    if (d.page && (Array.isArray(d.page.records) || d.page.records === undefined)) {
+        return {
+            records: Array.isArray(d.page.records) ? d.page.records : [],
+            pages: Number(d.page.pages) || 1,
+            current: Number(d.page.current) || 1
+        }
+    }
+    if (Array.isArray(d.records)) {
+        return {
+            records: d.records,
+            pages: Number(d.pages) || 1,
+            current: Number(d.current) || 1
+        }
+    }
+    if (Array.isArray(d.list)) {
+        return { records: d.list, pages: Number(d.pages) || 1, current: Number(d.page) || 1 }
+    }
+    return null
+}
+
+function parseRecordSpeed(r) {
+    const raw = r.speed ?? r.info?.speed
+    const n = Number(raw)
+    return Number.isFinite(n) ? n : 0
+}
+
+/**
+ * 统计本月「结束且校内」的跑步距离之和(千米)
+ */
+async function fetchJkesMonthKm(token, year, month1to12) {
+    const monthStart = new Date(year, month1to12 - 1, 1).getTime()
+    const monthEnd = new Date(year, month1to12, 0, 23, 59, 59, 999).getTime()
+
+    let page = 1
+    let totalKm = 0
+    const pageSize = 50
+    let pages = 1
+
+    while (page <= pages) {
+        const url = `/health/runRecord/mylist?pageSize=${pageSize}&page=${page}`
+        const data = await jkesRequest(url, {}, token)
+        const pg = extractPage(data)
+        if (!pg) break
+
+        pages = Number(pg.pages) || 1
+        const records = pg.records || []
+
+        for (const r of records) {
+            if (!isEndRecord(r)) continue
+            if (!isIncampusRecord(r)) continue
+            const t = parseRecordMonthTime(r)
+            if (!Number.isFinite(t) || t < monthStart || t > monthEnd) continue
+            totalKm += recordDistanceKm(r)
+        }
+        page += 1
+        if (records.length === 0) break
+    }
+
+    console.log(`[JKES] 本月跑步距离: ${totalKm} km`)
+
+    return Math.round(totalKm * 1000) / 1000
+}
+
+/**
+ * 全量有效记录里程(千米),用于 total_num 近似同步
+ */
+async function fetchJkesTotalKm(token) {
+    let page = 1
+    let totalKm = 0
+    const pageSize = 50
+    let pages = 1
+
+    while (page <= pages) {
+        const url = `/health/runRecord/mylist?pageSize=${pageSize}&page=${page}`
+        const data = await jkesRequest(url, {}, token)
+        const pg = extractPage(data)
+        if (!pg) break
+
+        pages = Number(pg.pages) || 1
+        const records = pg.records || []
+
+        for (const r of records) {
+            if (!isEndRecord(r)) continue
+            if (!isIncampusRecord(r)) continue
+            totalKm += recordDistanceKm(r)
+        }
+        page += 1
+        if (records.length === 0) break
+    }
+
+    return Math.round(totalKm * 1000) / 1000
+}
+
+/** end 接口返回的 info 与列表记录字段兼容 */
+function distanceKmFromEndInfo(info) {
+    return recordDistanceKm(info || {})
+}
+
+/**
+ * 按记录 ID 在 mylist 分页中查找。
+ * 刚结束时可能出现 END+INCAMPUS 但 distance/speed 尚未更新。
+ */
+async function fetchJkesRecordById(token, recordId, maxPages = 8) {
+    const id = String(recordId || '').trim()
+    if (!id) return null
+    let page = 1
+    const pageSize = 50
+    let pages = 1
+
+    while (page <= pages && page <= maxPages) {
+        const url = `/health/runRecord/mylist?pageSize=${pageSize}&page=${page}`
+        const data = await jkesRequest(url, {}, token)
+        const pg = extractPage(data)
+        if (!pg) return null
+        pages = Number(pg.pages) || 1
+        const hit = (pg.records || []).find((x) => String(x?.id) === id)
+        if (hit) return hit
+        page += 1
+    }
+    return null
+}
+
+function isJkesRecordValidInCampus(r) {
+    return !!r && isEndRecord(r) && isIncampusRecord(r)
+}
+
+/** 完整同步:校内有效 + 距离>0 + 配速/速度>0 */
+function isJkesRecordFullySynced(r) {
+    if (!isJkesRecordValidInCampus(r)) return false
+    return recordDistanceKm(r) > 0 && parseRecordSpeed(r) > 0
+}
+
+module.exports = {
+    fetchJkesMonthKm,
+    fetchJkesTotalKm,
+    recordDistanceKm,
+    distanceKmFromEndInfo,
+    extractPage,
+    fetchJkesRecordById,
+    isJkesRecordValidInCampus,
+    isJkesRecordFullySynced
+}

+ 140 - 0
plugin/jkes/syncLepaoAccountFromToken.js

@@ -0,0 +1,140 @@
+/**
+ * 用 JKES token 拉取 getMyInfo 并更新 lepao_account(哪吒乐跑 / JKES 唯一数据源)
+ */
+const db = require('../DataBase/db.js')
+const { jkesRequest } = require('./request.js')
+
+const DEFAULT_AVATAR =
+    'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
+
+/**
+ * @param {string} token
+ * @param {{ userAgent?: string, deviceModel?: string }} [device]
+ * @returns {Promise<
+ *   | { ok: false, msg: string, jkesRes?: object }
+ *   | {
+ *       ok: true
+ *       student_num: string
+ *       findRows: object[]
+ *       profile: {
+ *         name: string
+ *         academy_name: string
+ *         grade_id: any
+ *         class_id: any
+ *         sex: number
+ *         user_avatar: string
+ *       }
+ *     }
+ * >}
+ */
+async function syncLepaoAccountFromToken(token, device = {}) {
+    const jkesRes = await jkesRequest('/sys/user/getMyInfo', {}, token)
+    console.log(jkesRes)
+    if (!jkesRes || jkesRes.code !== 0 || !jkesRes.data || !jkesRes.data.info) {
+        return {
+            ok: false,
+            msg: jkesRes?.message || '获取用户信息失败,请稍后重试或联系客服',
+            jkesRes
+        }
+    }
+
+    const {
+        code,
+        realName,
+        identifyCode,
+        mobileNumber,
+        gender,
+        gradeYear,
+        collegeCode,
+        classCode,
+        homeAddr
+    } = jkesRes.data.info
+
+    const sex = gender?.label === '男' ? 1 : 2
+    const name = realName
+    const academy_name = collegeCode?.name ?? ''
+    const grade_id = gradeYear
+    const class_id = classCode?.className ?? ''
+    const student_num = code
+    const user_avatar = DEFAULT_AVATAR
+
+    const findSql = `
+        SELECT 
+            a.email, a.create_user, a.auto_run, a.auto_day, a.notice_type, e.bot_umo
+        FROM
+            lepao_account a
+        LEFT JOIN
+            lepao_extra e
+        ON
+            a.student_num = e.student_num
+        WHERE
+            a.student_num = ? AND a.create_user IS NOT NULL
+        `
+
+    const findRows = await db.query(findSql, [student_num])
+    if (!findRows) {
+        return { ok: false, msg: '无法获取用户数据,请重试' }
+    }
+    if (findRows.length === 0) {
+        return {
+            ok: false,
+            msg: '该乐跑账号尚未在哪吒乐跑中添加,请先前往 https://jkes.xxoo365.top/ 添加你的账户'
+        }
+    }
+
+    const time = Date.now()
+    const ua = device.userAgent
+    const dm = device.deviceModel
+
+    let updateSql =
+        'UPDATE lepao_account SET token = ?, name = ?, grade_id = ?, class_id = ?, sex = ?, academy_name = ?, update_time = ?, user_avatar = ?, identifyCode = ?, mobileNumber = ?, homeAddr = ?, state = 1'
+    const params = [
+        token,
+        name,
+        grade_id,
+        class_id,
+        sex,
+        academy_name,
+        time,
+        user_avatar,
+        identifyCode,
+        mobileNumber,
+        homeAddr
+    ]
+
+    if (ua != null && ua !== '') {
+        updateSql += ', userAgent = ?'
+        params.push(ua)
+    }
+    if (dm != null && dm !== '') {
+        updateSql += ', deviceModel = ?'
+        params.push(dm)
+    }
+
+    updateSql += ' WHERE student_num = ?'
+    params.push(student_num)
+
+    const updateRows = await db.query(updateSql, params)
+    if (!updateRows || updateRows.affectedRows < 1) {
+        return { ok: false, msg: '更新账号信息失败,请重试' }
+    }
+
+    return {
+        ok: true,
+        student_num,
+        findRows,
+        profile: {
+            name,
+            academy_name,
+            grade_id,
+            class_id,
+            sex,
+            user_avatar
+        }
+    }
+}
+
+module.exports = {
+    syncLepaoAccountFromToken,
+    DEFAULT_AVATAR
+}

+ 123 - 0
plugin/jkes/updateAccountCore.js

@@ -0,0 +1,123 @@
+const EmailTemplate = require('../Email/emailTemplate.js')
+const { enqueueLepaoStartRun } = require('../mq/enqueueLepaoStartRun')
+const mq = require('../mq')
+const { BaseStdResponse } = require('../../BaseStdResponse.js')
+const Redis = require('../DataBase/Redis.js')
+const jkesRedisKeys = require('./redisKeys.js')
+const { syncLepaoAccountFromToken } = require('./syncLepaoAccountFromToken.js')
+
+async function executeLepaoTokenUpdate(ctx, req, res) {
+    const { logger, messageQueue } = ctx
+    const { token } = req.body
+
+    if ([token].some((value) => value === '' || value === null || value === undefined)) {
+        return res.json({
+            ...BaseStdResponse.MISSING_PARAMETER
+        })
+    }
+
+    try {
+        const sync = await syncLepaoAccountFromToken(token)
+        if (!sync.ok) {
+            logger.error(`获取/更新用户信息失败: ${sync.msg}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: sync.msg
+            })
+        }
+
+        const { student_num, findRows, profile } = sync
+
+        try {
+            await Redis.set(jkesRedisKeys.runnerFlag(student_num), '1')
+        } catch (e) {
+            logger.error(`${student_num} 写入 jkes_runner 标记失败:${e.message || e}`)
+        }
+
+        let msg
+        if (findRows[0].auto_run === 1) {
+            msg = `当前已开启自动乐跑,系统将自动进行乐跑。后续通知将发送到您预留的联系方式,请留意。`
+        } else {
+            msg = `当前未开启自动乐跑,如需进行乐跑,请前往 哪吒乐跑 手动执行乐跑操作。后续通知将发送到您预留的联系方式,请留意。`
+        }
+
+        res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                name: profile.name,
+                user_avatar: profile.user_avatar,
+                sex: profile.sex,
+                academy_name: profile.academy_name,
+                grade_id: profile.grade_id,
+                class_id: profile.class_id,
+                auto_run: findRows[0].auto_run,
+                account: student_num,
+                msg
+            }
+        })
+
+        const emailData = {
+            name: profile.name,
+            type: 'lepao_update',
+            umo: findRows[0].bot_umo,
+            account: student_num,
+            academy_name: profile.academy_name,
+            grade_id: profile.grade_id,
+            auto_run: findRows[0].auto_run
+        }
+
+        if (findRows[0].notice_type === 'bot' && findRows[0].bot_umo) {
+            logger.info(`${student_num}发送乐跑更新Bot通知,UMO=${findRows[0].bot_umo}`)
+            const ch = await mq.getChannel(messageQueue)
+
+            await ch.assertQueue(messageQueue, {
+                durable: true
+            })
+
+            ch.sendToQueue(messageQueue, Buffer.from(JSON.stringify(emailData)), {
+                persistent: true,
+                contentType: 'application/json'
+            })
+
+            logger.info(`${student_num}乐跑更新Bot通知发送完成`)
+        } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
+            await EmailTemplate.updateSuccess(findRows[0].email, emailData)
+            logger.info(`${student_num}乐跑更新邮件发送完成`)
+        }
+
+        if (
+            findRows[0].auto_run === 1 &&
+            Array.isArray(findRows[0].auto_day) &&
+            findRows[0].auto_day.includes(new Date().getDay())
+        ) {
+            const { planJkesAutoRun } = require('./monthPolicy')
+            planJkesAutoRun(student_num, findRows[0].auto_day, token, {
+                monthTargetKm: findRows[0].target_count,
+                stopAfterMinimum: true
+            })
+                .then((plan) => {
+                    if (plan.run) {
+                        enqueueLepaoStartRun(student_num, logger, {
+                            targetKm: plan.targetKm,
+                            autoDoubleSlot: plan.targetKm >= 2
+                        })
+                    } else {
+                        logger.info(`${student_num} 登录后未触发 JKES 自动乐跑:${plan.reason}`)
+                    }
+                })
+                .catch((e) => {
+                    logger.error(`${student_num} JKES 自动乐跑计划失败:${e.message || e}`)
+                })
+        }
+    } catch (error) {
+        logger.error(`更新用户信息时出错。${error.stack}`)
+        return res.json({
+            ...BaseStdResponse.ERR,
+            msg: '更新用户信息失败,请重试'
+        })
+    }
+}
+
+module.exports = {
+    executeLepaoTokenUpdate
+}

+ 15 - 7
plugin/mq/enqueueLepaoStartRun.js

@@ -1,21 +1,29 @@
 const mq = require('./index')
 const mq = require('./index')
 const { assertRunforgeTaskIngress, publishRunforgeTask } = require('./runforgeTaskMq')
 const { assertRunforgeTaskIngress, publishRunforgeTask } = require('./runforgeTaskMq')
+const mqNames = require('./jkesMqNames')
 
 
 /**
 /**
- * 将乐跑任务写入 MQ,由 Worker 执行(与 SingleRun 一致
+ * 将乐跑任务写入 MQ,由 Worker 执行(JKES
  */
  */
-async function enqueueLepaoStartRun(studentNum, logger) {
+async function enqueueLepaoStartRun(studentNum, logger, options = {}) {
     try {
     try {
-        const channel = await mq.getChannel('lepao_account_autorun')
+        const channel = await mq.getChannel(mqNames.channelAccountAutorun)
         await assertRunforgeTaskIngress(channel, logger)
         await assertRunforgeTaskIngress(channel, logger)
         const taskId = `lepao:account:${Date.now()}:${studentNum}`
         const taskId = `lepao:account:${Date.now()}:${studentNum}`
+        const data = {
+            taskId,
+            account: studentNum
+        }
+        if (options.targetKm != null) {
+            data.targetKm = options.targetKm
+        }
+        if (options.autoDoubleSlot) {
+            data.autoDoubleSlot = true
+        }
         publishRunforgeTask(channel, {
         publishRunforgeTask(channel, {
             id: taskId,
             id: taskId,
             type: 'lepao.startRun',
             type: 'lepao.startRun',
-            data: {
-                taskId,
-                account: studentNum
-            },
+            data,
             retry: 0
             retry: 0
         })
         })
     } catch (e) {
     } catch (e) {

+ 16 - 0
plugin/mq/jkesMqNames.js

@@ -0,0 +1,16 @@
+/**
+ * JKES 乐跑侧 RabbitMQ 连接名 / 与旧系统隔离
+ */
+
+module.exports = {
+    /** 手动触发乐跑 */
+    channelLepaoApi: 'jkes_lepao_api',
+    /** 定时/补充乐跑 */
+    channelLepaoCorn: 'jkes_lepao_corn',
+    /** 登录后自动投递 */
+    channelAccountAutorun: 'jkes_lepao_account_autorun',
+    /** Redis 延迟调度 tick */
+    channelScheduleTick: 'jkes_lepao_schedule_tick',
+    /** Bot 通知(UpdateAccount JKES) */
+    queueNotify: 'jkes_lepao_notify_queue'
+}

+ 3 - 14
plugin/mq/lepaoAutoScheduleRedis.js

@@ -1,7 +1,7 @@
 const Redis = require('../DataBase/Redis')
 const Redis = require('../DataBase/Redis')
 
 
-/** 到期后由进程定时器投递到 runforge_task_queue(重启不丢,依赖 Redis) */
-const SCHEDULE_KEY = 'lepao:mq:scheduled'
+/** JKES 延迟调度 ZSET(唯一) */
+const SCHEDULE_KEY = 'jkes_lepao:mq:scheduled'
 
 
 const POP_DUE_LUA = `
 const POP_DUE_LUA = `
 local key = KEYS[1]
 local key = KEYS[1]
@@ -21,10 +21,6 @@ function stripScheduleMeta(msg) {
     return copy
     return copy
 }
 }
 
 
-/**
- * 延迟投递:整消息入 ZSET,score = fireAt(毫秒时间戳)
- * @param {object} meta 可选,管理端展示用:{ name, account, delayMs }
- */
 async function scheduleDelayedRunforgeTask(fireAt, messageObject, meta = null) {
 async function scheduleDelayedRunforgeTask(fireAt, messageObject, meta = null) {
     const toStore =
     const toStore =
         meta != null
         meta != null
@@ -45,9 +41,6 @@ async function requeueAt(fireAt, messageObject) {
     await Redis.sendCommand(['ZADD', SCHEDULE_KEY, String(fireAt), member])
     await Redis.sendCommand(['ZADD', SCHEDULE_KEY, String(fireAt), member])
 }
 }
 
 
-/**
- * 原子取出已到期的成员(原始 JSON 字符串数组)
- */
 async function popDueMessages(now = Date.now(), limit = 100) {
 async function popDueMessages(now = Date.now(), limit = 100) {
     const res = await Redis.sendCommand([
     const res = await Redis.sendCommand([
         'EVAL',
         'EVAL',
@@ -61,14 +54,10 @@ async function popDueMessages(now = Date.now(), limit = 100) {
     return res.map((x) => (Buffer.isBuffer(x) ? x.toString('utf8') : String(x)))
     return res.map((x) => (Buffer.isBuffer(x) ? x.toString('utf8') : String(x)))
 }
 }
 
 
-/** 清理过久未弹出项(异常场景) */
 async function pruneStaleScheduled(beforeScore, now = Date.now()) {
 async function pruneStaleScheduled(beforeScore, now = Date.now()) {
     await Redis.sendCommand(['ZREMRANGEBYSCORE', SCHEDULE_KEY, '-inf', String(beforeScore)])
     await Redis.sendCommand(['ZREMRANGEBYSCORE', SCHEDULE_KEY, '-inf', String(beforeScore)])
 }
 }
 
 
-/**
- * 管理端:尚未到期的调度(score > now)
- */
 async function listPendingScheduledForAdmin(now = Date.now(), limitTotal = 800) {
 async function listPendingScheduledForAdmin(now = Date.now(), limitTotal = 800) {
     await pruneStaleScheduled(now - 48 * 3600 * 1000, now)
     await pruneStaleScheduled(now - 48 * 3600 * 1000, now)
 
 
@@ -110,7 +99,7 @@ async function listPendingScheduledForAdmin(now = Date.now(), limitTotal = 800)
 
 
     return {
     return {
         items,
         items,
-        note: 'Redis 调度:未到 fireAt 前不会进入 runforge_task_queue;到期由本机定时任务写入 MQ。'
+        note: 'Redis 调度:未到 fireAt 前不会进入 jkes_runforge_task_queue;到期由定时任务写入 MQ。'
     }
     }
 }
 }
 
 

+ 5 - 4
plugin/mq/lepaoSchedulePublisher.js

@@ -6,11 +6,12 @@ const {
     requeueAt,
     requeueAt,
     SCHEDULE_KEY
     SCHEDULE_KEY
 } = require('./lepaoAutoScheduleRedis')
 } = require('./lepaoAutoScheduleRedis')
+const mqNames = require('./jkesMqNames')
 
 
 let intervalHandle = null
 let intervalHandle = null
 
 
 /**
 /**
- * 定时将 Redis 中已到期的乐跑任务写入 runforge_task_queue
+ * 定时将 Redis 中已到期的 JKES 乐跑任务写入 jkes_runforge_task_queue
  */
  */
 function startLepaoSchedulePublisher(options = {}) {
 function startLepaoSchedulePublisher(options = {}) {
     const logger = options.logger || console
     const logger = options.logger || console
@@ -25,7 +26,7 @@ function startLepaoSchedulePublisher(options = {}) {
             const rawList = await popDueMessages(now, batch)
             const rawList = await popDueMessages(now, batch)
             if (!rawList.length) return
             if (!rawList.length) return
 
 
-            const channel = await mq.getChannel('lepao_schedule_tick')
+            const channel = await mq.getChannel(mqNames.channelScheduleTick)
 
 
             for (const raw of rawList) {
             for (const raw of rawList) {
                 let msg
                 let msg
@@ -33,7 +34,7 @@ function startLepaoSchedulePublisher(options = {}) {
                     msg = JSON.parse(raw)
                     msg = JSON.parse(raw)
                 } catch (e) {
                 } catch (e) {
                     logger.error?.(
                     logger.error?.(
-                        `[LepaoSchedule] 调度 JSON 无效已丢弃 key=${SCHEDULE_KEY}: ${String(raw).slice(0, 120)}`
+                        `[LepaoSchedule] 调度 JSON 无效已丢弃: ${String(raw).slice(0, 120)}`
                     )
                     )
                     continue
                     continue
                 }
                 }
@@ -56,7 +57,7 @@ function startLepaoSchedulePublisher(options = {}) {
     }, intervalMs)
     }, intervalMs)
 
 
     logger.info?.(
     logger.info?.(
-        `[LepaoSchedule] 已启动 Redis→MQ 调度(间隔 ${intervalMs}ms,每批最多 ${batch} 条,key=${SCHEDULE_KEY})`
+        `[LepaoSchedule] 已启动 Redis→MQ(JKES)调度(间隔 ${intervalMs}ms,每批最多 ${batch} 条,key=${SCHEDULE_KEY})`
     )
     )
 }
 }
 
 

+ 2 - 7
plugin/mq/runforgeTaskMq.js

@@ -1,16 +1,11 @@
-const TASK_QUEUE = 'runforge_task_queue'
+/** JKES 乐跑任务队列(已无旧版工商队列) */
+const TASK_QUEUE = 'jkes_runforge_task_queue'
 
 
-/**
- * 声明乐跑任务主队列(无 RabbitMQ 插件)
- */
 async function assertRunforgeTaskIngress(channel, logger) {
 async function assertRunforgeTaskIngress(channel, logger) {
     await channel.assertQueue(TASK_QUEUE, { durable: true })
     await channel.assertQueue(TASK_QUEUE, { durable: true })
     return { mode: 'direct', queue: TASK_QUEUE }
     return { mode: 'direct', queue: TASK_QUEUE }
 }
 }
 
 
-/**
- * 投递乐跑任务 JSON 消息体(与 Worker 消费格式一致)
- */
 function publishRunforgeTask(channel, messageObject) {
 function publishRunforgeTask(channel, messageObject) {
     const body = Buffer.from(JSON.stringify(messageObject))
     const body = Buffer.from(JSON.stringify(messageObject))
     channel.sendToQueue(TASK_QUEUE, body, {
     channel.sendToQueue(TASK_QUEUE, body, {

Some files were not shown because too many files changed in this diff