|
|
@@ -31,9 +31,9 @@ class Worker {
|
|
|
|
|
|
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.channelName = 'lepao_worker'
|
|
|
@@ -64,8 +64,91 @@ class Worker {
|
|
|
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() {
|
|
|
- return (Date.now() / 1000).toFixed(3)
|
|
|
+ return Number((Date.now() / 1000).toFixed(3))
|
|
|
}
|
|
|
|
|
|
axiosProxyConfig() {
|
|
|
@@ -116,6 +199,9 @@ class Worker {
|
|
|
return await fn()
|
|
|
} catch (err) {
|
|
|
lastErr = err
|
|
|
+ if (!this.isRetryableTaskError(err)) {
|
|
|
+ throw err
|
|
|
+ }
|
|
|
this.logger.warn(`[RETRY] ${name} 第${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)
|
|
|
}
|
|
|
|
|
|
+ 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) {
|
|
|
- this.logger.info(`[${traceId}] [${type}] ${msg} ${data ? data : ''}`)
|
|
|
+ this.logger.info(`[${traceId}] [${type}] ${msg} ${data ? this.safeStringify(data) : ''}`)
|
|
|
}
|
|
|
|
|
|
logErr(traceId, msg, err) {
|
|
|
@@ -182,8 +279,6 @@ class Worker {
|
|
|
name
|
|
|
)
|
|
|
|
|
|
- this.log(traceId, 'RES', name, res)
|
|
|
-
|
|
|
let result = res.data
|
|
|
|
|
|
if (result?.data && result?.is_encrypt === 1) {
|
|
|
@@ -192,6 +287,26 @@ class Worker {
|
|
|
|
|
|
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
|
|
|
}, name)
|
|
|
}
|
|
|
@@ -214,12 +329,35 @@ class Worker {
|
|
|
let userData = null
|
|
|
let pathId = null
|
|
|
let runZoneId = 0
|
|
|
+ let bindRes = null
|
|
|
|
|
|
try {
|
|
|
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,
|
|
|
...userData,
|
|
|
+ run_end_time,
|
|
|
student_id: req.account
|
|
|
}
|
|
|
|
|
|
@@ -263,23 +401,38 @@ class Worker {
|
|
|
}
|
|
|
|
|
|
if (!pointData) {
|
|
|
- throw new Error('打卡点生成失败,乐跑任务终止')
|
|
|
+ throw new Error('打卡点获取失败,乐跑任务终止')
|
|
|
}
|
|
|
|
|
|
// 5️⃣ 提交跑步数据
|
|
|
- const bindRes = await this.handlers['lepao.bindData']({
|
|
|
+ bindRes = await this.handlers['lepao.bindData']({
|
|
|
...req,
|
|
|
+ random_id: pathId,
|
|
|
run_zone_id: runZoneId,
|
|
|
record_file: ossPath,
|
|
|
point_data: pointData
|
|
|
}, 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️⃣ 发送通知
|
|
|
if (ctx.channel) {
|
|
|
await this.enqueueTask(ctx.channel, 'lepao.sendNotice', {
|
|
|
account: req.account,
|
|
|
success: true,
|
|
|
- data: bindRes?.data ?? bindRes,
|
|
|
+ data: runResult.payload,
|
|
|
traceId
|
|
|
}, { id: `${traceId}:notice:success` })
|
|
|
}
|
|
|
@@ -322,6 +475,8 @@ class Worker {
|
|
|
)
|
|
|
}
|
|
|
throw err
|
|
|
+ } finally {
|
|
|
+ await Redis.del(`lepaoProgress:${req.account}`)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -407,7 +562,7 @@ class Worker {
|
|
|
return { delivered: false, via: 'none' }
|
|
|
})
|
|
|
|
|
|
- /* ---------------- 扣减次数(仅成功时执行) ---------------- */
|
|
|
+ /* ---------------- 扣减次数 ---------------- */
|
|
|
this.register('lepao.consumeCount', async (req, ctx) => {
|
|
|
const account = req?.account
|
|
|
const uuid = req?.uuid
|
|
|
@@ -507,7 +662,7 @@ class Worker {
|
|
|
}
|
|
|
|
|
|
let userData = rows[0]
|
|
|
-
|
|
|
+
|
|
|
if (!userData.create_user || !userData.uuid) {
|
|
|
this.logger.warn(`${account}账号状态异常`)
|
|
|
throw new Error('当前账号状态异常,请联系客服')
|
|
|
@@ -523,10 +678,10 @@ class Worker {
|
|
|
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'
|
|
|
|
|
|
- if(!userData.deviceModel)
|
|
|
+ if (!userData.deviceModel)
|
|
|
userData.deviceModel = '2211133C'
|
|
|
|
|
|
return userData
|
|
|
@@ -755,15 +910,26 @@ class Worker {
|
|
|
|
|
|
/* ---------------- 提交跑步数据 ---------------- */
|
|
|
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(
|
|
|
'SELECT * FROM path_data WHERE id=?',
|
|
|
[req.random_id]
|
|
|
)
|
|
|
+ if (!pathRow || pathRow.length === 0) {
|
|
|
+ throw new Error(`提交跑步数据失败:未找到路径数据(random_id=${req.random_id})`)
|
|
|
+ }
|
|
|
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 })
|
|
|
|
|
|
+ let points = req.point_data.map(({ address, jingwei, ...rest }) => rest)
|
|
|
+ points = JSON.stringify(points)
|
|
|
+
|
|
|
const data = {
|
|
|
uid: req.uid,
|
|
|
token: req.token,
|
|
|
@@ -777,12 +943,12 @@ class Worker {
|
|
|
version: 1,
|
|
|
nonce: String(Math.floor(Math.random() * 900000 + 100000)),
|
|
|
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),
|
|
|
end_time: req.run_end_time,
|
|
|
- distance: pathData.distance,
|
|
|
+ distance,
|
|
|
record_img: "",
|
|
|
- log_data: req.point_data,
|
|
|
+ log_data: points,
|
|
|
file_img: "",
|
|
|
is_running_area_valid: 1,
|
|
|
mobileDeviceId: 1,
|