Browse Source

✨ feat: 对乐跑流程做出了若干优化和改进

Pchen. 1 month ago
parent
commit
bd5f529809
2 changed files with 188 additions and 21 deletions
  1. 184 18
      lib/Lepao/Worker.js
  2. 4 3
      plugin/Lepao/Path.js

+ 184 - 18
lib/Lepao/Worker.js

@@ -31,9 +31,9 @@ class Worker {
 
 
         this.baseUrl = 'https://lepao.ctbu.edu.cn/v3/api.php'
         this.baseUrl = 'https://lepao.ctbu.edu.cn/v3/api.php'
 
 
-        this.taskQueue = 'task_queue'
-        this.resultQueue = 'task_result_queue'
-        this.deadQueue = 'task_dead_queue'
+        this.taskQueue = 'runforge_task_queue'
+        this.resultQueue = 'runforge_task_result_queue'
+        this.deadQueue = 'runforge_task_dead_queue'
         this.noticeQueue = 'runforge_message_queue'
         this.noticeQueue = 'runforge_message_queue'
 
 
         this.channelName = 'lepao_worker'
         this.channelName = 'lepao_worker'
@@ -64,8 +64,91 @@ class Worker {
         return new Promise(r => setTimeout(r, ms))
         return new Promise(r => setTimeout(r, ms))
     }
     }
 
 
+    isRunSuccess(bindResponse) {
+        const payload = bindResponse?.data
+        if (!bindResponse || bindResponse.status !== 1 || !payload) {
+            return {
+                ok: false,
+                reason: bindResponse?.info || '系统繁忙,请联系客服或稍后再试'
+            }
+        }
+
+        const failedReason = payload.record_failed_reason || ''
+        if (failedReason === '' || failedReason === '自动确认有效') {
+            return { ok: true, payload }
+        }
+
+        return {
+            ok: false,
+            reason: failedReason,
+            payload
+        }
+    }
+
+    extractApiErrorMessage(name, result) {
+        if (!result) {
+            this.logger.error(`${name} 接口无响应数据: ${this.safeStringify(result)}`)
+            return `系统繁忙,请联系客服或稍后再试`
+        }
+
+        const candidates = [
+            result.info,
+            result.msg,
+            result.message,
+            result?.data?.info,
+            result?.data?.msg,
+            result?.data?.message,
+            result?.data?.record_failed_reason
+        ]
+
+        const reason = candidates.find(v => typeof v === 'string' && v.trim() !== '')
+        if (reason) {
+            return reason
+        }
+
+        if (result.code !== undefined || result.status !== undefined) {
+            this.logger.error(`${name} 接口返回异常: ${this.safeStringify(result)}`)
+            return `系统繁忙,请联系客服或稍后再试`
+        }
+        return `系统繁忙,请联系客服或稍后再试`
+    }
+
+    async markLoginExpired(account) {
+        if (!account) return
+        try {
+            const sql = 'UPDATE lepao_account SET state = 0 WHERE student_num = ?'
+            await db.query(sql, [account])
+            this.logger.warn(`${account} 登录状态已失效,已自动更新为未登录`)
+        } catch (error) {
+            this.logger.error(`更新账号登录状态失败:${error.stack || error}`)
+        }
+    }
+
+    async writeSuccessRedis(account) {
+        if (!account) return
+        try {
+            const now = new Date()
+            const tomorrow = new Date().setHours(24, 0, 0, 0)
+            const exp = Math.floor((tomorrow - now) / 1000)
+            await Redis.set(`lepaoSuccess:${account}`, account, { EX: exp })
+        } catch (error) {
+            this.logger.error(`写入乐跑成功缓存失败: ${error.stack || error}`)
+        }
+    }
+
+    async addLepaoRecord(uuid, account, result, pathId, pointData) {
+        if (!uuid || !account || !result || !pathId) return
+        try {
+            const time = Date.now()
+            const sql = 'INSERT INTO lepao_record (uuid, time, lepao_account, result, path_id, point_data) VALUES (?, ?, ?, ?, ?, ?)'
+            await db.query(sql, [uuid, time, account, result, pathId, JSON.stringify(pointData || [])])
+        } catch (error) {
+            this.logger.error(`写入乐跑记录失败: ${error.stack || error}`)
+        }
+    }
+
     lepaoTimestamp() {
     lepaoTimestamp() {
-        return (Date.now() / 1000).toFixed(3)
+        return Number((Date.now() / 1000).toFixed(3))
     }
     }
 
 
     axiosProxyConfig() {
     axiosProxyConfig() {
@@ -116,6 +199,9 @@ class Worker {
                 return await fn()
                 return await fn()
             } catch (err) {
             } catch (err) {
                 lastErr = err
                 lastErr = err
+                if (!this.isRetryableTaskError(err)) {
+                    throw err
+                }
                 this.logger.warn(`[RETRY] ${name} 第${i + 1}次失败`)
                 this.logger.warn(`[RETRY] ${name} 第${i + 1}次失败`)
 
 
                 await this.sleep(1000 * (i + 1)) // 指数退避
                 await this.sleep(1000 * (i + 1)) // 指数退避
@@ -142,8 +228,19 @@ class Worker {
         return ['PATH_SELECT_FAILED', 'CHECKPOINT_FETCH_FAILED', 'CHECKPOINT_INSUFFICIENT'].includes(err.code)
         return ['PATH_SELECT_FAILED', 'CHECKPOINT_FETCH_FAILED', 'CHECKPOINT_INSUFFICIENT'].includes(err.code)
     }
     }
 
 
+    safeStringify(obj) {
+        const seen = new WeakSet();
+        return JSON.stringify(obj, (key, value) => {
+            if (typeof value === 'object' && value !== null) {
+                if (seen.has(value)) return '[Circular]';
+                seen.add(value);
+            }
+            return value;
+        })
+    }
+
     log(traceId, type, msg, data) {
     log(traceId, type, msg, data) {
-        this.logger.info(`[${traceId}] [${type}] ${msg} ${data ? data : ''}`)
+        this.logger.info(`[${traceId}] [${type}] ${msg} ${data ? this.safeStringify(data) : ''}`)
     }
     }
 
 
     logErr(traceId, msg, err) {
     logErr(traceId, msg, err) {
@@ -182,8 +279,6 @@ class Worker {
                 name
                 name
             )
             )
 
 
-            this.log(traceId, 'RES', name, res)
-
             let result = res.data
             let result = res.data
 
 
             if (result?.data && result?.is_encrypt === 1) {
             if (result?.data && result?.is_encrypt === 1) {
@@ -192,6 +287,26 @@ class Worker {
 
 
             this.log(traceId, 'RES', name, result)
             this.log(traceId, 'RES', name, result)
 
 
+            // 除 bindData 外,其余调用若接口已明确返回失败,直接抛出该失败原因
+            // bindData 需要保留完整响应由 isRunSuccess 统一判定。
+            if (name !== 'bindData') {
+                const hasCode = result && Object.prototype.hasOwnProperty.call(result, 'code')
+                const hasStatus = result && Object.prototype.hasOwnProperty.call(result, 'status')
+                const failedByCode = hasCode && Number(result.code) !== 1 && Number(result.code) !== 200
+                const failedByStatus = hasStatus && Number(result.status) !== 1
+                if (failedByCode || failedByStatus) {
+                    const message = this.extractApiErrorMessage(name, result)
+                    const err = new Error(message)
+                    // 学习 Lepao.js:若明确提示重新登录,自动标记账号失效
+                    if (message.includes('重新登录')) {
+                        await this.markLoginExpired(raw?.student_num)
+                    }
+                    // 接口已返回业务错误,禁止重试
+                    err.retryable = false
+                    throw err
+                }
+            }
+
             return result
             return result
         }, name)
         }, name)
     }
     }
@@ -214,12 +329,35 @@ class Worker {
             let userData = null
             let userData = null
             let pathId = null
             let pathId = null
             let runZoneId = 0
             let runZoneId = 0
+            let bindRes = null
 
 
             try {
             try {
                 userData = await this.handlers['lepao.getUserData'](req, ctx)
                 userData = await this.handlers['lepao.getUserData'](req, ctx)
+
+                // 进入乐跑进程后写入进行中缓存
+                const progressKey = `lepaoProgress:${req.account}`
+                const inProgress = await Redis.get(progressKey)
+                if (inProgress) {
+                    throw new Error('该账号已进入乐跑任务队列,请等待乐跑完成后再进行乐跑操作')
+                }
+                await Redis.set(progressKey, req.account, { EX: 1800 })
+
+                // 晚上10点后提前
+                let run_end_time = Math.floor(Date.now() / 1000) - 300 // 提前5分钟
+                let hour = new Date().getHours()
+
+                if (hour < 7)
+                    throw new Error('当前不在有效乐跑时间范围内。RunForge支持乐跑时间段为7:00~24:00')
+
+                if (hour >= 22) {
+                    this.logger.info(`${req.account}当前时间为${hour}点,调整run_end_time提前5小时`)
+                    run_end_time -= 18000
+                }
+
                 req = {
                 req = {
                     ...req,
                     ...req,
                     ...userData,
                     ...userData,
+                    run_end_time,
                     student_id: req.account
                     student_id: req.account
                 }
                 }
 
 
@@ -263,23 +401,38 @@ class Worker {
                 }
                 }
 
 
                 if (!pointData) {
                 if (!pointData) {
-                    throw new Error('打卡点生成失败,乐跑任务终止')
+                    throw new Error('打卡点获取失败,乐跑任务终止')
                 }
                 }
 
 
                 // 5️⃣ 提交跑步数据
                 // 5️⃣ 提交跑步数据
-                const bindRes = await this.handlers['lepao.bindData']({
+                bindRes = await this.handlers['lepao.bindData']({
                     ...req,
                     ...req,
+                    random_id: pathId,
                     run_zone_id: runZoneId,
                     run_zone_id: runZoneId,
                     record_file: ossPath,
                     record_file: ossPath,
                     point_data: pointData
                     point_data: pointData
                 }, ctx)
                 }, ctx)
 
 
+                // 绑定接口有返回即入库(无论成功或失败)
+                if (bindRes && bindRes.data) {
+                    await this.addLepaoRecord(userData?.create_user, req.account, bindRes.data, pathId, pointData)
+                }
+
+                // 使用旧版 Lepao.js 的规则判断“是否真正乐跑成功”
+                const runResult = this.isRunSuccess(bindRes)
+                if (runResult.ok || runResult.reason === '当天关联成绩次数已达到上限') {
+                    await this.writeSuccessRedis(req.account)
+                }
+                if (!runResult.ok) {
+                    throw new Error(runResult.reason)
+                }
+
                 // 6️⃣ 发送通知
                 // 6️⃣ 发送通知
                 if (ctx.channel) {
                 if (ctx.channel) {
                     await this.enqueueTask(ctx.channel, 'lepao.sendNotice', {
                     await this.enqueueTask(ctx.channel, 'lepao.sendNotice', {
                         account: req.account,
                         account: req.account,
                         success: true,
                         success: true,
-                        data: bindRes?.data ?? bindRes,
+                        data: runResult.payload,
                         traceId
                         traceId
                     }, { id: `${traceId}:notice:success` })
                     }, { id: `${traceId}:notice:success` })
                 }
                 }
@@ -322,6 +475,8 @@ class Worker {
                     )
                     )
                 }
                 }
                 throw err
                 throw err
+            } finally {
+                await Redis.del(`lepaoProgress:${req.account}`)
             }
             }
         })
         })
 
 
@@ -407,7 +562,7 @@ class Worker {
             return { delivered: false, via: 'none' }
             return { delivered: false, via: 'none' }
         })
         })
 
 
-        /* ---------------- 扣减次数(仅成功时执行) ---------------- */
+        /* ---------------- 扣减次数 ---------------- */
         this.register('lepao.consumeCount', async (req, ctx) => {
         this.register('lepao.consumeCount', async (req, ctx) => {
             const account = req?.account
             const account = req?.account
             const uuid = req?.uuid
             const uuid = req?.uuid
@@ -507,7 +662,7 @@ class Worker {
             }
             }
 
 
             let userData = rows[0]
             let userData = rows[0]
-            
+
             if (!userData.create_user || !userData.uuid) {
             if (!userData.create_user || !userData.uuid) {
                 this.logger.warn(`${account}账号状态异常`)
                 this.logger.warn(`${account}账号状态异常`)
                 throw new Error('当前账号状态异常,请联系客服')
                 throw new Error('当前账号状态异常,请联系客服')
@@ -523,10 +678,10 @@ class Worker {
                 throw new Error('用户乐跑次数不足,请购买乐跑次数后重试!')
                 throw new Error('用户乐跑次数不足,请购买乐跑次数后重试!')
             }
             }
 
 
-            if(!userData.userAgent)
+            if (!userData.userAgent)
                 userData.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'
                 userData.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'
 
 
-            if(!userData.deviceModel)
+            if (!userData.deviceModel)
                 userData.deviceModel = '2211133C'
                 userData.deviceModel = '2211133C'
 
 
             return userData
             return userData
@@ -755,15 +910,26 @@ class Worker {
 
 
         /* ---------------- 提交跑步数据 ---------------- */
         /* ---------------- 提交跑步数据 ---------------- */
         this.register('lepao.bindData', async (req, ctx) => {
         this.register('lepao.bindData', async (req, ctx) => {
+            if (req?.random_id === undefined || req?.random_id === null || req?.random_id === '') {
+                throw new Error('提交跑步数据失败:缺少 random_id')
+            }
+
             const pathRow = await db.query(
             const pathRow = await db.query(
                 'SELECT * FROM path_data WHERE id=?',
                 'SELECT * FROM path_data WHERE id=?',
                 [req.random_id]
                 [req.random_id]
             )
             )
+            if (!pathRow || pathRow.length === 0) {
+                throw new Error(`提交跑步数据失败:未找到路径数据(random_id=${req.random_id})`)
+            }
             const pathData = pathRow[0]
             const pathData = pathRow[0]
 
 
-            const stepData = generateCadence(pathData.distance, pathData.time)
+            const distance = Number(Number(pathData.distance || 0).toFixed(2))
+            const stepData = generateCadence(distance, pathData.time)
             const stepInfo = JSON.stringify({ interval: 60, list: stepData.cadence_list })
             const stepInfo = JSON.stringify({ interval: 60, list: stepData.cadence_list })
 
 
+            let points = req.point_data.map(({ address, jingwei, ...rest }) => rest)
+            points = JSON.stringify(points)
+
             const data = {
             const data = {
                 uid: req.uid,
                 uid: req.uid,
                 token: req.token,
                 token: req.token,
@@ -777,12 +943,12 @@ class Worker {
                 version: 1,
                 version: 1,
                 nonce: String(Math.floor(Math.random() * 900000 + 100000)),
                 nonce: String(Math.floor(Math.random() * 900000 + 100000)),
                 ostype: 5,
                 ostype: 5,
-                game_id: req.run_zone_id || 0,
+                game_id: String(req.run_zone_id || 0),
                 start_time: req.run_end_time - Number(pathData.time),
                 start_time: req.run_end_time - Number(pathData.time),
                 end_time: req.run_end_time,
                 end_time: req.run_end_time,
-                distance: pathData.distance,
+                distance,
                 record_img: "",
                 record_img: "",
-                log_data: req.point_data,
+                log_data: points,
                 file_img: "",
                 file_img: "",
                 is_running_area_valid: 1,
                 is_running_area_valid: 1,
                 mobileDeviceId: 1,
                 mobileDeviceId: 1,

+ 4 - 3
plugin/Lepao/Path.js

@@ -39,8 +39,6 @@ function getPathData(pathlist, runEndTime, useTime) {
         return newItem
         return newItem
     })
     })
 
 
-    console.log(newPathlist)
-
     return newPathlist
     return newPathlist
 }
 }
 
 
@@ -58,7 +56,10 @@ function selectCheckpoints(path, checkpoints, runLogNum, pointUpdateDistance, lo
     // 打乱顺序
     // 打乱顺序
     for (let i = filteredCheckpoints.length - 1; i > 0; i--) {
     for (let i = filteredCheckpoints.length - 1; i > 0; i--) {
         const j = Math.floor(Math.random() * (i + 1))
         const j = Math.floor(Math.random() * (i + 1))
-        [filteredCheckpoints[i], filteredCheckpoints[j]] = [filteredCheckpoints[j], filteredCheckpoints[i]]
+        // 避免解构交换在部分运行环境下触发 TDZ 相关异常
+        const temp = filteredCheckpoints[i]
+        filteredCheckpoints[i] = filteredCheckpoints[j]
+        filteredCheckpoints[j] = temp
     }
     }
 
 
     const usedCheckpoints = new Set()
     const usedCheckpoints = new Set()