Browse Source

✨ feat: 完成重工乐跑主要流程

Pchen. 3 months ago
parent
commit
a1d7d041b3

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

@@ -1,8 +1,8 @@
-const API = require("../../../lib/API.js");
-const axios = require("axios");
-const db = require("../../../plugin/DataBase/db.js");
-const { BaseStdResponse } = require("../../../BaseStdResponse.js");
-const AccessControl = require("../../../lib/AccessControl.js");
+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 { lepaoAuth, lepaoUserInfo } =  require('../../../lib/Lepao/lepaoAPI')
 
 
 class AddAccount extends API {
 class AddAccount extends API {
     constructor() {
     constructor() {
@@ -15,44 +15,7 @@ class AddAccount extends API {
         this.banEmailList = ['icloud.com']
         this.banEmailList = ['icloud.com']
     }
     }
 
 
-    async lepaoAuth(student_num, password) {
-        const endpoint = "http://222.178.152.79:100/api_v1/login"
-        const params = new URLSearchParams()
-        params.append('password', password)
-        params.append('account', student_num)
-
-        const res = await axios.post(endpoint, params, {
-            proxy: false,
-            headers: {
-                "User-Agent": 'okhttp/4.9.0'
-            }
-        })
-        const data = res.data
-        if (!data || data.status !== 1) {
-            throw new Error(data?.message ?? "无法验证乐跑账号,请联系客服或稍后再试")
-        }
-        return true
-    }
-
-    async lepaoUserInfo(student_num) {
-        const params = new URLSearchParams()
-        params.append('account', student_num)
-
-        const endpoint = "http://222.178.152.79:100/api_v1/getUserInfo"
-        const res = await axios.post(endpoint, params, {
-            proxy: false,
-            headers: {
-                "User-Agent": 'okhttp/4.9.0'
-            }
-        })
-        const data = res.data
-        if (!data || data.status !== 1 || !data.data || !data.data.id || !data.data.nickName || !data.data.department || !data.data.frequency) {
-            this.logger.error(`获取乐跑用户信息失败!${data?.message ?? "未知错误"}`)
-            throw new Error(data?.message ?? "无法获取用户信息,请联系客服或稍后再试")
-        }
-        return data.data
-    }
-
+    
     async onRequest(req, res) {
     async onRequest(req, res) {
         let { uuid, session, student_num, email, id, area, auto_time, auto_run, target_count, password, notes } = req.body
         let { uuid, session, student_num, email, id, area, auto_time, auto_run, target_count, password, notes } = req.body
 
 
@@ -108,7 +71,7 @@ class AddAccount extends API {
             // 进行密码校验
             // 进行密码校验
             try {
             try {
                 password = atob(password)
                 password = atob(password)
-                await this.lepaoAuth(student_num, password)
+                await lepaoAuth(student_num, password)
             } catch (err) {
             } catch (err) {
                 this.logger.info(`乐跑账号验证失败!${err.message}`)
                 this.logger.info(`乐跑账号验证失败!${err.message}`)
                 return res.json({ ...BaseStdResponse.ERR, msg: err.message ?? '无法验证乐跑账号,请联系客服或稍后再试' })
                 return res.json({ ...BaseStdResponse.ERR, msg: err.message ?? '无法验证乐跑账号,请联系客服或稍后再试' })
@@ -127,7 +90,7 @@ class AddAccount extends API {
         if (!id) {
         if (!id) {
             // 获取用户信息
             // 获取用户信息
             try {
             try {
-                userInfo = await this.lepaoUserInfo(student_num)
+                userInfo = await lepaoUserInfo(student_num)
 
 
                 if (auto_run && userInfo.frequency >= target_count && target_count !== 0)
                 if (auto_run && userInfo.frequency >= target_count && target_count !== 0)
                     return res.json({ ...BaseStdResponse.ERR, msg: '该账号累计跑步次数已达到目标次数,请尝试修改目标次数' })
                     return res.json({ ...BaseStdResponse.ERR, msg: '该账号累计跑步次数已达到目标次数,请尝试修改目标次数' })

+ 7 - 5
apis/Lepao/Record/Admin/GetLepaoRecords.js

@@ -53,13 +53,15 @@ class AdminGetLepaoRecords extends API {
         let sql = `
         let sql = `
                 SELECT 
                 SELECT 
                     r.id,
                     r.id,
+                    r.uuid,
                     r.time,
                     r.time,
-                    r.result,
+                    r.area,
                     r.lepao_account,
                     r.lepao_account,
-                    a.name,
-                    a.user_avatar,
-                    u.username,
-                    u.avatar
+                    r.startTime,
+                    r.endTime,
+                    r.distance,
+                    r.state,
+                    a.name
                 FROM 
                 FROM 
                     lepao_record r
                     lepao_record r
                 LEFT JOIN 
                 LEFT JOIN 

+ 10 - 10
apis/Lepao/Record/Admin/GetRecordDetail.js

@@ -40,22 +40,22 @@ class AdminGetRecordDetail extends API {
         let sql = `
         let sql = `
                 SELECT 
                 SELECT 
                     r.time,
                     r.time,
-                    r.result,
-                    r.lepao_account,
-                    r.point_data,
                     r.path_id,
                     r.path_id,
-                    a.name,
-                    p.data
+                    r.task_id,
+                    r.area,
+                    r.path_data,
+                    r.startTime,
+                    r.endTime,
+                    r.distance,
+                    r.state,
+                    r.lepao_account,
+                    a.name
                 FROM 
                 FROM 
                     lepao_record r
                     lepao_record r
                 LEFT JOIN 
                 LEFT JOIN 
                     lepao_account a
                     lepao_account a
                 ON 
                 ON 
                     r.lepao_account = a.student_num
                     r.lepao_account = a.student_num
-                LEFT JOIN
-                    path_data p
-                ON 
-                    r.path_id = p.id
                 WHERE 
                 WHERE 
                     r.id = ?
                     r.id = ?
             `
             `
@@ -74,7 +74,7 @@ class AdminGetRecordDetail extends API {
             }) 
             }) 
 
 
         let data = rows[0]
         let data = rows[0]
-        data.data = data.data.map(point => [point.o, point.a])
+        data.distance = Number(data.distance).toFixed(2)
 
 
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,

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

@@ -48,10 +48,13 @@ class GetLepaoRecords extends API {
                     r.id,
                     r.id,
                     r.uuid,
                     r.uuid,
                     r.time,
                     r.time,
-                    r.result,
+                    r.area,
                     r.lepao_account,
                     r.lepao_account,
-                    a.name,
-                    a.user_avatar
+                    r.startTime,
+                    r.endTime,
+                    r.distance,
+                    r.state,
+                    a.name
                 FROM 
                 FROM 
                     lepao_record r
                     lepao_record r
                 LEFT JOIN 
                 LEFT JOIN 
@@ -121,4 +124,4 @@ class GetLepaoRecords extends API {
     }
     }
 }
 }
 
 
-module.exports.GetLepaoRecords = GetLepaoRecords;
+module.exports.GetLepaoRecords = GetLepaoRecords

+ 8 - 9
apis/Lepao/Record/GetRecordDetail.js

@@ -32,21 +32,20 @@ class GetRecordDetail extends API {
         let sql = `
         let sql = `
                 SELECT 
                 SELECT 
                     r.time,
                     r.time,
-                    r.result,
+                    r.path_data,
+                    r.area,
+                    r.startTime,
+                    r.endTime,
+                    r.distance,
+                    r.state,
                     r.lepao_account,
                     r.lepao_account,
-                    r.point_data,
-                    a.name,
-                    p.data
+                    a.name
                 FROM 
                 FROM 
                     lepao_record r
                     lepao_record r
                 LEFT JOIN 
                 LEFT JOIN 
                     lepao_account a
                     lepao_account a
                 ON 
                 ON 
                     r.lepao_account = a.student_num
                     r.lepao_account = a.student_num
-                LEFT JOIN
-                    path_data p
-                ON 
-                    r.path_id = p.id
                 WHERE 
                 WHERE 
                     (r.uuid = ? OR (a.create_user IS NOT NULL AND a.create_user = ?)) AND r.id = ?
                     (r.uuid = ? OR (a.create_user IS NOT NULL AND a.create_user = ?)) AND r.id = ?
             `
             `
@@ -65,7 +64,7 @@ class GetRecordDetail extends API {
             }) 
             }) 
 
 
         let data = rows[0]
         let data = rows[0]
-        data.data = data.data.map(point => [point.o, point.a])
+        data.distance = Number(data.distance).toFixed(2)
 
 
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,

+ 16 - 16
apis/Lepao/SingleRun.js

@@ -3,7 +3,7 @@ const Redis = require('../../plugin/DataBase/Redis')
 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 lepao = require("../../lib/Lepao/Lepao.js").lepao
+const lepao = require("../../lib/Lepao/cg_lepao").Lepao
 
 
 // 单次乐跑
 // 单次乐跑
 class SingleRun extends API {
 class SingleRun extends API {
@@ -28,27 +28,27 @@ class SingleRun extends API {
             })
             })
 
 
         let hour = new Date().getHours()
         let hour = new Date().getHours()
-        if (hour < 7)
-            return res.json({
-                ...BaseStdResponse.ERR,
-                msg: '当前不在有效乐跑时间范围内。请在7:00~24:00发起乐跑'
-            })
+        // if (hour < 7)
+        //     return res.json({
+        //         ...BaseStdResponse.ERR,
+        //         msg: '当前不在有效乐跑时间范围内。请在7:00~24:00发起乐跑'
+        //     })
 
 
         try {
         try {
             // 检查redis是否存在当天乐跑成功记录
             // 检查redis是否存在当天乐跑成功记录
-            const isSuccess = await Redis.get(`lepaoSuccess:${student_num}`)
+            const isSuccess = await Redis.get(`cgLepaoSuccess:${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}`)
-            if (isProgress)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '该账号已进入乐跑任务队列,请等待乐跑完成后再进行乐跑操作'
-                })
+            // const isProgress = await Redis.get(`cgLepaoProgress:${student_num}`)
+            // if (isProgress)
+            //     return res.json({
+            //         ...BaseStdResponse.ERR,
+            //         msg: '该账号已进入乐跑任务队列,请等待乐跑完成后再进行乐跑操作'
+            //     })
 
 
             let selectSql = 'SELECT create_user FROM lepao_account WHERE student_num = ?'
             let selectSql = 'SELECT create_user FROM lepao_account WHERE student_num = ?'
             let selectRows = await db.query(selectSql, [student_num])
             let selectRows = await db.query(selectSql, [student_num])
@@ -67,7 +67,7 @@ class SingleRun extends API {
                     })
                     })
             }
             }
 
 
-            let sql = 'SELECT token, uid, school_id, state FROM lepao_account WHERE student_num = ?'
+            let sql = 'SELECT 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({
@@ -78,7 +78,7 @@ class SingleRun extends API {
             if (rows[0].state !== 1)
             if (rows[0].state !== 1)
                 return res.json({
                 return res.json({
                     ...BaseStdResponse.ERR,
                     ...BaseStdResponse.ERR,
-                    msg: '账号状态为未登录,请使用登录器更新账号信息后乐跑'
+                    msg: '账号状态异常,无法进行乐跑。请联系客服处理'
                 })
                 })
 
 
             res.json({
             res.json({
@@ -86,7 +86,7 @@ class SingleRun extends API {
             })
             })
 
 
             try {
             try {
-                await lepao.beginLepao(selectRows[0].create_user, student_num, rows[0].token, rows[0].uid, rows[0].school_id, rows[0].state)
+                await lepao.beginLepao(selectRows[0].create_user, student_num)
             } catch (err) {
             } catch (err) {
                 this.logger.error(`后台乐跑任务异常:${err.stack}`)
                 this.logger.error(`后台乐跑任务异常:${err.stack}`)
             }
             }

+ 0 - 115
apis/Upload/UploadFaceVideo.js

@@ -1,115 +0,0 @@
-const API = require("../../lib/API")
-const { v4: uuidv4 } = require('uuid')
-const Redis = require('../../plugin/DataBase/Redis')
-const db = require("../../plugin/DataBase/db.js")
-const { BaseStdResponse } = require("../../BaseStdResponse")
-const multer = require('multer')
-const path = require('path')
-const fs = require('fs')
-const { url } = require("../../config.json")
-
-// 配置 Multer 存储选项
-const storage = multer.diskStorage({
-    destination: (req, file, cb) => {
-        const { student_num } = req.body
-        if (!student_num) {
-            return cb(new Error('缺少参数'))
-        }
-        const destPath = path.join('uploads', 'faces', student_num)
-        fs.mkdirSync(destPath, { recursive: true }) // 确保目录存在
-        cb(null, destPath)
-    },
-    filename: (req, file, cb) => {
-        const randomName = uuidv4()
-        cb(null, `${randomName}.webm`)
-    }
-})
-
-// 限制文件类型为 webm
-const fileFilter = (req, file, cb) => {
-    const extname = path.extname(file.originalname).toLowerCase() === ".webm"
-    const mimetype = file.mimetype === "video/webm"
-    if (extname && mimetype) {
-        return cb(null, true)
-    } else {
-        cb(new Error('只允许上传 webm 格式的视频'))
-    }
-}
-
-// 初始化 Multer
-const upload = multer({
-    storage: storage,
-    fileFilter: fileFilter,
-    limits: { fileSize: 200 * 1024 * 1024 } // 最大 200MB
-}).single('upload')
-
-class UploadFaceVideo extends API {
-    constructor() {
-        super()
-
-        this.noEncrypt()
-        this.setMethod("POST")
-        this.setPath("/UploadFaceVideo")
-    }
-
-    async onRequest(req, res) {
-        upload(req, res, async (err) => {
-            if (err) {
-                this.logger.error(`视频上传失败!${err.stack || ''}`)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: err.message ?? '视频上传失败!'
-                })
-            }
-
-            const { student_num, key } = req.body
-
-            if ([student_num, key].some(value => value === '' || value === null || value === undefined)) {
-                return res.json({
-                    ...BaseStdResponse.MISSING_PARAMETER
-                })
-            }
-
-            if (!req.file) {
-                return res.json({
-                    ...BaseStdResponse.MISSING_PARAMETER,
-                    msg: '请上传 webm 视频文件'
-                })
-            }
-
-            try {
-                const code = await Redis.get(`faceReco:${student_num}`)
-                if (!code || code !== key)
-                    return res.json({
-                        ...BaseStdResponse.ERR,
-                        msg: '令牌已过期!请刷新页面重试'
-                    })
-
-                await Redis.del(`faceReco:${student_num}`)
-            } catch (err) {
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '令牌验证失败!请刷新页面重试'
-                })
-            }
-
-            const videoPath = `/uploads/faces/${student_num}/${req.file.filename}`
-            const videoUrl = `${url}/uploads/faces/${student_num}/${req.file.filename}`
-            const time = new Date().getTime()
-
-            let sql = 'UPDATE lepao_face SET video_path = ?, create_time = ?, state = ?, url = ? WHERE student_num = ?'
-            let rows = await db.query(sql, [videoPath, time, 1, videoUrl, student_num])
-            if (!rows || rows.affectedRows !== 1)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '上传失败,请稍后再试'
-                })
-
-            res.json({
-                ...BaseStdResponse.OK
-            })
-        })
-    }
-}
-
-module.exports.UploadFaceVideo = UploadFaceVideo

+ 4 - 3
index.js

@@ -1,5 +1,6 @@
-const SERVER = require('./lib/Server');
+const SERVER = require('./lib/Server')
 
 
-const server = new SERVER();
 
 
-server.start();
+const server = new SERVER()
+
+server.start()

+ 294 - 4
lib/Lepao/cg_lepao.js

@@ -2,10 +2,11 @@ const axios = require('axios')
 const Redis = require('../../plugin/DataBase/Redis')
 const Redis = require('../../plugin/DataBase/Redis')
 const db = require('../../plugin/DataBase/db')
 const db = require('../../plugin/DataBase/db')
 const mq = require('../../plugin/mq')
 const mq = require('../../plugin/mq')
-const path = require('path')   
 const Logger = require('../Logger')
 const Logger = require('../Logger')
 const path = require('path')
 const path = require('path')
 const EmailTemplate = require('../../plugin/Email/emailTemplate')
 const EmailTemplate = require('../../plugin/Email/emailTemplate')
+const { offsetGeoPoints, geoPointsToString, addSeconds } = require('./generatePath')
+const { sendStartLepao, sendStopLepao, lepaoUserInfo } = require('./lepaoAPI')
 
 
 class cgLepao {
 class cgLepao {
     constructor() {
     constructor() {
@@ -13,8 +14,297 @@ class cgLepao {
         this.logger = new Logger(path.join(__dirname, '../logs/cgLepao.log'), 'INFO')
         this.logger = new Logger(path.join(__dirname, '../logs/cgLepao.log'), 'INFO')
     }
     }
 
 
-    
+    // 获取跑步线路
+    async getPath(account) {
+        this.logger.info(`${account}开始获取路径`)
+        const accountSql = 'SELECT area, sex FROM lepao_account WHERE student_num = ?'
+        const rows = await db.query(accountSql, [account])
+        if (!rows || rows.length === 0) {
+            this.logger.error(`${account}无法获取账号数据`)
+            throw new Error('无法获取账号数据')
+        }
+
+        const { area, sex } = rows[0]
+
+        let max = 4.00
+        let min = 2.00
+        if (sex === 1) {
+            max = 2.10
+            min = 1.60
+        }
+
+        this.logger.info(`${account}路径参数: area=${area ?? '随机'}, max_distance=${max}, min_distance=${min}`)
+
+        let pathSql = 'SELECT id, time FROM path_data WHERE state = 1 AND distance < ? AND distance > ? '
+        const pathParams = [max, min]
+
+        if (area) {
+            pathSql += ' AND run_zone_name = ?'
+            pathParams.push(area)
+        }
+
+        pathSql += ' ORDER BY count ASC LIMIT 1'
+
+        const paths = await db.query(pathSql, pathParams)
+        if (!paths || paths.length === 0) {
+            this.logger.error(`${account}未找到符合条件的路线`)
+            throw new Error('未找到符合条件的路线,请改变路径选择条件')
+        }
+
+        const randomPath = paths[0]
+
+        const updateSql = 'UPDATE path_data SET count = count + 1 WHERE id = ?'
+        await db.query(updateSql, [randomPath.id])
+        this.logger.info(`${account}路径选中id=${randomPath.id},计数加1成功`)
+        return { path_id: randomPath.id, path_time: randomPath.time, area }
+    }
+
+    async writeRedis(account) {
+        try {
+            // 计算至明日0时过期的秒数
+            const now = new Date()
+            const tomorrow = new Date().setHours(24, 0, 0, 0)
+            const exp = Math.floor((tomorrow - now) / 1000)
+
+            await Redis.set(`cgLepaoSuccess:${account}`, account, {
+                EX: exp
+            })
+        } catch (error) {
+            this.logger.error(`redis缓存乐跑记录失败: ${error.stack || '未知错误'}`)
+        }
+    }
+
+    async lepaoFail(uuid) {
+        try {
+            this.logger.info(`返还用户 ${uuid} 乐跑次数`)
+            let sql = 'UPDATE users SET lepao_count = lepao_count + 1 WHERE uuid  = ?'
+            await db.query(sql, [uuid])
+
+            this.logger.info(`返还用户 ${uuid} 乐跑次数成功`)
+        } catch (error) {
+            this.logger.error(`返还用户 ${uuid} 乐跑次数时出错: ${error.stack || error.message}`)
+        }
+    }
+
+    async sendSuccessEmail(account, lepaoData, total_num) {
+        try {
+            this.logger.info(`${account}发送乐跑成功邮件`)
+            const emailSql = 'SELECT name, email, target_count FROM lepao_account WHERE student_num = ?'
+            const rows = await db.query(emailSql, [account])
+            if (!rows || rows.length === 0) {
+                this.logger.error(`${account}查找用户邮箱失败`)
+                throw new Error('查找用户邮箱失败')
+            }
+
+            const data = {
+                ...lepaoData,
+                term_num: rows[0].target_count,
+                total_num,
+                name: rows[0].name,
+                account
+            }
+
+            await EmailTemplate.lepaoSuccess(rows[0].email, data)
+            this.logger.info(`${account}乐跑成功邮件发送完成`)
+
+            if (rows[0].target_count !== 0 && total_num >= rows[0].target_count) {
+                this.logger.info(`${account}乐跑目标完成,发送乐跑结束邮件并关闭自动乐跑`)
+                await EmailTemplate.lepaoOver(rows[0].email, data)
+                let overSql = 'UPDATE lepao_account SET auto_run = 0 WHERE student_num = ?'
+                let overRows = await db.query(overSql, [account])
+                if (!overRows || overRows.affectedRows !== 1)
+                    this.logger.warn(`${account}乐跑结束后关闭自动乐跑失败`)
+                else
+                    this.logger.info(`${account}自动乐跑关闭成功`)
+            }
+        } catch (error) {
+            this.logger.error(`发送成功邮件失败: ${error.stack || error.message}`)
+        }
+    }
+
+    async sendFailEmail(account, reason) {
+        try {
+            this.logger.info(`${account}发送乐跑失败邮件,原因: ${reason}`)
+            const emailSql = 'SELECT name, email FROM lepao_account WHERE student_num = ?'
+            const rows = await db.query(emailSql, [account])
+            if (!rows || rows.length == 0) {
+                this.logger.error(`${account}查找用户邮箱失败`)
+                throw new Error('查找用户邮箱失败')
+            }
+
+            const data = {
+                name: rows[0].name,
+                account,
+                reason: reason || '系统繁忙,请联系客服或稍后再试'
+            }
+
+            await EmailTemplate.lepaoFail(rows[0].email, data)
+            this.logger.info(`${account}乐跑失败邮件发送完成`)
+        } catch (error) {
+            this.logger.error(`发送失败邮件失败: ${error.stack || error.message}`)
+        }
+    }
+
+    async beginLepao(uuid, account) {
+        try {
+            this.logger.info(`${account}开始执行乐跑流程`)
+
+            // 检查redis是否存在当天乐跑成功记录
+            const isSuccess = await Redis.get(`cgLepaoSuccess:${account}`)
+            if (isSuccess)
+                throw new Error('该账号当天已存在成功乐跑记录')
+            const isProgress = await Redis.get(`cgLepaoProgress:${account}`)
+            // if (isProgress)
+            //     throw new Error('该账号已进入乐跑任务队列,请等待乐跑完成后再进行乐跑操作')
+
+            //已开始乐跑,存入Redis
+            await Redis.set(`cgLepaoProgress:${account}`, account)
+
+            // 扣除乐跑次数
+            this.logger.info(`${account}开始扣减乐跑次数`)
+            const useLepaoCountSql = 'UPDATE users SET lepao_count = lepao_count - 1 WHERE uuid  = ?'
+            await db.query(useLepaoCountSql, [uuid])
+            this.logger.info(`${account}扣减乐跑次数完成`)
+
+            const userPermissionSql = 'SELECT lepao_count FROM users WHERE uuid = ?'
+            const userPermissionData = await db.query(userPermissionSql, [uuid])
+            if (!userPermissionData || userPermissionData.length !== 1) {
+                this.logger.error(`${account}无法获取用户信息`)
+                throw new Error('无法获取用户信息,请重试或联系RunForge客服')
+            }
+
+            if (userPermissionData[0].lepao_count < 1) {
+                this.logger.warn(`${account}乐跑次数不足`)
+                throw new Error('用户乐跑次数不足,请购买乐跑套餐!')
+            }
+
+            // 获取路径 ID
+            const { path_id, path_time, area } = await this.getPath(account)
+
+            // 提交乐跑请求
+            const { task_id, startTime } = await sendStartLepao(account)
+
+            let taskSql = 'INSERT INTO lepao_record (uuid, lepao_account, task_id, path_id, startTime, time, area) VALUES (?, ?, ?, ?, ?, ?, ?)'
+            let taskRows = await db.query(taskSql, [uuid, account, task_id, path_id, startTime, path_time, area])
+            if (!taskRows || taskRows.affectedRows === 0) {
+                this.logger.error(`${account}乐跑任务入库失败`)
+                throw new Error('乐跑任务发起失败,请联系客服或稍后再试')
+            }
+
+            let task = {
+                uuid, account, task_id, path_id, path_time, startTime
+            }
+            let delayMs = path_time * 1000
+
+            const ch = await mq.getChannel('cg_run_task')
+
+            ch.sendToQueue(
+                'cg_run_delay_queue',
+                Buffer.from(JSON.stringify(task)),
+                {
+                    expiration: delayMs.toString(),
+                    persistent: true
+                }
+            )
+
+            this.logger.info(`已投递跑步任务 user=${account} 延迟=${delayMs}ms`)
+        } catch (error) {
+            await this.lepaoFail(uuid)
+            let failSql = 'UPDATE lepao_records SET state = 2 WHERE lepao_account = ? AND state = 0 ORDER BY id DESC LIMIT 1'
+            await db.query(failSql, [account])
+            await this.sendFailEmail(account, error.message)
+            await Redis.del(`cgLepaoProgress:${account}`)
+            this.logger.error(`乐跑流程失败: ${error.stack || '未知错误'}`)
+        }
+    }
+
+    async lepaoFinish() {
+        const ch = await mq.getChannel('cg_run_finish')
+        ch.prefetch(1) // 控制并发
+
+        await ch.consume('cg_run_finish_queue', async (msg) => {
+            if (!msg) return
+            const task = JSON.parse(msg.content.toString())
+            this.logger.info(`开始处理乐跑结束任务 user=${task.account} task_id=${task.task_id}`)
+
+            try {
+                let pathSql = 'SELECT * FROM path_data WHERE id = ?'
+                const pathRows = await db.query(pathSql, [task.path_id])
+                if (!pathRows || pathRows.length === 0) {
+                    this.logger.error(`${task.account}乐跑路径数据获取失败 id=${task.path_id}`)
+                    ch.ack(msg)
+                    throw new Error('乐跑路径数据获取失败,请联系客服或稍后再试')
+                }
+                let pathData = pathRows[0]
+                let pathLine = offsetGeoPoints(pathData.data)
+                let pathLineStr = geoPointsToString(pathLine)
+
+                let lepaoData = {
+                    account: task.account,
+                    calorie: Number(pathData.calorie).toFixed(2),
+                    distance: Number(pathData.distance).toFixed(6),
+                    distribution: pathData.speed,
+                    duration: pathData.time,
+                    endTime: addSeconds(task.startTime, pathData.time),
+                    id: task.task_id,
+                    maxDistribution: '0.00',
+                    pathLine: pathLineStr,
+                    startTime: task.startTime,
+                }
+
+                this.logger.info(`${task.account}乐跑数据构造结束:`)
+                console.log(lepaoData)
+
+                let lepaoResult = await sendStopLepao(lepaoData)
+
+                if (lepaoResult.status !== 1) {
+                    this.logger.error(`${task.account}乐跑失败!`)
+                    let updateSql = 'UPDATE lepao_record SET startTime = ?, endTime = ?, frequency = ?, distance = ?, path_data = ?, state = 2 WHERE task_id = ?'
+                    await db.query(updateSql, [
+                        lepaoResult.startTime,
+                        lepaoResult.endTime,
+                        lepaoResult.frequency,
+                        pathData.distance,
+                        pathLine,
+                        task.task_id
+                    ])
+
+                    throw new Error('乐跑失败,请联系客服或稍后再试')
+                }
+
+                let updateSql = 'UPDATE lepao_record SET startTime = ?, endTime = ?, frequency = ?, distance = ?, path_data = ?, state = 1 WHERE task_id = ?'
+                await db.query(updateSql, [
+                    lepaoResult.startTime,
+                    lepaoResult.endTime,
+                    lepaoResult.frequency,
+                    pathData.distance,
+                    pathLine,
+                    task.task_id
+                ])
+
+                this.logger.info(`${task.account}乐跑成功,获取账号信息`)
+
+                let userInfo = await lepaoUserInfo(task.account)
+                const { frequency } = userInfo
+
+                lepaoResult.time = pathData.time
+                lepaoResult.distance = Number(pathData.distance).toFixed(2)
+                lepaoResult.area = pathData.run_zone_name
+
+                this.sendSuccessEmail(task.account, lepaoResult, frequency)
+            } catch (error) {
+                await this.lepaoFail(task.uuid)
+                await this.sendFailEmail(task.account, error.message)
+                this.logger.error(`乐跑结束处理失败: ${error.stack || '未知错误'}`)
+            } finally {
+                await Redis.del(`cgLepaoProgress:${task.account}`)
+                ch.ack(msg)
+            }
+        })
+
+        this.logger.info('乐跑任务结束消费者启动完成')
+    }
 }
 }
 
 
-const cgLepao = new cgLepao()
-module.exports.cgLepao = cgLepao
+const Lepao = new cgLepao()
+module.exports.Lepao = Lepao

+ 79 - 0
lib/Lepao/generatePath.js

@@ -0,0 +1,79 @@
+/**
+ * 给经纬度数组增加 0~5cm 的随机偏移
+ * @param {Array<Array<number>>} points [[lng, lat], ...]
+ * @param {number} maxOffsetCm 最大偏移(厘米),默认 5cm
+ * @returns {Array<Array<number>>}
+ */
+export function offsetGeoPoints(points, maxOffsetCm = 5) {
+    return points.map(([lng, lat]) => {
+        // cm -> meter
+        const maxOffsetMeter = maxOffsetCm / 100
+
+        // 随机偏移(-max ~ +max)
+        const offsetMeterLng = (Math.random() * 2 - 1) * maxOffsetMeter
+        const offsetMeterLat = (Math.random() * 2 - 1) * maxOffsetMeter
+
+        // 米 -> 度
+        const latOffset = offsetMeterLat / 111320
+        const lngOffset = offsetMeterLng / (111320 * Math.cos(lat * Math.PI / 180))
+
+        return [
+            lng + lngOffset,
+            lat + latOffset
+        ]
+    })
+}
+
+/**
+ * 经纬度数组转字符串
+ * 输入格式:[[lng, lat], ...]
+ * 输出格式:lat,lnglat,lng
+ * @param {Array<Array<number>>} points
+ * @param {number} fixed 保留小数位数(可选)
+ * @returns {string}
+ */
+export function geoPointsToString(points, fixed = 6) {
+    return points
+        .map(([lng, lat]) =>
+            `${lat.toFixed(fixed)},${lng.toFixed(fixed)}`
+        )
+        .join('') + ''
+}
+
+/**
+ * 将 startTime 加上指定秒数,返回同样格式的 endTime
+ * @param {string} startTime - 格式: "YYYY-MM-DD HH:mm:ss"
+ * @param {number} secondsToAdd - 要加的秒数
+ * @returns {string} - 格式: "YYYY-MM-DD HH:mm:ss"
+ */
+export function addSeconds(startTime, secondsToAdd) {
+    const [datePart, timePart] = startTime.split(' ')
+    let [year, month, day] = datePart.split('-').map(Number)
+    let [hour, minute, second] = timePart.split(':').map(Number)
+
+    // 转换成总秒数
+    let totalSeconds = hour * 3600 + minute * 60 + second + secondsToAdd
+
+    // 处理天数进位
+    let extraDays = Math.floor(totalSeconds / 86400)
+    totalSeconds %= 86400
+
+    hour = Math.floor(totalSeconds / 3600)
+    minute = Math.floor((totalSeconds % 3600) / 60)
+    second = totalSeconds % 60
+
+    // 处理日期进位
+    const daysInMonth = (y, m) => new Date(y, m, 0).getDate()
+    day += extraDays
+    while (day > daysInMonth(year, month)) {
+        day -= daysInMonth(year, month)
+        month += 1
+        if (month > 12) {
+            month = 1
+            year += 1
+        }
+    }
+
+    const pad = (n) => n.toString().padStart(2, '0')
+    return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`
+}

+ 90 - 0
lib/Lepao/lepaoAPI.js

@@ -0,0 +1,90 @@
+import axios from 'axios'
+
+export async function sendStartLepao(account) {
+    const params = new URLSearchParams()
+    params.append('account', account)
+
+    const endpoint = "http://222.178.152.79:100/api_v1/startSport"
+    const res = await axios.post(endpoint, params, {
+        proxy: false,
+        headers: {
+            "User-Agent": 'zhong gong le pao/1.7 (iPhone; iOS 26.2; Scale/3.00)'
+        }
+    })
+
+    const data = res.data
+    if (!data || data.status !== 1 || !data.data || !data.data.id || !data.data.startTime) {
+        console.log(`提交乐跑任务失败!${data?.message ?? "未知错误"}`)
+        throw new Error(data?.message ?? "提交乐跑任务失败!请联系客服或稍后再试")
+    }
+    return { task_id: data.data.id, startTime: data.data.startTime }
+}
+
+export async function sendStopLepao(data) {
+    const params = new URLSearchParams()
+    params.append('account', data.account)
+    params.append('calorie', data.calorie)
+    params.append('distance', data.distance)
+    params.append('distribution', data.distribution)
+    params.append('duration', data.duration)
+    params.append('endTime', data.endTime)
+    params.append('id', data.id)
+    params.append('maxDistribution', '0.00')
+    params.append('pathLine', data.pathLine)
+    params.append('startTime', data.startTime)
+    params.append('str1', 'iPhone')
+
+    const endpoint = "http://222.178.152.79:100/api_v1/endSport"
+    const res = await axios.post(endpoint, params, {
+        proxy: false,
+        headers: {
+            "User-Agent": 'zhong gong le pao/1.7 (iPhone; iOS 26.2; Scale/3.00)'
+        }
+    })
+
+    const resData = res.data
+    console.log(`${data.account}乐跑结束返回数据:${JSON.stringify(resData)}`)
+    if (!resData || resData.status !== 1 || !resData.data) {
+        console.log(`乐跑任务进行失败!${resData?.message ?? "未知错误"}`)
+        throw new Error(resData?.message ?? "乐跑任务进行失败!请联系客服或稍后再试")
+    }
+    return resData.data
+}
+
+export async function lepaoUserInfo(student_num) {
+    const params = new URLSearchParams()
+    params.append('account', student_num)
+
+    const endpoint = "http://222.178.152.79:100/api_v1/getUserInfo"
+    const res = await axios.post(endpoint, params, {
+        proxy: false,
+        headers: {
+            "User-Agent": 'zhong gong le pao/1.7 (iPhone; iOS 26.2; Scale/3.00)'
+        }
+    })
+    const data = res.data
+    if (!data || data.status !== 1 || !data.data || !data.data.id || !data.data.nickName || !data.data.department || !data.data.frequency) {
+        console.log(`获取乐跑用户信息失败!${data?.message ?? "未知错误"}`)
+        throw new Error(data?.message ?? "无法获取用户信息,请联系客服或稍后再试")
+    }
+    return data.data
+}
+
+export async function lepaoAuth(student_num, password) {
+    const endpoint = "http://222.178.152.79:100/api_v1/login"
+    const params = new URLSearchParams()
+    params.append('password', password)
+    params.append('account', student_num)
+
+    const res = await axios.post(endpoint, params, {
+        proxy: false,
+        headers: {
+            "User-Agent": 'zhong gong le pao/1.7 (iPhone; iOS 26.2; Scale/3.00)'
+        }
+    })
+    const data = res.data
+    if (!data || data.status !== 1) {
+        throw new Error(data?.message ?? "无法验证乐跑账号,请联系客服或稍后再试")
+    }
+    return true
+}

+ 6 - 3
lib/Server.js

@@ -6,6 +6,8 @@ const config = require('../config.json')
 const Logger = require('./Logger')
 const Logger = require('./Logger')
 const MySQL = require('../plugin/DataBase/MySQL')
 const MySQL = require('../plugin/DataBase/MySQL')
 const mq = require('../plugin/mq')
 const mq = require('../plugin/mq')
+const initmq = require('../plugin/mq/init')
+const lepao = require("./Lepao/cg_lepao").Lepao
 
 
 class SERVER {
 class SERVER {
     constructor() {
     constructor() {
@@ -49,9 +51,9 @@ class SERVER {
         try {
         try {
             await mq.init()
             await mq.init()
             const ch = await mq.getChannel('health')
             const ch = await mq.getChannel('health')
-
-            await ch.assertQueue('mq_health_check', { durable: false })
-            this.logger.info('✅ RabbitMQ 初始化 & 测试成功')
+            await initmq()
+            
+            this.logger.info('✅ RabbitMQ 初始化成功')
         } catch (e) {
         } catch (e) {
             this.logger.error('❌ RabbitMQ 初始化失败')
             this.logger.error('❌ RabbitMQ 初始化失败')
             process.exit(1)
             process.exit(1)
@@ -102,6 +104,7 @@ class SERVER {
                 this.app.listen(this.port, () => {
                 this.app.listen(this.port, () => {
                     this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
                     this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
                 })
                 })
+                lepao.lepaoFinish()
             }).catch(err => {
             }).catch(err => {
                 this.logger.error(`启动服务器失败: ${err.message}`)
                 this.logger.error(`启动服务器失败: ${err.message}`)
             })
             })

+ 2 - 2
plugin/Email/emailTemplate.js

@@ -451,10 +451,10 @@ class emailTemplate {
 
 
                 <div class="info">
                 <div class="info">
                 <p><strong>学号:</strong> ${data.account}</p>
                 <p><strong>学号:</strong> ${data.account}</p>
-                <p><strong>跑区:</strong> ${data.pass_tit} 🌈</p>
+                <p><strong>跑区:</strong> ${data.area} 🌈</p>
                 <p><strong>跑步时间:</strong> ${this.formatSecondsToMinSec(data.time)} ⏱️</p>
                 <p><strong>跑步时间:</strong> ${this.formatSecondsToMinSec(data.time)} ⏱️</p>
                 <p><strong>平均配速:</strong> ${this.calculatePace(data.time, data.distance)} 🐇</p>
                 <p><strong>平均配速:</strong> ${this.calculatePace(data.time, data.distance)} 🐇</p>
-                <p><strong>跑步距离:</strong> ${data.distance} Km 💕</p>
+                <p><strong>跑步距离:</strong> ${Number(data.distance).toFixed(2)} Km 💕</p>
                 <p><strong>累计次数:</strong> ${data.total_num} 次 ✨</p>
                 <p><strong>累计次数:</strong> ${data.total_num} 次 ✨</p>
                 <p><strong>剩余次数:</strong> ${data.term_num - data.total_num >= 0 ? (data.term_num - data.total_num) : '已完成'} 次 🎯</p>
                 <p><strong>剩余次数:</strong> ${data.term_num - data.total_num >= 0 ? (data.term_num - data.total_num) : '已完成'} 次 🎯</p>
                 </div>
                 </div>

+ 34 - 0
plugin/mq/init.js

@@ -0,0 +1,34 @@
+const mq = require('./') 
+
+const initmq = async () => {
+    try {
+        await mq.init()
+
+        const ch = await mq.getChannel('init')
+
+        // 结束交换机
+        await ch.assertExchange('cg_run_finish_exchange', 'direct', { durable: true })
+
+        // 结束队列
+        await ch.assertQueue('cg_run_finish_queue', { durable: true })
+        await ch.bindQueue(
+            'cg_run_finish_queue',
+            'cg_run_finish_exchange',
+            'finish'
+        )
+
+        // 延迟队列
+        await ch.assertQueue('cg_run_delay_queue', {
+            durable: true,
+            arguments: {
+                'x-dead-letter-exchange': 'cg_run_finish_exchange',
+                'x-dead-letter-routing-key': 'finish'
+            }
+        })
+    } catch (e) {
+        console.error('❌ RabbitMQ 初始化失败', e)
+        process.exit(1)
+    }
+}
+
+module.exports = initmq