Browse Source

merge: 将 master 前端依赖的 API 同步到 jkes 分支

合并订单退款、优惠码、文档/下载中心、权限管理、赠送审核、第三方登录等接口,保留 jkes 乐跑核心逻辑与 MQ 队列配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 1 month ago
parent
commit
3aa6f9f137
100 changed files with 7334 additions and 352 deletions
  1. 3 1
      .gitignore
  2. 58 0
      apis/Article/Admin/DeleteArticle.js
  3. 61 0
      apis/Article/Admin/GetArticle.js
  4. 101 0
      apis/Article/Admin/GetArticleList.js
  5. 77 0
      apis/Article/Admin/PostArticle.js
  6. 79 0
      apis/Article/Admin/PublicArticle.js
  7. 45 0
      apis/Article/Category/Admin/Delete.js
  8. 40 0
      apis/Article/Category/Admin/List.js
  9. 76 0
      apis/Article/Category/Admin/Upsert.js
  10. 35 0
      apis/Article/Category/GetCategories.js
  11. 65 0
      apis/Article/GetArticle.js
  12. 81 0
      apis/Article/GetArticleList.js
  13. 64 0
      apis/Coupon/Admin/Detail.js
  14. 80 0
      apis/Coupon/Admin/List.js
  15. 202 0
      apis/Coupon/Admin/Save.js
  16. 62 0
      apis/Coupon/Validate.js
  17. 37 0
      apis/Download/Admin/Delete.js
  18. 42 0
      apis/Download/Admin/List.js
  19. 97 0
      apis/Download/Admin/Upsert.js
  20. 30 0
      apis/Download/Public/GetDownloadCenter.js
  21. 21 5
      apis/Goods/Admin/AddGoods.js
  22. 149 0
      apis/Goods/Admin/ApproveSendCountRequest.js
  23. 3 0
      apis/Goods/Admin/GetGoods.js
  24. 1 0
      apis/Goods/Admin/GetGoodsList.js
  25. 96 0
      apis/Goods/Admin/GetSendCountRequestList.js
  26. 147 0
      apis/Goods/Admin/RejectSendCountRequest.js
  27. 121 0
      apis/Goods/GetMySendCountRequestList.js
  28. 146 7
      apis/Goods/SendCount.js
  29. 34 0
      apis/JW/AdminGetAccount.js
  30. 1 2
      apis/Kefu/Order/Admin/GetOrderDetail.js
  31. 1 2
      apis/Kefu/Order/Admin/GetOrderList.js
  32. 14 2
      apis/Kefu/Order/Admin/ReplyOrder.js
  33. 64 0
      apis/Kefu/Order/Bot/AIReply.js
  34. 26 5
      apis/Kefu/Order/CreateOrder.js
  35. 62 6
      apis/Lepao/Account/Admin/ChangeLepaoCount.js
  36. 14 7
      apis/Lepao/Account/Admin/GetAccountList.js
  37. 61 0
      apis/Lepao/Account/Admin/UpdateAccountInfo.js
  38. 98 0
      apis/Lepao/BindAudit/Admin/ByAccount.js
  39. 134 0
      apis/Lepao/BindAudit/Admin/List.js
  40. 106 0
      apis/Lepao/BindAudit/MyList.js
  41. 1 2
      apis/Lepao/ChangeAutoRun.js
  42. 146 0
      apis/Lepao/Count/Ledger/AdminList.js
  43. 112 0
      apis/Lepao/Count/Ledger/MyList.js
  44. 2 1
      apis/Lepao/Path/GetPathDetail.js
  45. 72 0
      apis/Lepao/Record/Admin/GetOfficialTermRecords.js
  46. 72 0
      apis/Lepao/Record/GetOfficialTermRecords.js
  47. 1 1
      apis/MCP/userMcp/GetMcp.js
  48. 2 2
      apis/MCP/userMcp/McpRPC.js
  49. 17 0
      apis/MCP/workorderMcp/GetMcp.js
  50. 124 0
      apis/MCP/workorderMcp/McpRPC.js
  51. 106 0
      apis/Order/Admin/GetOrderDetail.js
  52. 177 0
      apis/Order/Admin/GetOrderList.js
  53. 57 0
      apis/Order/Admin/RefundOrder.js
  54. 30 2
      apis/Order/CallBack.js
  55. 82 0
      apis/Order/CancelOrder.js
  56. 93 163
      apis/Order/CreateOrder.js
  57. 23 1
      apis/Order/GetOrderDetail.js
  58. 70 0
      apis/Order/RefundOrder.js
  59. 22 0
      apis/Permission/Admin/GetPermissionPoints.js
  60. 22 0
      apis/Permission/Admin/GetResourceRules.js
  61. 48 0
      apis/Permission/Admin/GetUserPermissions.js
  62. 46 0
      apis/Permission/Admin/SetUserPermissions.js
  63. 34 0
      apis/Permission/Admin/UpdateResourceRule.js
  64. 42 0
      apis/Power/Admin/GetChangeRecord.js
  65. 129 0
      apis/Power/Admin/List.js
  66. 4 4
      apis/Public/GetAndroidAppVersion.js
  67. 2 2
      apis/Public/GetAppVersion.js
  68. 314 0
      apis/QK/Admin/Admin.js
  69. 30 0
      apis/QK/Batch/Batch.js
  70. 173 0
      apis/QK/Client/Client.js
  71. 162 0
      apis/QK/Task/Task.js
  72. 72 0
      apis/QuXuanShu/Admin/Detail.js
  73. 148 0
      apis/QuXuanShu/Admin/List.js
  74. 233 0
      apis/QuXuanShu/GetBookList.js
  75. 24 12
      apis/User/Admin/GetQueueTasks.js
  76. 42 3
      apis/User/Admin/GetUserList.js
  77. 50 0
      apis/User/Admin/SetSendCountAutoApprove.js
  78. 70 0
      apis/User/Admin/SetUserBan.js
  79. 2 2
      apis/User/BindEmail.js
  80. 8 1
      apis/User/GetPermissions.js
  81. 7 1
      apis/User/GetUserInfo.js
  82. 19 0
      apis/User/Login.js
  83. 6 0
      apis/User/qqLogin/qqLoginStep2.js
  84. 90 0
      apis/User/uniLogin/BindSocial.js
  85. 42 3
      apis/User/uniLogin/GetLoginUrl.js
  86. 65 47
      apis/User/uniLogin/Login.js
  87. 86 0
      apis/User/uniLogin/UnbindSocial.js
  88. 107 0
      config.example.json
  89. 0 61
      config.json
  90. 41 2
      lib/API.js
  91. 281 1
      lib/AccessControl.js
  92. 164 0
      lib/CouponService.js
  93. 50 0
      lib/Lepao/BindAudit.js
  94. 49 0
      lib/Lepao/CountLedger.js
  95. 41 4
      lib/Lepao/Mcp.js
  96. 203 0
      lib/Lepao/WorkOrderMcp.js
  97. 111 0
      lib/Lepao/lepaoOfficialApi.js
  98. 42 0
      lib/Lepao/lepaoSchoolHttp.js
  99. 121 0
      lib/Lepao/officialAccountAccess.js
  100. 311 0
      lib/Lepao/qgOssPut.js

+ 3 - 1
.gitignore

@@ -1,3 +1,5 @@
 node_modules/
 *.log
-uploads/
+uploads/
+config.json
+.DS_Store

+ 58 - 0
apis/Article/Admin/DeleteArticle.js

@@ -0,0 +1,58 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+// 删除文章
+class DeleteArticle extends API {
+    constructor() {
+        super();
+
+        this.setPath('/Admin/Article')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id } = req.body
+
+        if ([uuid, session, id].some(value => value === '' || value === null || value === undefined))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        // 检查 session
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        // 检查权限
+        let permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes("admin") && !permission.includes("article"))
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED
+            })
+
+        let sql = 'DELETE FROM article WHERE id = ?'
+ 
+        let r = await db.query(sql, [id])
+
+        try {
+            if (r && r.affectedRows > 0) {
+                res.json({
+                    ...BaseStdResponse.OK
+                })
+            } else {
+                res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '删除文章失败!数据库错误' })
+            }
+        } catch (err) {
+            this.logger.error(`删除文章失败!${err.stack}`)
+            res.json({
+                ...BaseStdResponse.ERR,
+                msg: "删除文章失败!",
+            });
+        }
+    }
+}
+
+module.exports.DeleteArticle = DeleteArticle;

+ 61 - 0
apis/Article/Admin/GetArticle.js

@@ -0,0 +1,61 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class GetArticle extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Article');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id } = req.query;
+
+        if ([uuid, session, id].some(value => value === '' || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes("admin") && !permission.includes("article"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        const sql = `
+            SELECT 
+                a.id,
+                a.slug,
+                a.title,
+                a.cover,
+                a.describe,
+                a.content,
+                a.type,
+                a.state,
+                a.views,
+                a.time,
+                u.username AS author
+            FROM article a
+            LEFT JOIN users u ON a.author = u.uuid
+            WHERE a.id = ?
+        `;
+
+        try {
+            const rows = await db.query(sql, [id]);
+
+            if (!rows || rows.length !== 1)
+                return res.json({ ...BaseStdResponse.MISSING_FILE, msg: '文章不存在' });
+
+            res.json({
+                ...BaseStdResponse.OK,
+                data: rows[0]
+            });
+        } catch (err) {
+            this.logger.error(`获取文章失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '获取文章失败!' });
+        }
+    }
+}
+
+module.exports.GetArticle = GetArticle;

+ 101 - 0
apis/Article/Admin/GetArticleList.js

@@ -0,0 +1,101 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class GetArticleList extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Article/List');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, pagesize, current, type, state } = req.query;
+
+        if ([uuid, session, pagesize, current, type, state].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+                endpoint: 1513126
+            });
+        }
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes("admin") && !permission.includes("article"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        pagesize = parseInt(pagesize, 10);
+        current = parseInt(current, 10);
+        state = parseInt(state, 10);
+
+        if (isNaN(pagesize) || pagesize <= 0 || isNaN(current) || current <= 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' });
+        }
+
+        const offset = (current - 1) * pagesize;
+        const filterAllTypes = type === 'all';
+
+        let sql = `
+            SELECT 
+                a.id,
+                a.slug,
+                a.title,
+                a.cover,
+                a.\`describe\`,
+                a.type,
+                a.time,
+                a.views,
+                a.state,
+                u.username AS author
+            FROM article a
+            LEFT JOIN users u ON a.author = u.uuid
+            WHERE a.state = ?
+        `;
+        const params = [state];
+
+        if (!filterAllTypes) {
+            sql += ' AND a.type = ?';
+            params.push(type);
+        }
+
+        sql += ' ORDER BY a.id DESC LIMIT ? OFFSET ?';
+        params.push(String(pagesize), String(offset));
+
+        let countSql = 'SELECT COUNT(*) AS total FROM article a WHERE a.state = ?';
+        const countParams = [state];
+        if (!filterAllTypes) {
+            countSql += ' AND a.type = ?';
+            countParams.push(type);
+        }
+
+        try {
+            const articles = await db.query(sql, params);
+            const countResult = await db.query(countSql, countParams);
+            const total = countResult[0].total;
+            const totalPages = Math.ceil(total / pagesize);
+
+            res.json({
+                ...BaseStdResponse.OK,
+                data: articles || [],
+                pagination: {
+                    current,
+                    pagesize,
+                    total,
+                    totalPages
+                }
+            });
+        } catch (err) {
+            this.logger.error(`获取文章列表失败!${err.stack}`);
+            res.json({
+                ...BaseStdResponse.ERR,
+                msg: '获取文章列表失败!',
+                endpoint: 153127
+            });
+        }
+    }
+}
+
+module.exports.GetArticleList = GetArticleList;

+ 77 - 0
apis/Article/Admin/PostArticle.js

@@ -0,0 +1,77 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+const { slugify, isValidSlug, ensureUniqueSlug } = require("../../../lib/slugify");
+
+class PostArticle extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Article');
+        this.setMethod('POST');
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            id,
+            title,
+            slug,
+            cover,
+            describe,
+            content,
+            type,
+            state
+        } = req.body;
+
+        if ([uuid, session, title, cover, content, type].some(value => value === '' || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes("admin") && !permission.includes("article"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        const time = new Date().getTime();
+
+        try {
+            if (!id) {
+                let baseSlug = slug ? String(slug).trim().toLowerCase() : slugify(title);
+                if (!isValidSlug(baseSlug))
+                    return res.json({ ...BaseStdResponse.ERR, msg: '文章标识格式无效(3-64位小写字母、数字、连字符)' });
+
+                const finalSlug = await ensureUniqueSlug(db, baseSlug);
+                const articleCover = (cover && String(cover).trim()) ? String(cover).trim().slice(0, 16) : '📄';
+
+                const sql = 'INSERT INTO article (title, slug, cover, time, content, author, state, `describe`, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
+                const r = await db.query(sql, [title, finalSlug, articleCover, time, content, uuid, state, describe, type]);
+
+                if (r && r.affectedRows > 0) {
+                    return res.json({ ...BaseStdResponse.OK, id: r.insertId, slug: finalSlug });
+                }
+                return res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '发表文章失败!数据库错误' });
+            }
+
+            const existing = await db.query('SELECT slug FROM article WHERE id = ?', [id]);
+            if (!existing || existing.length === 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '文章不存在' });
+
+            const articleCover = (cover && String(cover).trim()) ? String(cover).trim().slice(0, 16) : '📄';
+            const sql = 'UPDATE article SET title = ?, cover = ?, content = ?, state = ?, `describe` = ?, type = ? WHERE id = ?';
+            const r = await db.query(sql, [title, articleCover, content, state, describe, type, id]);
+
+            if (r && r.affectedRows > 0) {
+                return res.json({ ...BaseStdResponse.OK, slug: existing[0].slug });
+            }
+            return res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '发表文章失败!数据库错误' });
+        } catch (err) {
+            this.logger.error(`发表文章失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: "发表文章失败!" });
+        }
+    }
+}
+
+module.exports.PostArticle = PostArticle;

+ 79 - 0
apis/Article/Admin/PublicArticle.js

@@ -0,0 +1,79 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+// 公开/私密文章
+class PublicArticle extends API {
+    constructor() {
+        super();
+
+        this.setPath('/Admin/Article/Public');
+        this.setMethod('POST');
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id } = req.body
+
+        if ([uuid, session, id].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+            })
+        }
+
+        // 检查 session
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED,
+            });
+        }
+
+        // 检查权限
+        let permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes("admin") && !permission.includes("article")) {
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED,
+            })
+        }
+
+        try {
+            // 查询当前文章的状态
+            let [currentState] = await db.query('SELECT state FROM article WHERE id = ?', [id]);
+
+            if (!currentState) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    endpoint: 7894379,
+                    msg: '文章不存在!'
+                })
+            }
+
+            // 计算新的状态
+            let newState = currentState.state === 0 ? 1 : 0;
+
+            // 更新状态
+            let sql = 'UPDATE article SET state = ? WHERE id = ?';
+            let result = await db.query(sql, [newState, id]);
+
+            if (result && result.affectedRows > 0) {
+                return res.json({
+                    ...BaseStdResponse.OK
+                })
+            } else {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    endpoint: 7894378,
+                    msg: '修改文章状态失败!数据库错误',
+                });
+            }
+        } catch (err) {
+            this.logger.error(`修改文章状态失败!${err.stack}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: "修改文章状态失败!",
+            });
+        }
+    }
+}
+
+module.exports.PublicArticle = PublicArticle

+ 45 - 0
apis/Article/Category/Admin/Delete.js

@@ -0,0 +1,45 @@
+const API = require("../../../../lib/API");
+const db = require("../../../../plugin/DataBase/db");
+const AccessControl = require("../../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../../BaseStdResponse");
+
+class AdminArticleCategoryDelete extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Article/Category');
+        this.setMethod('DELETE');
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id } = req.body;
+        if ([uuid, session, id].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes('admin') && !permission.includes('article'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        try {
+            const cat = await db.query('SELECT slug FROM article_category WHERE id = ?', [id]);
+            if (!cat || cat.length === 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '分类不存在' });
+
+            const count = await db.query('SELECT COUNT(*) AS total FROM article WHERE type = ?', [cat[0].slug]);
+            if (count && count[0].total > 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '该分类下仍有文章,无法删除' });
+
+            const r = await db.query('DELETE FROM article_category WHERE id = ?', [id]);
+            if (!r || r.affectedRows === 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '删除分类失败' });
+            return res.json({ ...BaseStdResponse.OK });
+        } catch (err) {
+            this.logger.error(`删除文章分类失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '删除文章分类失败!' });
+        }
+    }
+}
+
+module.exports.AdminArticleCategoryDelete = AdminArticleCategoryDelete;

+ 40 - 0
apis/Article/Category/Admin/List.js

@@ -0,0 +1,40 @@
+const API = require("../../../../lib/API");
+const db = require("../../../../plugin/DataBase/db");
+const AccessControl = require("../../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../../BaseStdResponse");
+
+class AdminArticleCategoryList extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Article/Category/List');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query;
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes('admin') && !permission.includes('article'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        const sql = `
+            SELECT id, name, slug, icon, sort_order, is_active, created_at
+            FROM article_category
+            ORDER BY sort_order ASC, id ASC
+        `;
+        try {
+            const rows = await db.query(sql);
+            res.json({ ...BaseStdResponse.OK, data: rows || [] });
+        } catch (err) {
+            this.logger.error(`获取文章分类列表失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '获取文章分类列表失败!' });
+        }
+    }
+}
+
+module.exports.AdminArticleCategoryList = AdminArticleCategoryList;

+ 76 - 0
apis/Article/Category/Admin/Upsert.js

@@ -0,0 +1,76 @@
+const API = require("../../../../lib/API");
+const db = require("../../../../plugin/DataBase/db");
+const AccessControl = require("../../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../../BaseStdResponse");
+const { isValidSlug } = require("../../../../lib/slugify");
+
+class AdminArticleCategoryUpsert extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Article/Category');
+        this.setMethod('POST');
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id, name, slug, icon, sort_order, is_active } = req.body;
+        if ([uuid, session, name, slug].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes('admin') && !permission.includes('article'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        const safeSlug = String(slug).trim().toLowerCase();
+        if (!isValidSlug(safeSlug) || safeSlug.length > 32)
+            return res.json({ ...BaseStdResponse.ERR, msg: '分类标识格式无效(3-32位小写字母、数字、连字符)' });
+
+        const safeIcon = icon ? String(icon).trim().slice(0, 16) : null;
+        const sortOrder = Number(sort_order) || 0;
+        const active = Number(is_active) === 0 ? 0 : 1;
+        const now = Date.now();
+
+        try {
+            if (id) {
+                const existing = await db.query('SELECT slug FROM article_category WHERE id = ?', [id]);
+                if (!existing || existing.length === 0)
+                    return res.json({ ...BaseStdResponse.ERR, msg: '分类不存在' });
+
+                const oldSlug = existing[0].slug;
+                if (oldSlug !== safeSlug) {
+                    const dup = await db.query('SELECT id FROM article_category WHERE slug = ? AND id != ?', [safeSlug, id]);
+                    if (dup && dup.length > 0)
+                        return res.json({ ...BaseStdResponse.ERR, msg: '分类标识已存在' });
+                    await db.query('UPDATE article SET type = ? WHERE type = ?', [safeSlug, oldSlug]);
+                }
+
+                const r = await db.query(
+                    'UPDATE article_category SET name = ?, slug = ?, icon = ?, sort_order = ?, is_active = ? WHERE id = ?',
+                    [String(name).trim(), safeSlug, safeIcon, sortOrder, active, id]
+                );
+                if (!r || r.affectedRows === 0)
+                    return res.json({ ...BaseStdResponse.ERR, msg: '更新分类失败' });
+                return res.json({ ...BaseStdResponse.OK, id });
+            }
+
+            const dup = await db.query('SELECT id FROM article_category WHERE slug = ?', [safeSlug]);
+            if (dup && dup.length > 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '分类标识已存在' });
+
+            const r = await db.query(
+                'INSERT INTO article_category (name, slug, icon, sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)',
+                [String(name).trim(), safeSlug, safeIcon, sortOrder, active, now]
+            );
+            if (!r || r.affectedRows === 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '创建分类失败' });
+            return res.json({ ...BaseStdResponse.OK, id: r.insertId });
+        } catch (err) {
+            this.logger.error(`保存文章分类失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '保存文章分类失败!' });
+        }
+    }
+}
+
+module.exports.AdminArticleCategoryUpsert = AdminArticleCategoryUpsert;

+ 35 - 0
apis/Article/Category/GetCategories.js

@@ -0,0 +1,35 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class GetArticleCategories extends API {
+    constructor() {
+        super();
+        this.setPath('/Article/Categories');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        const sql = `
+            SELECT name, slug, icon, sort_order
+            FROM article_category
+            WHERE is_active = 1
+            ORDER BY sort_order ASC, id ASC
+        `;
+        try {
+            const rows = await db.query(sql);
+            res.json({
+                ...BaseStdResponse.OK,
+                data: rows || []
+            });
+        } catch (err) {
+            this.logger.error(`获取文章分类失败!${err.stack}`);
+            res.json({
+                ...BaseStdResponse.ERR,
+                msg: '获取文章分类失败!'
+            });
+        }
+    }
+}
+
+module.exports.GetArticleCategories = GetArticleCategories;

+ 65 - 0
apis/Article/GetArticle.js

@@ -0,0 +1,65 @@
+const API = require("../../lib/API");
+const db = require("../../plugin/DataBase/db");
+const { BaseStdResponse } = require("../../BaseStdResponse");
+
+class GetArticle extends API {
+    constructor() {
+        super();
+        this.setPath('/Article');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        const { slug } = req.query;
+
+        if (!slug) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+                endpoint: 153123
+            });
+        }
+
+        const safeSlug = String(slug).trim();
+
+        try {
+            await db.query('UPDATE article SET views = views + 1 WHERE state = 1 AND slug = ?', [safeSlug]);
+
+            const sql = `
+                SELECT 
+                    a.slug,
+                    a.title,
+                    a.cover,
+                    a.\`describe\`,
+                    a.content,
+                    a.type,
+                    a.views,
+                    a.time,
+                    u.username AS author
+                FROM article a
+                LEFT JOIN users u ON a.author = u.uuid
+                WHERE a.state = 1 AND a.slug = ?
+            `;
+
+            const rows = await db.query(sql, [safeSlug]);
+
+            if (!rows || rows.length !== 1)
+                return res.json({
+                    ...BaseStdResponse.MISSING_FILE,
+                    msg: '文章不存在或无查看权限'
+                });
+
+            res.json({
+                ...BaseStdResponse.OK,
+                data: rows[0]
+            });
+        } catch (err) {
+            this.logger.error(`获取文章失败!${err.stack}`);
+            res.json({
+                ...BaseStdResponse.ERR,
+                msg: '获取文章失败!'
+            });
+        }
+    }
+}
+
+module.exports.GetArticle = GetArticle;

+ 81 - 0
apis/Article/GetArticleList.js

@@ -0,0 +1,81 @@
+const API = require("../../lib/API");
+const db = require("../../plugin/DataBase/db");
+const { BaseStdResponse } = require("../../BaseStdResponse");
+
+class GetArticleList extends API {
+    constructor() {
+        super();
+        this.setPath('/Article/List');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        let { pagesize, current, type } = req.query;
+
+        if ([pagesize, current, type].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+                endpoint: 1513126
+            });
+        }
+
+        pagesize = parseInt(pagesize, 10);
+        current = parseInt(current, 10);
+
+        if (isNaN(pagesize) || pagesize <= 0 || isNaN(current) || current <= 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' });
+        }
+
+        const offset = (current - 1) * pagesize;
+
+        const sql = `
+            SELECT 
+                a.slug,
+                a.title,
+                a.\`describe\`,
+                a.cover,
+                a.type,
+                a.views,
+                a.time,
+                u.username AS author
+            FROM article a
+            LEFT JOIN users u ON a.author = u.uuid
+            WHERE a.state = 1 AND a.type = ?
+            ORDER BY a.id DESC
+            LIMIT ? OFFSET ?
+        `;
+
+        const countSql = `
+            SELECT COUNT(*) AS total
+            FROM article a
+            WHERE a.state = 1 AND a.type = ?
+        `;
+
+        try {
+            const articles = await db.query(sql, [type, String(pagesize), String(offset)]);
+            const countResult = await db.query(countSql, [type]);
+            const total = countResult[0].total;
+            const totalPages = Math.ceil(total / pagesize);
+
+            res.json({
+                ...BaseStdResponse.OK,
+                data: articles || [],
+                pagination: {
+                    current,
+                    pagesize,
+                    total,
+                    totalPages
+                }
+            });
+        } catch (err) {
+            this.logger.error(`获取文章列表失败!${err.stack}`);
+            res.json({
+                ...BaseStdResponse.ERR,
+                msg: '获取文章列表失败!',
+                endpoint: 153127
+            });
+        }
+    }
+}
+
+module.exports.GetArticleList = GetArticleList;

+ 64 - 0
apis/Coupon/Admin/Detail.js

@@ -0,0 +1,64 @@
+const API = require('../../../lib/API.js')
+const db = require('../../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../../BaseStdResponse.js')
+const AccessControl = require('../../../lib/AccessControl.js')
+class CouponDetail extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Coupon/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id } = req.query
+
+        if ([uuid, session, id].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 })
+        }
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('product')) {
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+        }
+
+        const rows = await db.query('SELECT * FROM coupons WHERE id = ? LIMIT 1', [id])
+        if (!rows || rows.length !== 1) {
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: '优惠码不存在' })
+        }
+
+        const coupon = rows[0]
+        const users = await db.query(
+            `SELECT cu.user_uuid, u.username FROM coupon_users cu
+             LEFT JOIN users u ON u.uuid = cu.user_uuid
+             WHERE cu.coupon_id = ?`,
+            [id]
+        )
+        const goods = await db.query(
+            `SELECT cg.goods_id, g.name FROM coupon_goods cg
+             LEFT JOIN goods g ON g.id = cg.goods_id
+             WHERE cg.coupon_id = ?`,
+            [id]
+        )
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                ...coupon,
+                allowedUsers: (users || []).map((r) => ({
+                    uuid: r.user_uuid,
+                    username: r.username
+                })),
+                allowedGoods: (goods || []).map((r) => ({
+                    id: r.goods_id,
+                    name: r.name
+                }))
+            }
+        })
+    }
+}
+
+module.exports.CouponDetail = CouponDetail

+ 80 - 0
apis/Coupon/Admin/List.js

@@ -0,0 +1,80 @@
+const API = require('../../../lib/API.js')
+const db = require('../../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../../BaseStdResponse.js')
+const AccessControl = require('../../../lib/AccessControl.js')
+
+class ListCoupons extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Coupon/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, pagesize, current, keyword, state } = req.query
+
+        if ([uuid, session, pagesize, current].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 })
+        }
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('product')) {
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+        }
+
+        const pageSize = Math.max(1, Number(pagesize))
+        const page = Math.max(1, Number(current))
+        const offset = (page - 1) * pageSize
+
+        let sql = `SELECT * FROM coupons WHERE 1=1`
+        let countSql = `SELECT COUNT(*) AS total FROM coupons WHERE 1=1`
+        const params = []
+        const countParams = []
+
+        if (keyword) {
+            sql += ` AND (code LIKE ? OR name LIKE ?)`
+            countSql += ` AND (code LIKE ? OR name LIKE ?)`
+            const kw = `%${keyword}%`
+            params.push(kw, kw)
+            countParams.push(kw, kw)
+        }
+
+        const stateNum = Number(state)
+        if (
+            state !== undefined &&
+            state !== '' &&
+            state !== '-1' &&
+            !Number.isNaN(stateNum) &&
+            stateNum !== -1
+        ) {
+            sql += ` AND state = ?`
+            countSql += ` AND state = ?`
+            params.push(stateNum)
+            countParams.push(stateNum)
+        }
+
+        sql += ` ORDER BY id DESC LIMIT ? OFFSET ?`
+        params.push(String(pageSize), String(offset))
+
+        const rows = await db.query(sql, params)
+        const countResult = await db.query(countSql, countParams)
+        const total = Number(countResult?.[0]?.total || 0)
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows || [],
+            pagination: {
+                current: page,
+                pagesize: pageSize,
+                total,
+                totalPages: Math.ceil(total / pageSize)
+            }
+        })
+    }
+}
+
+module.exports.ListCoupons = ListCoupons

+ 202 - 0
apis/Coupon/Admin/Save.js

@@ -0,0 +1,202 @@
+const API = require('../../../lib/API.js')
+const db = require('../../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../../BaseStdResponse.js')
+const AccessControl = require('../../../lib/AccessControl.js')
+function parseUsernames(text) {
+    if (!text) return []
+    return [...new Set(
+        String(text)
+            .split(/[,,\n\r\s]+/)
+            .map((s) => s.trim())
+            .filter(Boolean)
+    )]
+}
+
+class SaveCoupon extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Coupon/Save')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const {
+            uuid,
+            session,
+            id,
+            code,
+            name,
+            discount_type,
+            discount_value,
+            user_scope,
+            goods_scope,
+            total_limit,
+            per_user_limit,
+            min_amount,
+            start_time,
+            end_time,
+            state,
+            allowed_usernames,
+            allowed_goods_ids
+        } = req.body
+
+        if ([uuid, session, code, discount_type, discount_value, user_scope, goods_scope, state].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 })
+        }
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('product')) {
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+        }
+
+        const normalizedCode = String(code).trim().toUpperCase()
+        if (!/^[A-Z0-9_-]{3,32}$/.test(normalizedCode)) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '优惠码仅支持 3-32 位字母、数字、下划线或横线'
+            })
+        }
+
+        if (!['percent', 'fixed'].includes(discount_type)) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '折扣类型无效' })
+        }
+
+        const dValue = Number(discount_value)
+        if (discount_type === 'percent' && (dValue <= 0 || dValue > 100)) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '折扣比例需在 1~100 之间' })
+        }
+        if (discount_type === 'fixed' && dValue <= 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '减免金额需大于 0' })
+        }
+
+        const uScope = Number(user_scope)
+        const gScope = Number(goods_scope)
+        const usernames = parseUsernames(allowed_usernames)
+        const goodsIds = (Array.isArray(allowed_goods_ids) ? allowed_goods_ids : [])
+            .map((g) => Number(g))
+            .filter((g) => g > 0)
+
+        if (uScope === 1 && usernames.length === 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '请指定可使用该优惠码的用户' })
+        }
+        if (gScope === 1 && goodsIds.length === 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '请指定可使用该优惠码的商品' })
+        }
+
+        const dup = await db.query(
+            'SELECT id FROM coupons WHERE code = ? AND id != ? LIMIT 1',
+            [normalizedCode, id || 0]
+        )
+        if (dup && dup.length > 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '优惠码已存在' })
+        }
+
+        const time = Date.now()
+        const conn = await db.connect()
+
+        try {
+            await conn.beginTransaction()
+
+            let couponId = id ? Number(id) : null
+
+            if (!couponId) {
+                const [ins] = await conn.execute(
+                    `INSERT INTO coupons (
+                        code, name, discount_type, discount_value, user_scope, goods_scope,
+                        total_limit, per_user_limit, min_amount, start_time, end_time,
+                        state, create_user, create_time, update_time
+                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+                    [
+                        normalizedCode,
+                        name || '',
+                        discount_type,
+                        dValue,
+                        uScope,
+                        gScope,
+                        Number(total_limit || 0),
+                        Math.max(1, Number(per_user_limit || 1)),
+                        Number(min_amount || 0),
+                        start_time || null,
+                        end_time || null,
+                        Number(state),
+                        uuid,
+                        time,
+                        time
+                    ]
+                )
+                couponId = ins.insertId
+            } else {
+                await conn.execute(
+                    `UPDATE coupons SET
+                        code = ?, name = ?, discount_type = ?, discount_value = ?,
+                        user_scope = ?, goods_scope = ?, total_limit = ?, per_user_limit = ?,
+                        min_amount = ?, start_time = ?, end_time = ?, state = ?, update_time = ?
+                     WHERE id = ?`,
+                    [
+                        normalizedCode,
+                        name || '',
+                        discount_type,
+                        dValue,
+                        uScope,
+                        gScope,
+                        Number(total_limit || 0),
+                        Math.max(1, Number(per_user_limit || 1)),
+                        Number(min_amount || 0),
+                        start_time || null,
+                        end_time || null,
+                        Number(state),
+                        time,
+                        couponId
+                    ]
+                )
+            }
+
+            await conn.execute('DELETE FROM coupon_users WHERE coupon_id = ?', [couponId])
+            await conn.execute('DELETE FROM coupon_goods WHERE coupon_id = ?', [couponId])
+
+            if (uScope === 1) {
+                for (const username of usernames) {
+                    const [userRows] = await conn.execute(
+                        'SELECT uuid FROM users WHERE username = ? LIMIT 1',
+                        [username]
+                    )
+                    if (!userRows || userRows.length !== 1) {
+                        await conn.rollback()
+                        return res.json({
+                            ...BaseStdResponse.ERR,
+                            msg: `用户不存在:${username}`
+                        })
+                    }
+                    await conn.execute(
+                        'INSERT INTO coupon_users (coupon_id, user_uuid) VALUES (?, ?)',
+                        [couponId, userRows[0].uuid]
+                    )
+                }
+            }
+
+            if (gScope === 1) {
+                for (const goodsId of goodsIds) {
+                    await conn.execute(
+                        'INSERT INTO coupon_goods (coupon_id, goods_id) VALUES (?, ?)',
+                        [couponId, goodsId]
+                    )
+                }
+            }
+
+            await conn.commit()
+            return res.json({ ...BaseStdResponse.OK, id: couponId })
+        } catch (err) {
+            await conn.rollback()
+            this.logger.error(`保存优惠码失败: ${err.stack}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '保存优惠码失败' })
+        }
+    }
+}
+
+module.exports.SaveCoupon = SaveCoupon

+ 62 - 0
apis/Coupon/Validate.js

@@ -0,0 +1,62 @@
+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 { validateCoupon } = require('../../lib/CouponService.js')
+
+class ValidateCoupon extends API {
+    constructor() {
+        super()
+        this.setPath('/Coupon/Validate')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, code, goods_id } = req.body
+
+        if ([uuid, session, code, goods_id].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 })
+        }
+
+        const goodsRows = await db.query(
+            'SELECT id, price, state, num FROM goods WHERE id = ? LIMIT 1',
+            [goods_id]
+        )
+        if (!goodsRows || goodsRows.length !== 1) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '商品不存在' })
+        }
+        const goods = goodsRows[0]
+        if (goods.state !== 1 || goods.num < 1) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '商品不可购买' })
+        }
+
+        const result = await validateCoupon({
+            code,
+            userUuid: uuid,
+            goodsId: goods_id,
+            goodsPrice: goods.price
+        })
+
+        if (!result.ok) {
+            return res.json({ ...BaseStdResponse.ERR, msg: result.msg })
+        }
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                code: result.code,
+                name: result.name,
+                displayDiscount: result.displayDiscount,
+                originalPrice: result.originalPrice,
+                discountAmount: result.discountAmount,
+                finalPrice: result.finalPrice
+            }
+        })
+    }
+}
+
+module.exports.ValidateCoupon = ValidateCoupon

+ 37 - 0
apis/Download/Admin/Delete.js

@@ -0,0 +1,37 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class AdminDownloadDelete extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Download');
+        this.setMethod('DELETE');
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id } = req.body;
+        if ([uuid, session, id].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        try {
+            const r = await db.query('DELETE FROM download_item WHERE id = ?', [id]);
+            if (!r || r.affectedRows === 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '删除下载项失败' });
+            return res.json({ ...BaseStdResponse.OK });
+        } catch (err) {
+            this.logger.error(`删除下载项失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '删除下载项失败!' });
+        }
+    }
+}
+
+module.exports.AdminDownloadDelete = AdminDownloadDelete;

+ 42 - 0
apis/Download/Admin/List.js

@@ -0,0 +1,42 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class AdminDownloadList extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Download/List');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query;
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        const sql = `
+            SELECT id, title, description, download_url, version, icon,
+                   button_text, button_color, platform, extract_code,
+                   changelog_html, sort_order, is_active, created_at, updated_at
+            FROM download_item
+            ORDER BY sort_order ASC, id ASC
+        `;
+        try {
+            const rows = await db.query(sql);
+            res.json({ ...BaseStdResponse.OK, data: rows || [] });
+        } catch (err) {
+            this.logger.error(`获取下载项列表失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '获取下载项列表失败!' });
+        }
+    }
+}
+
+module.exports.AdminDownloadList = AdminDownloadList;

+ 97 - 0
apis/Download/Admin/Upsert.js

@@ -0,0 +1,97 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const AccessControl = require("../../../lib/AccessControl");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class AdminDownloadUpsert extends API {
+    constructor() {
+        super();
+        this.setPath('/Admin/Download');
+        this.setMethod('POST');
+    }
+
+    async onRequest(req, res) {
+        const {
+            uuid, session, id, title, description, download_url, version,
+            icon, button_text, button_color, platform, extract_code,
+            changelog_html, sort_order, is_active
+        } = req.body;
+
+        if ([uuid, session, title, download_url].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER });
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED });
+
+        const permission = await AccessControl.getPermission(uuid);
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED });
+
+        const now = Date.now();
+        const safeIcon = (icon && String(icon).trim()) ? String(icon).trim().slice(0, 16) : '📦';
+        const sortOrder = Number(sort_order) || 0;
+        const active = Number(is_active) === 0 ? 0 : 1;
+
+        try {
+            if (id) {
+                const r = await db.query(`
+                    UPDATE download_item SET
+                        title = ?, description = ?, download_url = ?, version = ?,
+                        icon = ?, button_text = ?, button_color = ?, platform = ?,
+                        extract_code = ?, changelog_html = ?, sort_order = ?,
+                        is_active = ?, updated_at = ?
+                    WHERE id = ?
+                `, [
+                    String(title).trim(),
+                    description || '',
+                    String(download_url).trim(),
+                    version || '',
+                    safeIcon,
+                    button_text || '立即下载',
+                    button_color || 'primary',
+                    platform || '',
+                    extract_code || '',
+                    changelog_html || '',
+                    sortOrder,
+                    active,
+                    now,
+                    id
+                ]);
+                if (!r || r.affectedRows === 0)
+                    return res.json({ ...BaseStdResponse.ERR, msg: '更新下载项失败' });
+                return res.json({ ...BaseStdResponse.OK, id });
+            }
+
+            const r = await db.query(`
+                INSERT INTO download_item
+                    (title, description, download_url, version, icon, button_text,
+                     button_color, platform, extract_code, changelog_html,
+                     sort_order, is_active, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            `, [
+                String(title).trim(),
+                description || '',
+                String(download_url).trim(),
+                version || '',
+                safeIcon,
+                button_text || '立即下载',
+                button_color || 'primary',
+                platform || '',
+                extract_code || '',
+                changelog_html || '',
+                sortOrder,
+                active,
+                now,
+                now
+            ]);
+            if (!r || r.affectedRows === 0)
+                return res.json({ ...BaseStdResponse.ERR, msg: '创建下载项失败' });
+            return res.json({ ...BaseStdResponse.OK, id: r.insertId });
+        } catch (err) {
+            this.logger.error(`保存下载项失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '保存下载项失败!' });
+        }
+    }
+}
+
+module.exports.AdminDownloadUpsert = AdminDownloadUpsert;

+ 30 - 0
apis/Download/Public/GetDownloadCenter.js

@@ -0,0 +1,30 @@
+const API = require("../../../lib/API");
+const db = require("../../../plugin/DataBase/db");
+const { BaseStdResponse } = require("../../../BaseStdResponse");
+
+class GetDownloadCenter extends API {
+    constructor() {
+        super();
+        this.setPath('/Public/GetDownloadCenter');
+        this.setMethod('GET');
+    }
+
+    async onRequest(req, res) {
+        const sql = `
+            SELECT title, description, download_url, version, icon,
+                   button_text, button_color, platform, extract_code, changelog_html, sort_order
+            FROM download_item
+            WHERE is_active = 1
+            ORDER BY sort_order ASC, id ASC
+        `;
+        try {
+            const rows = await db.query(sql);
+            res.json({ ...BaseStdResponse.OK, data: rows || [] });
+        } catch (err) {
+            this.logger.error(`获取下载中心失败!${err.stack}`);
+            res.json({ ...BaseStdResponse.ERR, msg: '获取下载中心失败!' });
+        }
+    }
+}
+
+module.exports.GetDownloadCenter = GetDownloadCenter;

+ 21 - 5
apis/Goods/Admin/AddGoods.js

@@ -24,9 +24,25 @@ class AddProduct extends API {
             price,
             num,
             lepao_count,
-            ic_count
+            ic_count,
+            icon,
+            description,
+            features
         } = req.body
 
+        const goodsIcon = (icon && String(icon).trim()) ? String(icon).trim().slice(0, 16) : '🏃'
+        const goodsDesc = description != null ? String(description).trim().slice(0, 200) : ''
+        let goodsFeatures = '[]'
+        if (features != null && features !== '') {
+            const raw = typeof features === 'string' ? features : JSON.stringify(features)
+            try {
+                const arr = JSON.parse(raw)
+                goodsFeatures = JSON.stringify(Array.isArray(arr) ? arr.map(s => String(s).trim()).filter(Boolean).slice(0, 6) : [])
+            } catch {
+                goodsFeatures = '[]'
+            }
+        }
+
         if ([uuid, session, name, state, content, price, num, lepao_count, ic_count].some(value => value === '' || value === null || value === undefined))
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
@@ -49,11 +65,11 @@ class AddProduct extends API {
         const time = new Date().getTime()
 
         if (!id) {
-            sql = 'INSERT INTO goods (name, create_user, create_time, update_time, state, content, price, lepao_count, ic_count, num) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
-            r = await db.query(sql, [name, uuid, time, time, state, content, price, lepao_count, ic_count, num])
+            sql = 'INSERT INTO goods (name, create_user, create_time, update_time, state, content, price, lepao_count, ic_count, num, icon, description, features) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+            r = await db.query(sql, [name, uuid, time, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures])
         } else {
-            sql = 'UPDATE goods SET name = ?, update_user = ?, update_time = ?, state = ?,  content = ?, price = ?, lepao_count = ?, ic_count = ?, num = ? WHERE id = ?'
-            r = await db.query(sql, [name, uuid, time, state, content, price, lepao_count, ic_count, num, id])
+            sql = 'UPDATE goods SET name = ?, update_user = ?, update_time = ?, state = ?, content = ?, price = ?, lepao_count = ?, ic_count = ?, num = ?, icon = ?, description = ?, features = ? WHERE id = ?'
+            r = await db.query(sql, [name, uuid, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures, id])
         }
 
         try {

+ 149 - 0
apis/Goods/Admin/ApproveSendCountRequest.js

@@ -0,0 +1,149 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const EmailTemplate = require("../../../plugin/Email/emailTemplate")
+const { insertLedgerRecord } = require("../../../lib/Lepao/CountLedger")
+
+class ApproveSendCountRequest extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Goods/SendCountRequest/Approve")
+        this.setMethod("POST")
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id } = req.body
+        id = Number(id)
+
+        if ([uuid, session, id].some(value => value === "" || value === null || value === undefined || Number.isNaN(id)))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!Number.isInteger(id) || id <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: "参数错误" })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        if (!await AccessControl.canAccess(uuid, ['action.goods.reviewSendCount']))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const conn = await db.connect()
+        try {
+            await conn.beginTransaction()
+
+            const [requestRows] = await conn.execute(
+                `SELECT id, sender_uuid, receiver_user_id, count, status
+                 FROM lepao_send_count_request
+                 WHERE id = ?
+                 FOR UPDATE`,
+                [id]
+            )
+
+            if (!requestRows || requestRows.length !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到赠送申请记录!" })
+            }
+
+            const request = requestRows[0]
+            if (request.status !== "pending") {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "该申请已审核,请刷新后重试!" })
+            }
+
+            const [senderRows] = await conn.execute(
+                "SELECT username FROM users WHERE uuid = ?",
+                [request.sender_uuid]
+            )
+            const senderUsername = senderRows && senderRows[0] ? senderRows[0].username : "未知用户"
+
+            const [receiverRows] = await conn.execute(
+                "SELECT uuid, lepao_count FROM users WHERE id = ? FOR UPDATE",
+                [request.receiver_user_id]
+            )
+            if (!receiverRows || receiverRows.length !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "接收用户不存在,审核通过失败!" })
+            }
+            const receiverUuid = receiverRows[0].uuid
+            const beforeCount = Number(receiverRows[0].lepao_count || 0)
+
+            const [incResult] = await conn.execute(
+                "UPDATE users SET lepao_count = lepao_count + ? WHERE id = ?",
+                [request.count, request.receiver_user_id]
+            )
+            if (!incResult || incResult.affectedRows !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "接收用户不存在,审核通过失败!" })
+            }
+
+            const [updateResult] = await conn.execute(
+                `UPDATE lepao_send_count_request
+                 SET status = 'approved', reviewer_uuid = ?, reviewed_at = NOW(), reject_reason = NULL
+                 WHERE id = ?`,
+                [uuid, id]
+            )
+            if (!updateResult || updateResult.affectedRows !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "更新审核状态失败,请稍后再试!" })
+            }
+
+            await insertLedgerRecord({
+                executor: conn,
+                userUuid: receiverUuid,
+                delta: Number(request.count || 0),
+                balanceBefore: beforeCount,
+                balanceAfter: beforeCount + Number(request.count || 0),
+                bizType: 'gift_receive',
+                bizId: `send_request:${id}`,
+                operatorUuid: uuid,
+                remark: `${senderUsername}赠送${request.count}次`
+            })
+
+            await conn.commit()
+            const requestId = request.id
+            const reviewTime = new Date().getTime()
+
+            // 非阻塞通知接收人,不影响审核结果
+            Promise.resolve().then(async () => {
+                try {
+                    const infoSql = `
+                        SELECT
+                            ru.email AS receiver_email,
+                            ru.username AS receiver_username,
+                            su.username AS sender_username
+                        FROM users ru
+                        LEFT JOIN users su ON su.uuid = ?
+                        WHERE ru.id = ?
+                    `
+                    const infoRows = await db.query(infoSql, [request.sender_uuid, request.receiver_user_id])
+                    if (!infoRows || infoRows.length !== 1 || !infoRows[0].receiver_email) {
+                        this.logger.warn(`[SendCountNotify][approve][requestId=${requestId}] 接收人邮箱为空或用户不存在,跳过通知`)
+                        return
+                    }
+
+                    await EmailTemplate.sendCountRequestApproved(infoRows[0].receiver_email, {
+                        requestId,
+                        senderUsername: infoRows[0].sender_username || "未知用户",
+                        count: request.count,
+                        reviewTime
+                    })
+                } catch (mailErr) {
+                    this.logger.error(`[SendCountNotify][approve][requestId=${requestId}] 接收人通知发送失败:${mailErr.message || "未知错误"}`)
+                }
+            })
+
+            return res.json({ ...BaseStdResponse.OK, msg: "审核通过成功" })
+        } catch (err) {
+            try { await conn.rollback() } catch (_) { }
+            this.logger.error(`审核通过赠送申请失败!${err.message || "未知错误"}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: "审核通过失败,请稍后再试!" })
+        } finally {
+            if (conn?.connection && typeof conn.connection.release === 'function' && typeof conn?.release === 'function') {
+                conn.release()
+            }
+        }
+    }
+}
+
+module.exports.ApproveSendCountRequest = ApproveSendCountRequest

+ 3 - 0
apis/Goods/Admin/GetGoods.js

@@ -48,6 +48,9 @@ class GetGoods extends API {
                 a.num,
                 a.ic_count,
                 a.lepao_count,
+                a.icon,
+                a.description,
+                a.features,
                 a.create_time,
                 a.update_time,
                 a.limit,

+ 1 - 0
apis/Goods/Admin/GetGoodsList.js

@@ -52,6 +52,7 @@ class GetGoodsList extends API {
                 a.num,
                 a.ic_count,
                 a.lepao_count,
+                a.icon,
                 a.views,
                 a.create_time,
                 a.update_time,

+ 96 - 0
apis/Goods/Admin/GetSendCountRequestList.js

@@ -0,0 +1,96 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class GetSendCountRequestList extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Goods/SendCountRequest/List")
+        this.setMethod("GET")
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, pagesize, current, status } = req.query
+
+        if ([uuid, session, pagesize, current].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (isNaN(pagesize) || pagesize <= 0 || pagesize > 50 || isNaN(current) || current <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: "参数错误" })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        if (!await AccessControl.canAccess(uuid, ['page.admin.goods.sendCountRequestList']))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const statusList = ["pending", "approved", "rejected"]
+        if (status && !statusList.includes(status))
+            return res.json({ ...BaseStdResponse.ERR, msg: "状态参数错误" })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+
+        let sql = `
+            SELECT
+                r.id,
+                r.sender_uuid,
+                su.username AS sender_username,
+                r.receiver_user_id,
+                ru.username AS receiver_username,
+                r.count,
+                r.status,
+                r.created_at,
+                r.reviewed_at,
+                r.reviewer_uuid,
+                rv.username AS reviewer_username,
+                r.reject_reason
+            FROM
+                lepao_send_count_request r
+            LEFT JOIN users su ON su.uuid = r.sender_uuid
+            LEFT JOIN users ru ON ru.id = r.receiver_user_id
+            LEFT JOIN users rv ON rv.uuid = r.reviewer_uuid
+            WHERE 1 = 1
+        `
+
+        let countSql = `
+            SELECT COUNT(*) AS total
+            FROM lepao_send_count_request r
+            WHERE 1 = 1
+        `
+
+        const params = []
+        const countParams = []
+
+        if (status) {
+            sql += ` AND r.status = ?`
+            countSql += ` AND r.status = ?`
+            params.push(status)
+            countParams.push(status)
+        }
+
+        sql += `
+            ORDER BY r.id DESC
+            LIMIT ? OFFSET ?
+        `
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(sql, params)
+        const countRows = await db.query(countSql, countParams)
+
+        if (!rows || !countRows)
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "获取赠送审核列表失败!" })
+
+        res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.GetSendCountRequestList = GetSendCountRequestList

+ 147 - 0
apis/Goods/Admin/RejectSendCountRequest.js

@@ -0,0 +1,147 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const EmailTemplate = require("../../../plugin/Email/emailTemplate")
+const { insertLedgerRecord } = require("../../../lib/Lepao/CountLedger")
+
+class RejectSendCountRequest extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Goods/SendCountRequest/Reject")
+        this.setMethod("POST")
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id, reject_reason } = req.body
+        id = Number(id)
+        reject_reason = typeof reject_reason === "string" ? reject_reason.trim() : ""
+
+        if ([uuid, session, id].some(value => value === "" || value === null || value === undefined || Number.isNaN(id)))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!Number.isInteger(id) || id <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: "参数错误" })
+
+        if (reject_reason.length > 255)
+            return res.json({ ...BaseStdResponse.ERR, msg: "拒绝原因过长,请控制在255字以内!" })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        if (!await AccessControl.canAccess(uuid, ['action.goods.reviewSendCount']))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const conn = await db.connect()
+        try {
+            await conn.beginTransaction()
+
+            const [requestRows] = await conn.execute(
+                `SELECT id, sender_uuid, receiver_user_id, count, status
+                 FROM lepao_send_count_request
+                 WHERE id = ?
+                 FOR UPDATE`,
+                [id]
+            )
+
+            if (!requestRows || requestRows.length !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到赠送申请记录!" })
+            }
+
+            const request = requestRows[0]
+            if (request.status !== "pending") {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "该申请已审核,请刷新后重试!" })
+            }
+
+            const [senderRows] = await conn.execute(
+                "SELECT lepao_count FROM users WHERE uuid = ? FOR UPDATE",
+                [request.sender_uuid]
+            )
+            if (!senderRows || senderRows.length !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "赠送人不存在,退回次数失败!" })
+            }
+            const beforeCount = Number(senderRows[0].lepao_count || 0)
+
+            const [refundResult] = await conn.execute(
+                "UPDATE users SET lepao_count = lepao_count + ? WHERE uuid = ?",
+                [request.count, request.sender_uuid]
+            )
+            if (!refundResult || refundResult.affectedRows !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "赠送人不存在,退回次数失败!" })
+            }
+
+            const [updateResult] = await conn.execute(
+                `UPDATE lepao_send_count_request
+                 SET status = 'rejected', reviewer_uuid = ?, reviewed_at = NOW(), reject_reason = ?
+                 WHERE id = ?`,
+                [uuid, reject_reason || null, id]
+            )
+            if (!updateResult || updateResult.affectedRows !== 1) {
+                await conn.rollback()
+                return res.json({ ...BaseStdResponse.ERR, msg: "更新审核状态失败,请稍后再试!" })
+            }
+
+            await insertLedgerRecord({
+                executor: conn,
+                userUuid: request.sender_uuid,
+                delta: Number(request.count || 0),
+                balanceBefore: beforeCount,
+                balanceAfter: beforeCount + Number(request.count || 0),
+                bizType: 'gift_send_refund',
+                bizId: `send_request:${id}`,
+                operatorUuid: uuid,
+                remark: reject_reason || ''
+            })
+
+            await conn.commit()
+            const requestId = request.id
+            const reviewTime = new Date().getTime()
+
+            // 非阻塞通知赠送人,不影响审核结果
+            Promise.resolve().then(async () => {
+                try {
+                    const infoSql = `
+                        SELECT
+                            su.email AS sender_email,
+                            su.username AS sender_username,
+                            ru.username AS receiver_username
+                        FROM users su
+                        LEFT JOIN users ru ON ru.id = ?
+                        WHERE su.uuid = ?
+                    `
+                    const infoRows = await db.query(infoSql, [request.receiver_user_id, request.sender_uuid])
+                    if (!infoRows || infoRows.length !== 1 || !infoRows[0].sender_email) {
+                        this.logger.warn(`[SendCountNotify][reject][requestId=${requestId}] 赠送人邮箱为空或用户不存在,跳过通知`)
+                        return
+                    }
+
+                    await EmailTemplate.sendCountRequestRejected(infoRows[0].sender_email, {
+                        requestId,
+                        receiverUsername: infoRows[0].receiver_username || "未知用户",
+                        count: request.count,
+                        reviewTime,
+                        rejectReason: reject_reason || "无"
+                    })
+                } catch (mailErr) {
+                    this.logger.error(`[SendCountNotify][reject][requestId=${requestId}] 赠送人通知发送失败:${mailErr.message || "未知错误"}`)
+                }
+            })
+
+            return res.json({ ...BaseStdResponse.OK, msg: "已拒绝该赠送申请" })
+        } catch (err) {
+            try { await conn.rollback() } catch (_) { }
+            this.logger.error(`拒绝赠送申请失败!${err.message || "未知错误"}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: "拒绝申请失败,请稍后再试!" })
+        } finally {
+            if (conn?.connection && typeof conn.connection.release === 'function' && typeof conn?.release === 'function') {
+                conn.release()
+            }
+        }
+    }
+}
+
+module.exports.RejectSendCountRequest = RejectSendCountRequest

+ 121 - 0
apis/Goods/GetMySendCountRequestList.js

@@ -0,0 +1,121 @@
+const API = require("../../lib/API")
+const db = require("../../plugin/DataBase/db")
+const AccessControl = require("../../lib/AccessControl")
+const { BaseStdResponse } = require("../../BaseStdResponse")
+
+class GetMySendCountRequestList extends API {
+    constructor() {
+        super()
+        this.setPath("/Goods/SendCountRequest/MyList")
+        this.setMethod("GET")
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, pagesize, current, direction, status } = req.query
+
+        if ([uuid, session, pagesize, current].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (isNaN(pagesize) || pagesize <= 0 || pagesize > 50 || isNaN(current) || current <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: "参数错误" })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const directionList = ["sent", "received"]
+        if (direction && !directionList.includes(direction))
+            return res.json({ ...BaseStdResponse.ERR, msg: "direction参数错误" })
+
+        const statusList = ["pending", "approved", "rejected"]
+        if (status && !statusList.includes(status))
+            return res.json({ ...BaseStdResponse.ERR, msg: "status参数错误" })
+
+        const userRows = await db.query("SELECT id FROM users WHERE uuid = ?", [uuid])
+        if (!userRows || userRows.length !== 1)
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "获取用户信息失败!" })
+
+        const userId = userRows[0].id
+        const offset = (Number(current) - 1) * Number(pagesize)
+
+        let sql = `
+            SELECT
+                r.id,
+                r.sender_uuid,
+                su.username AS sender_username,
+                su.avatar AS sender_avatar,
+                r.receiver_user_id,
+                ru.username AS receiver_username,
+                ru.avatar AS receiver_avatar,
+                CASE WHEN r.sender_uuid = ? THEN 'sent' ELSE 'received' END AS direction,
+                CASE WHEN r.sender_uuid = ? THEN ru.username ELSE su.username END AS counterparty_username,
+                CASE WHEN r.sender_uuid = ? THEN ru.avatar ELSE su.avatar END AS counterparty_avatar,
+                r.count,
+                r.status,
+                r.created_at,
+                r.reviewed_at,
+                r.reject_reason
+            FROM
+                lepao_send_count_request r
+            LEFT JOIN users su ON su.uuid = r.sender_uuid
+            LEFT JOIN users ru ON ru.id = r.receiver_user_id
+            WHERE
+                (r.sender_uuid = ? OR r.receiver_user_id = ?)
+        `
+
+        let countSql = `
+            SELECT COUNT(*) AS total
+            FROM
+                lepao_send_count_request r
+            WHERE
+                (r.sender_uuid = ? OR r.receiver_user_id = ?)
+        `
+
+        const params = [uuid, uuid, uuid, uuid, userId]
+        const countParams = [uuid, userId]
+
+        if (direction === "sent") {
+            sql += ` AND r.sender_uuid = ?`
+            countSql += ` AND r.sender_uuid = ?`
+            params.push(uuid)
+            countParams.push(uuid)
+        }
+
+        if (direction === "received") {
+            sql += ` AND r.receiver_user_id = ?`
+            countSql += ` AND r.receiver_user_id = ?`
+            params.push(userId)
+            countParams.push(userId)
+        }
+
+        if (status) {
+            sql += ` AND r.status = ?`
+            countSql += ` AND r.status = ?`
+            params.push(status)
+            countParams.push(status)
+        }
+
+        sql += `
+            ORDER BY r.id DESC
+            LIMIT ? OFFSET ?
+        `
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(sql, params)
+        const countRows = await db.query(countSql, countParams)
+
+        if (!rows || !countRows)
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "获取赠送记录失败!" })
+
+        res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.GetMySendCountRequestList = GetMySendCountRequestList

+ 146 - 7
apis/Goods/SendCount.js

@@ -2,6 +2,8 @@ const API = require("../../lib/API")
 const db = require("../../plugin/DataBase/db")
 const AccessControl = require("../../lib/AccessControl")
 const { BaseStdResponse } = require("../../BaseStdResponse")
+const EmailTemplate = require("../../plugin/Email/emailTemplate")
+const { insertLedgerRecord } = require("../../lib/Lepao/CountLedger")
 
 class SendCount extends API {
     constructor() {
@@ -30,7 +32,7 @@ class SendCount extends API {
             await conn.beginTransaction()
 
             const [senderRows] = await conn.execute(
-                "SELECT id, username, lepao_count FROM users WHERE uuid = ?",
+                "SELECT id, username, lepao_count, COALESCE(send_count_auto_approve, 0) AS send_count_auto_approve FROM users WHERE uuid = ?",
                 [uuid]
             )
             if (!senderRows || senderRows.length !== 1) {
@@ -60,17 +62,150 @@ class SendCount extends API {
                 return res.json({ ...BaseStdResponse.ERR, msg: "剩余乐跑次数不足,请购买后再赠送!" })
             }
 
-            const [incResult] = await conn.execute(
-                "UPDATE users SET lepao_count = lepao_count + ? WHERE id = ?",
-                [count, targetRows[0].id]
+            const senderLepaoBefore = Number(senderRows[0].lepao_count || 0)
+            const autoApprove = Number(senderRows[0].send_count_auto_approve) === 1
+
+            if (autoApprove) {
+                const [recvRows] = await conn.execute(
+                    "SELECT uuid, lepao_count FROM users WHERE id = ? FOR UPDATE",
+                    [targetRows[0].id]
+                )
+                if (!recvRows || recvRows.length !== 1) {
+                    await conn.rollback()
+                    return res.json({ ...BaseStdResponse.ERR, msg: "未找到接收用户,请检查用户名是否正确!" })
+                }
+                const receiverUuid = recvRows[0].uuid
+                const beforeRecv = Number(recvRows[0].lepao_count || 0)
+
+                const [incResult] = await conn.execute(
+                    "UPDATE users SET lepao_count = lepao_count + ? WHERE id = ?",
+                    [count, targetRows[0].id]
+                )
+                if (!incResult || incResult.affectedRows !== 1) {
+                    await conn.rollback()
+                    return res.json({ ...BaseStdResponse.ERR, msg: "接收方入账失败,请稍后再试!" })
+                }
+
+                const [insertResult] = await conn.execute(
+                    `INSERT INTO lepao_send_count_request
+                    (sender_uuid, receiver_user_id, count, status, created_at, reviewed_at, reviewer_uuid)
+                    VALUES (?, ?, ?, 'approved', NOW(), NOW(), NULL)`,
+                    [uuid, targetRows[0].id, count]
+                )
+                if (!insertResult || insertResult.affectedRows !== 1) {
+                    await conn.rollback()
+                    return res.json({ ...BaseStdResponse.ERR, msg: "记录赠送失败,请稍后再试!" })
+                }
+
+                const requestId = insertResult.insertId
+                await insertLedgerRecord({
+                    executor: conn,
+                    userUuid: uuid,
+                    delta: -count,
+                    balanceBefore: senderLepaoBefore,
+                    balanceAfter: senderLepaoBefore - count,
+                    bizType: "gift_send_lock",
+                    bizId: `send_request:${requestId}`,
+                    remark: `向${username}赠送${count}次`
+                })
+                await insertLedgerRecord({
+                    executor: conn,
+                    userUuid: receiverUuid,
+                    delta: count,
+                    balanceBefore: beforeRecv,
+                    balanceAfter: beforeRecv + count,
+                    bizType: "gift_receive",
+                    bizId: `send_request:${requestId}`,
+                    operatorUuid: null,
+                    remark: `${senderRows[0].username}赠送${count}次`
+                })
+
+                await conn.commit()
+                const reviewTime = new Date().getTime()
+
+                Promise.resolve().then(async () => {
+                    try {
+                        const infoSql = `
+                            SELECT ru.email AS receiver_email, ru.username AS receiver_username
+                            FROM users ru
+                            WHERE ru.id = ?
+                        `
+                        const infoRows = await db.query(infoSql, [targetRows[0].id])
+                        if (!infoRows || infoRows.length !== 1 || !infoRows[0].receiver_email) {
+                            this.logger.warn(`[SendCountNotify][auto][requestId=${requestId}] 接收人邮箱为空,跳过通知`)
+                            return
+                        }
+                        await EmailTemplate.sendCountRequestApproved(infoRows[0].receiver_email, {
+                            requestId,
+                            senderUsername: senderRows[0].username,
+                            count,
+                            reviewTime
+                        })
+                    } catch (mailErr) {
+                        this.logger.error(`[SendCountNotify][auto][requestId=${requestId}] 接收人通知发送失败:${mailErr.message || "未知错误"}`)
+                    }
+                })
+
+                return res.json({ ...BaseStdResponse.OK, msg: "赠送成功,对方已到账" })
+            }
+
+            const [insertResult] = await conn.execute(
+                `INSERT INTO lepao_send_count_request
+                (sender_uuid, receiver_user_id, count, status, created_at)
+                VALUES (?, ?, ?, 'pending', NOW())`,
+                [uuid, targetRows[0].id, count]
             )
-            if (incResult.affectedRows !== 1) {
+            if (!insertResult || insertResult.affectedRows !== 1) {
                 await conn.rollback()
-                return res.json({ ...BaseStdResponse.ERR, msg: "赠送次数失败,请稍后再试!" })
+                return res.json({ ...BaseStdResponse.ERR, msg: "提交赠送审核失败,请稍后再试!" })
             }
 
+            const requestId = insertResult.insertId
+            await insertLedgerRecord({
+                executor: conn,
+                userUuid: uuid,
+                delta: -count,
+                balanceBefore: senderLepaoBefore,
+                balanceAfter: senderLepaoBefore - count,
+                bizType: "gift_send_lock",
+                bizId: `send_request:${requestId}`,
+                remark: `向${username}赠送${count}次`
+            })
             await conn.commit()
-            return res.json({ ...BaseStdResponse.OK, msg: "赠送成功" })
+            const createTime = new Date().getTime()
+
+            // 非阻塞通知管理员,不影响主业务流程
+            Promise.resolve().then(async () => {
+                try {
+                    const adminSql = `
+                        SELECT email
+                        FROM users
+                        WHERE email IS NOT NULL
+                          AND email <> ''
+                          AND (JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"'))
+                    `
+                    const adminRows = await db.query(adminSql)
+                    if (!adminRows || adminRows.length === 0) {
+                        this.logger.warn(`[SendCountNotify][submit][requestId=${requestId}] 未找到可通知的管理员邮箱`)
+                        return
+                    }
+
+                    const emails = [...new Set(adminRows.map(row => row.email).filter(Boolean))]
+                    for (const email of emails) {
+                        await EmailTemplate.sendCountRequestNotifyAdmins(email, {
+                            requestId,
+                            senderUsername: senderRows[0].username,
+                            receiverUsername: username,
+                            count,
+                            createTime
+                        })
+                    }
+                } catch (mailErr) {
+                    this.logger.error(`[SendCountNotify][submit][requestId=${requestId}] 管理员通知发送失败:${mailErr.message || "未知错误"}`)
+                }
+            })
+
+            return res.json({ ...BaseStdResponse.OK, msg: "已提交审核,审核通过后接收方将到账" })
 
         } catch (err) {
             try { await conn.rollback() } catch (_) { }
@@ -79,6 +214,10 @@ class SendCount extends API {
                 ...BaseStdResponse.ERR,
                 msg: `赠送次数失败,请稍后再试!`
             })
+        } finally {
+            if (conn?.connection && typeof conn.connection.release === 'function' && typeof conn?.release === 'function') {
+                conn.release()
+            }
         }
     }
 }

+ 34 - 0
apis/JW/AdminGetAccount.js

@@ -0,0 +1,34 @@
+const API = require('../../lib/API.js')
+const db = require('../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../BaseStdResponse.js')
+const AccessControl = require('../../lib/AccessControl')
+
+class AdminGetAccount extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/JW/Account')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, create_user } = req.query
+
+        if ([uuid, session, create_user].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        try {
+            const rows = await AccessControl.listVerifiedJwAccounts(create_user)
+            return res.json({ ...BaseStdResponse.OK, data: rows })
+        } catch (err) {
+            this.logger.error(`管理员获取统一身份认证账号失败:${err.stack || err}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '获取统一身份认证账号失败' })
+        }
+    }
+}
+
+module.exports.AdminGetAccount = AdminGetAccount

+ 1 - 2
apis/Kefu/Order/Admin/GetOrderDetail.js

@@ -25,8 +25,7 @@ class GetOrderDetail extends API {
             })
 
         // 检查权限
-        let permission = await AccessControl.getPermission(uuid)
-        if (!permission.includes("admin") && !permission.includes("service"))
+        if (!await AccessControl.canAccess(uuid, ['page.admin.service.orderList']))
             return res.json({
                 ...BaseStdResponse.PERMISSION_DENIED
             })

+ 1 - 2
apis/Kefu/Order/Admin/GetOrderList.js

@@ -41,8 +41,7 @@ class GetOrderList extends API {
             })
 
         // 检查权限
-        let permission = await AccessControl.getPermission(uuid)
-        if (!permission.includes("admin") && !permission.includes("service"))
+        if (!await AccessControl.canAccess(uuid, ['page.admin.service.orderList']))
             return res.json({
                 ...BaseStdResponse.PERMISSION_DENIED
             })

+ 14 - 2
apis/Kefu/Order/Admin/ReplyOrder.js

@@ -3,6 +3,7 @@ const db = require("../../../../plugin/DataBase/db.js");
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
 const AccessControl = require("../../../../lib/AccessControl.js");
 const EmailTemplate = require('../../../../plugin/Email/emailTemplate')
+const OneBotV11 = require('../../../../plugin/OneBot/OneBotV11')
 
 class ReplyOrder extends API {
     constructor() {
@@ -26,8 +27,7 @@ class ReplyOrder extends API {
             })
 
         // 检查权限
-        let permission = await AccessControl.getPermission(uuid)
-        if (!permission.includes("admin") && !permission.includes("service"))
+        if (!await AccessControl.canAccess(uuid, ['action.service.replyOrder']))
             return res.json({
                 ...BaseStdResponse.PERMISSION_DENIED
             })
@@ -67,6 +67,18 @@ class ReplyOrder extends API {
                 if (selectRows[0].email) {
                     await EmailTemplate.orderNewReply(selectRows[0].email, { id, content, files })
                 }
+
+                // try {
+                //     await OneBotV11.sendOrderMessage({
+                //         orderId: id,
+                //         role: '客服',
+                //         senderUuid: uuid,
+                //         content,
+                //         files: files ?? []
+                //     })
+                // } catch (botErr) {
+                //     this.logger.error(`OneBot v11 转发客服回复失败:${botErr.stack}`)
+                // }
             } else {
                 return res.json({ ...BaseStdResponse.ERR, msg: '回复工单失败!数据库错误' })
             }

+ 64 - 0
apis/Kefu/Order/Bot/AIReply.js

@@ -0,0 +1,64 @@
+const API = require("../../../../lib/API.js");
+const db = require("../../../../plugin/DataBase/db.js");
+const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
+const OneBotV11 = require("../../../../plugin/OneBot/OneBotV11.js");
+
+class AIReply extends API {
+    constructor() {
+        super();
+        this.setPath('/Kefu/Order/Bot/AIReply')
+        this.setMethod('POST')
+        this.noEncrypt()
+    }
+
+    async onRequest(req, res) {
+        const onebot = OneBotV11.getOneBotConfig()
+        if (!onebot.enabled) {
+            return res.json({ ...BaseStdResponse.ERR, msg: 'OneBot v11 未启用' })
+        }
+
+        const token = req.headers['x-onebot-access-token'] || req.headers['authorization'] || ''
+        if (onebot.callbackToken && !String(token).includes(onebot.callbackToken)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED,
+                msg: 'AI 回调 token 校验失败'
+            })
+        }
+
+        const { id, content } = req.body || {}
+        if ([id, content].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+                msg: '缺少 id 或 content'
+            })
+        }
+
+        const selectSql = 'SELECT msg, state FROM work_order WHERE id = ?'
+        const rows = await db.query(selectSql, [id])
+        if (!rows || rows.length !== 1 || rows[0].state === 2) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '工单不存在或已关闭'
+            })
+        }
+
+        const now = Date.now()
+        const msg = rows[0].msg || []
+        msg.push({
+            time: now,
+            content: String(content).trim(),
+            uuid: 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281',
+            type: 'ai',
+            files: []
+        })
+
+        const updateSql = 'UPDATE work_order SET msg = ?, update_time = ?, state = 3 WHERE id = ?'
+        await db.query(updateSql, [msg, now, id])
+
+        return res.json({
+            ...BaseStdResponse.OK
+        })
+    }
+}
+
+module.exports.AIReply = AIReply

+ 26 - 5
apis/Kefu/Order/CreateOrder.js

@@ -3,6 +3,7 @@ const db = require("../../../plugin/DataBase/db.js");
 const { BaseStdResponse } = require("../../../BaseStdResponse.js");
 const AccessControl = require("../../../lib/AccessControl.js");
 const EmailTemplate = require('../../../plugin/Email/emailTemplate')
+const OneBotV11 = require('../../../plugin/OneBot/OneBotV11')
 
 class CreateOrder extends API {
     constructor() {
@@ -15,15 +16,17 @@ class CreateOrder extends API {
     async onRequest(req, res) {
         let { uuid, session, id, title, content, files, email } = req.body
 
-        if ([uuid, session, content].some(value => value === '' || value === null || value === undefined))
+        if ([uuid, session, content].some(value => value === '' || value === null || value === undefined)) {
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
             })
+        }
 
-        if (!await AccessControl.checkSession(uuid, session))
+        if (!await AccessControl.checkSession(uuid, session)) {
             return res.status(401).json({
                 ...BaseStdResponse.ACCESS_DENIED
             })
+        }
 
         const time = new Date().getTime()
 
@@ -36,7 +39,7 @@ class CreateOrder extends API {
             type: 'user'
         }
 
-        let sql, r
+        let sql, r, need_manual = 0
 
         if (!id) {
             if ([title].some(value => value === '' || value === null || value === undefined))
@@ -56,7 +59,7 @@ class CreateOrder extends API {
             sql = 'INSERT INTO work_order (title, email, msg, create_user, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?)'
             r = await db.query(sql, [title, email, msg, uuid, time, time])
         } else {
-            const selectSql = 'SELECT msg, state, email FROM work_order WHERE id = ? AND create_user = ?'
+            const selectSql = 'SELECT msg, state, email, need_manual FROM work_order WHERE id = ? AND create_user = ?'
             const selectRows = await db.query(selectSql, [id, uuid])
             if (!selectRows || selectRows.length !== 1 || selectRows[0].state === 2)
                 return res.json({
@@ -69,6 +72,7 @@ class CreateOrder extends API {
 
             sql = 'UPDATE work_order SET msg = ?, update_time = ?, state = 0 WHERE id = ? AND create_user = ?'
             r = await db.query(sql, [msg, time, id, uuid])
+            need_manual = selectRows[0].need_manual
         }
 
         try {
@@ -78,6 +82,23 @@ class CreateOrder extends API {
                     data: r.insertId
                 })
 
+                try {
+                    if (need_manual == 0) {
+                        this.logger.info(`工单转发OneBot开始:orderId=${r.insertId || id}`)
+                        await OneBotV11.sendOrderMessage({
+                            orderId: r.insertId || id,
+                            title,
+                            role: '用户',
+                            senderUuid: uuid,
+                            content,
+                            files: files ?? []
+                        })
+                        this.logger.info(`工单转发OneBot结束:orderId=${r.insertId || id}`)
+                    }
+                } catch (botErr) {
+                    this.logger.error(`OneBot v11 转发工单消息失败:${botErr.stack}`)
+                }
+
                 let kefuSql = `
                     SELECT email FROM users
                     WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
@@ -88,7 +109,7 @@ class CreateOrder extends API {
                 let emails = [...new Set(kefuRows.map(row => row.email))]
 
                 for (const email of emails) {
-                    if(!email) break
+                    if (!email) break
                     await EmailTemplate.orderNewReply(email, { id: r.insertId || id, content, files })
                 }
             } else {

+ 62 - 6
apis/Lepao/Account/Admin/ChangeLepaoCount.js

@@ -2,6 +2,7 @@ const API = require("../../../../lib/API");
 const db = require("../../../../plugin/DataBase/db");
 const AccessControl = require("../../../../lib/AccessControl");
 const { BaseStdResponse } = require("../../../../BaseStdResponse");
+const { insertLedgerRecord } = require("../../../../lib/Lepao/CountLedger");
 
 class ChangeLepaoCount extends API {
     constructor() {
@@ -16,13 +17,20 @@ class ChangeLepaoCount extends API {
             uuid,
             session,
             userid,
-            lepao_count
+            lepao_count,
+            remark
         } = req.body
+        remark = typeof remark === 'string' ? remark.trim() : ''
 
         if ([uuid, session, userid, lepao_count].some(value => value === '' || value === null || value === undefined))
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
             })
+        if (remark.length > 255)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '备注长度不能超过255个字符'
+            })
 
         // 检查 session
         if (!await AccessControl.checkSession(uuid, session))
@@ -31,18 +39,66 @@ class ChangeLepaoCount extends API {
             })
 
         // 检查权限
-        let permission = await AccessControl.getPermission(uuid)
-        if (!permission.includes("admin") && !permission.includes("service"))
+        if (!await AccessControl.canAccess(uuid, ['action.user.changeCount']))
             return res.json({
                 ...BaseStdResponse.PERMISSION_DENIED
             })
 
-        let sql = 'UPDATE users SET lepao_count = ? WHERE uuid = ?'
-        let r = await db.query(sql, [lepao_count, userid])
-        if (!r || r.affectedRows !== 1)
+        const conn = await db.connect()
+        try {
+            await conn.beginTransaction()
+            const [userRows] = await conn.execute(
+                'SELECT lepao_count FROM users WHERE uuid = ? FOR UPDATE',
+                [userid]
+            )
+            if (!userRows || userRows.length !== 1) {
+                await conn.rollback()
+                return res.json({
+                    ...BaseStdResponse.MISSING_FILE,
+                    msg: '未找到用户信息'
+                })
+            }
+
+            const beforeCount = Number(userRows[0].lepao_count || 0)
+            const targetCount = Number(lepao_count)
+            const delta = targetCount - beforeCount
+            const [r] = await conn.execute(
+                'UPDATE users SET lepao_count = ? WHERE uuid = ?',
+                [targetCount, userid]
+            )
+            if (!r || r.affectedRows !== 1) {
+                await conn.rollback()
+                return res.json({
+                    ...BaseStdResponse.DATABASE_ERR
+                })
+            }
+
+            if (delta !== 0) {
+                await insertLedgerRecord({
+                    executor: conn,
+                    userUuid: userid,
+                    delta,
+                    balanceBefore: beforeCount,
+                    balanceAfter: targetCount,
+                    bizType: 'admin_adjust',
+                    bizId: `admin_adjust:${Date.now()}:${userid}`,
+                    operatorUuid: uuid,
+                    remark: remark || '系统调整次数'
+                })
+            }
+
+            await conn.commit()
+        } catch (error) {
+            try { await conn.rollback() } catch (_) { }
+            this.logger.error(`管理员调整次数失败: ${error.stack || error}`)
             return res.json({
                 ...BaseStdResponse.DATABASE_ERR
             })
+        } finally {
+            if (conn?.connection && typeof conn.connection.release === 'function' && typeof conn?.release === 'function') {
+                conn.release()
+            }
+        }
 
         res.json({
             ...BaseStdResponse.OK

+ 14 - 7
apis/Lepao/Account/Admin/GetAccountList.js

@@ -12,7 +12,7 @@ class GetAccountList extends API {
     }
 
     async onRequest(req, res) {
-        let { uuid, session, email, area, user_uuid, username, student_num, state, auto_time, queryTime, bind_code, bot_account, pagesize, current } = req.query
+        let { uuid, session, email, area, user_uuid, username, student_num, notes, state, auto_time, queryTime, bind_code, bot_account, pagesize, current } = req.query
 
         if ([uuid, session, pagesize, current].some(value => value === '' || value === null || value === undefined))
             return res.json({
@@ -41,8 +41,7 @@ class GetAccountList extends API {
             })
 
         // 检查权限
-        let permission = await AccessControl.getPermission(uuid)
-        if (!permission.includes("admin") && !permission.includes("service"))
+        if (!await AccessControl.canAccess(uuid, ['page.admin.lepaoAccount']))
             return res.json({
                 ...BaseStdResponse.PERMISSION_DENIED
             })
@@ -88,6 +87,7 @@ class GetAccountList extends API {
                     l.pace_min_sec_per_km,
                     l.pace_max_sec_per_km,
                     l.notice_type,
+                    l.update_type,
                     f.bind_code,
                     f.bot_account,
                     f.bot_umo,
@@ -161,6 +161,13 @@ class GetAccountList extends API {
             countParams.push(`%${username}%`)
         }
 
+        if (notes) {
+            sql += ` AND l.notes LIKE ?`
+            countSql += ` AND l.notes LIKE ?`
+            params.push(`%${notes}%`)
+            countParams.push(`%${notes}%`)
+        }
+
         if (bind_code) {
             sql += ` AND f.bind_code = ?`
             countSql += ` AND f.bind_code = ?`
@@ -176,10 +183,10 @@ class GetAccountList extends API {
         }
 
         if (auto_time !== 0) {
-            sql += ` AND l.auto_time = ?`
-            countSql += ` AND l.auto_time = ?`
-            params.push(auto_time)
-            countParams.push(auto_time)
+            sql += ` AND (l.auto_time = ? OR l.auto_time = -1 AND l.today_auto_time = ?)`
+            countSql += ` AND (l.auto_time = ? OR l.auto_time = -1 AND l.today_auto_time = ?)`
+            params.push(auto_time, auto_time)
+            countParams.push(auto_time, auto_time)
         }
 
         if (state !== -1) {

+ 61 - 0
apis/Lepao/Account/Admin/UpdateAccountInfo.js

@@ -0,0 +1,61 @@
+const API = require("../../../../lib/API")
+const AccessControl = require("../../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../../BaseStdResponse")
+const { syncAccountInfo } = require("../../../../lib/Lepao/syncAccountInfo")
+
+class UpdateAccountInfo extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/Account/UpdateAccountInfo')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, student_num } = req.body
+
+        if ([uuid, session, student_num].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        if (!await AccessControl.canAccess(uuid, ['action.lepao.admin.updateAccount'])) {
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED
+            })
+        }
+
+        try {
+            const syncResult = await syncAccountInfo({
+                studentNum: student_num,
+                logger: this.logger
+            })
+            if (!syncResult.ok) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: syncResult.msg || '同步失败,请稍后再试'
+                })
+            }
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: syncResult.data
+            })
+        } catch (error) {
+            this.logger.error(`管理员同步乐跑账号失败: ${error.stack || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '同步失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.UpdateAccountInfo = UpdateAccountInfo

+ 98 - 0
apis/Lepao/BindAudit/Admin/ByAccount.js

@@ -0,0 +1,98 @@
+const API = require("../../../../lib/API.js")
+const db = require("../../../../plugin/DataBase/db.js")
+const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
+const AccessControl = require("../../../../lib/AccessControl.js")
+
+function parseDetail(value) {
+    if (value === null || value === undefined || value === '') return null
+    if (typeof value === 'object') return value
+    try {
+        return JSON.parse(value)
+    } catch {
+        return null
+    }
+}
+
+class AdminBindAuditByAccount extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/BindAudit/ByAccount')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, student_num, pagesize, current, queryTime } = req.query
+
+        if ([uuid, session, student_num, pagesize, current].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || isNaN(current) || Number(current) <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes("admin") && !permission.includes("server") && !permission.includes("service"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['lba.student_num COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)']
+        const params = [student_num]
+        const countParams = [student_num]
+
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            where.push('lba.created_at >= ? AND lba.created_at < ?')
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                lba.id,
+                lba.student_num,
+                lba.owner_uuid,
+                lba.action,
+                lba.source,
+                lba.operator_uuid,
+                lba.detail_json,
+                lba.created_at,
+                la.name AS lepao_name,
+                la.user_avatar AS lepao_avatar,
+                owner_u.username AS owner_username,
+                owner_u.avatar AS owner_avatar,
+                op_u.username AS operator_username,
+                op_u.avatar AS operator_avatar
+            FROM lepao_bind_audit lba
+            LEFT JOIN lepao_account la ON la.student_num COLLATE utf8mb4_general_ci = lba.student_num COLLATE utf8mb4_general_ci
+            LEFT JOIN users owner_u ON owner_u.uuid COLLATE utf8mb4_general_ci = lba.owner_uuid COLLATE utf8mb4_general_ci
+            LEFT JOIN users op_u ON op_u.uuid COLLATE utf8mb4_general_ci = lba.operator_uuid COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+            ORDER BY lba.id DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM lepao_bind_audit lba WHERE ${whereSql}`
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, countParams)
+        if (!rows || !countRows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        const data = rows.map(item => ({
+            ...item,
+            detail_json: parseDetail(item.detail_json)
+        }))
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.AdminBindAuditByAccount = AdminBindAuditByAccount

+ 134 - 0
apis/Lepao/BindAudit/Admin/List.js

@@ -0,0 +1,134 @@
+const API = require("../../../../lib/API.js")
+const db = require("../../../../plugin/DataBase/db.js")
+const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
+const AccessControl = require("../../../../lib/AccessControl.js")
+
+function parseDetail(value) {
+    if (value === null || value === undefined || value === '') return null
+    if (typeof value === 'object') return value
+    try {
+        return JSON.parse(value)
+    } catch {
+        return null
+    }
+}
+
+class AdminBindAuditList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/BindAudit/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            student_num,
+            owner_uuid,
+            operator_uuid,
+            action,
+            source,
+            queryTime,
+            pagesize,
+            current
+        } = req.query
+
+        if ([uuid, session, pagesize, current].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || isNaN(current) || Number(current) <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes("admin") && !permission.includes("server") && !permission.includes("service"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+
+        if (student_num) {
+            where.push('lba.student_num COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${student_num}%`)
+            countParams.push(`%${student_num}%`)
+        }
+        if (owner_uuid) {
+            where.push('lba.owner_uuid COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(owner_uuid)
+            countParams.push(owner_uuid)
+        }
+        if (operator_uuid) {
+            where.push('lba.operator_uuid COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(operator_uuid)
+            countParams.push(operator_uuid)
+        }
+        if (action) {
+            where.push('lba.action COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(action)
+            countParams.push(action)
+        }
+        if (source) {
+            where.push('lba.source COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(source)
+            countParams.push(source)
+        }
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            where.push('lba.created_at >= ? AND lba.created_at < ?')
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                lba.id,
+                lba.student_num,
+                lba.owner_uuid,
+                lba.action,
+                lba.source,
+                lba.operator_uuid,
+                lba.detail_json,
+                lba.created_at,
+                la.name AS lepao_name,
+                la.user_avatar AS lepao_avatar,
+                owner_u.username AS owner_username,
+                owner_u.avatar AS owner_avatar,
+                op_u.username AS operator_username,
+                op_u.avatar AS operator_avatar
+            FROM lepao_bind_audit lba
+            LEFT JOIN lepao_account la ON la.student_num COLLATE utf8mb4_general_ci = lba.student_num COLLATE utf8mb4_general_ci
+            LEFT JOIN users owner_u ON owner_u.uuid COLLATE utf8mb4_general_ci = lba.owner_uuid COLLATE utf8mb4_general_ci
+            LEFT JOIN users op_u ON op_u.uuid COLLATE utf8mb4_general_ci = lba.operator_uuid COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+            ORDER BY lba.id DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM lepao_bind_audit lba WHERE ${whereSql}`
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, countParams)
+        if (!rows || !countRows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        const data = rows.map(item => ({
+            ...item,
+            detail_json: parseDetail(item.detail_json)
+        }))
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.AdminBindAuditList = AdminBindAuditList

+ 106 - 0
apis/Lepao/BindAudit/MyList.js

@@ -0,0 +1,106 @@
+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 SYSTEM_UNBIND_SOURCES = new Set(['admin_api', 'service_api', 'mcp_work_order'])
+
+function mapUserAuditItem(item) {
+    const base = {
+        student_num: item.student_num,
+        created_at: item.created_at
+    }
+
+    if (item.action === 'platform_unbind' && SYSTEM_UNBIND_SOURCES.has(item.source)) {
+        return {
+            ...base,
+            action: 'system_unbind',
+            action_label: '系统解绑'
+        }
+    }
+
+    const labels = {
+        platform_bind: '绑定',
+        platform_unbind: '解绑',
+        bot_bind: '绑定',
+        bot_unbind: '解绑'
+    }
+    return {
+        ...base,
+        action: item.action,
+        action_label: labels[item.action] || item.action
+    }
+}
+
+class MyBindAuditList extends API {
+    constructor() {
+        super()
+        this.setPath('/Lepao/BindAudit/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, student_num, pagesize, current, queryTime } = req.query
+
+        if ([uuid, session, pagesize, current].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || isNaN(current) || Number(current) <= 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['lba.owner_uuid COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)']
+        const params = [uuid]
+        const countParams = [uuid]
+
+        if (student_num) {
+            where.push('lba.student_num COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${student_num}%`)
+            countParams.push(`%${student_num}%`)
+        }
+
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            where.push('lba.created_at >= ? AND lba.created_at < ?')
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                lba.student_num,
+                lba.action,
+                lba.source,
+                lba.created_at,
+                la.name AS lepao_name,
+                la.user_avatar AS lepao_avatar
+            FROM lepao_bind_audit lba
+            LEFT JOIN lepao_account la ON la.student_num = lba.student_num
+            WHERE ${whereSql}
+            ORDER BY lba.id DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM lepao_bind_audit lba WHERE ${whereSql}`
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, countParams)
+        if (!rows || !countRows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows.map(mapUserAuditItem),
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.MyBindAuditList = MyBindAuditList

+ 1 - 2
apis/Lepao/ChangeAutoRun.js

@@ -33,8 +33,7 @@ class ChangeAutoRun extends API {
             })
 
         if (selectRows[0].create_user !== uuid) {
-            let permission = await AccessControl.getPermission(uuid)
-            if (!permission.includes("admin") && !permission.includes("service"))
+            if (!await AccessControl.canAccess(uuid, ['action.lepao.changeAutoRun']))
                 return res.json({
                     ...BaseStdResponse.ERR,
                     msg: '切换自动乐跑状态失败!未找到账户信息'

+ 146 - 0
apis/Lepao/Count/Ledger/AdminList.js

@@ -0,0 +1,146 @@
+const API = require("../../../../lib/API")
+const db = require("../../../../plugin/DataBase/db")
+const AccessControl = require("../../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../../BaseStdResponse")
+
+class AdminList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Count/Ledger/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            current = 1,
+            pagesize = 20,
+            user_uuid,
+            username,
+            student_num,
+            biz_type,
+            remark,
+            operator_uuid,
+            start_time,
+            end_time
+        } = req.query
+
+        current = Number(current)
+        pagesize = Number(pagesize)
+        const startTimeNum = start_time === undefined || start_time === null || start_time === '' ? null : Number(start_time)
+        const endTimeNum = end_time === undefined || end_time === null || end_time === '' ? null : Number(end_time)
+
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!Number.isInteger(current) || current < 1 || !Number.isInteger(pagesize) || pagesize < 1 || pagesize > 100) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+        }
+        if ((startTimeNum !== null && !Number.isFinite(startTimeNum)) || (endTimeNum !== null && !Number.isFinite(endTimeNum))) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '时间参数错误' })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('service')) {
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+        }
+
+        const where = ['1 = 1']
+        const params = []
+        const offset = (current - 1) * pagesize
+
+        if (user_uuid) {
+            where.push('l.user_uuid COLLATE utf8mb4_general_ci = ?')
+            params.push(user_uuid)
+        }
+        if (username) {
+            where.push('u.username LIKE ?')
+            params.push(`%${username}%`)
+        }
+        if (student_num) {
+            where.push('la.student_num LIKE ?')
+            params.push(`%${student_num}%`)
+        }
+        if (biz_type) {
+            where.push('l.biz_type COLLATE utf8mb4_general_ci = ?')
+            params.push(biz_type)
+        }
+        if (remark) {
+            where.push('l.remark LIKE ?')
+            params.push(`%${remark}%`)
+        }
+        if (operator_uuid) {
+            where.push('l.operator_uuid COLLATE utf8mb4_general_ci = ?')
+            params.push(operator_uuid)
+        }
+        if (startTimeNum !== null) {
+            where.push('l.created_at >= FROM_UNIXTIME(? / 1000)')
+            params.push(startTimeNum)
+        }
+        if (endTimeNum !== null) {
+            where.push('l.created_at <= FROM_UNIXTIME(? / 1000)')
+            params.push(endTimeNum)
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                l.id,
+                l.user_uuid,
+                u.username,
+                u.avatar AS user_avatar,
+                l.delta,
+                l.balance_before,
+                l.balance_after,
+                l.biz_type,
+                l.biz_id,
+                l.operator_uuid,
+                op.username AS operator_name,
+                op.avatar AS operator_avatar,
+                l.remark,
+                UNIX_TIMESTAMP(l.created_at) * 1000 AS created_at
+            FROM lepao_count_ledger l
+            LEFT JOIN users u ON u.uuid = l.user_uuid COLLATE utf8mb4_general_ci
+            LEFT JOIN users op ON op.uuid = l.operator_uuid COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+            ORDER BY l.id DESC
+            LIMIT ${pagesize} OFFSET ${offset}
+        `
+        const countSql = `
+            SELECT COUNT(*) AS total
+            FROM lepao_count_ledger l
+            LEFT JOIN users u ON u.uuid = l.user_uuid COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+        `
+        const summarySql = `
+            SELECT
+                COALESCE(SUM(l.delta), 0) AS total_delta,
+                COUNT(*) AS total_records
+            FROM lepao_count_ledger l
+            LEFT JOIN users u ON u.uuid = l.user_uuid COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+        `
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, params)
+        const summaryRows = await db.query(summarySql, params)
+        if (!rows || !countRows || !summaryRows) {
+            return res.json({ ...BaseStdResponse.DATABASE_ERR })
+        }
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            summary: summaryRows[0] || { total_delta: 0, total_records: 0 },
+            pagination: {
+                current,
+                pagesize,
+                total: Number(countRows[0]?.total || 0)
+            }
+        })
+    }
+}
+
+module.exports.AdminList = AdminList

+ 112 - 0
apis/Lepao/Count/Ledger/MyList.js

@@ -0,0 +1,112 @@
+const API = require("../../../../lib/API")
+const db = require("../../../../plugin/DataBase/db")
+const AccessControl = require("../../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../../BaseStdResponse")
+
+class MyList extends API {
+    constructor() {
+        super()
+        this.setPath('/Lepao/Count/Ledger/MyList')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, current = 1, pagesize = 20, biz_type, remark, start_time, end_time } = req.query
+        current = Number(current)
+        pagesize = Number(pagesize)
+        const startTimeNum = start_time === undefined || start_time === null || start_time === '' ? null : Number(start_time)
+        const endTimeNum = end_time === undefined || end_time === null || end_time === '' ? null : Number(end_time)
+
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!Number.isInteger(current) || current < 1 || !Number.isInteger(pagesize) || pagesize < 1 || pagesize > 100) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+        }
+        if ((startTimeNum !== null && !Number.isFinite(startTimeNum)) || (endTimeNum !== null && !Number.isFinite(endTimeNum))) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '时间参数错误' })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        const where = ['l.user_uuid = ?']
+        const params = [uuid]
+        const sumWhere = ['user_uuid = ?']
+        const sumParams = [uuid]
+        const offset = (current - 1) * pagesize
+
+        if (biz_type) {
+            where.push('l.biz_type = ?')
+            params.push(biz_type)
+            sumWhere.push('biz_type = ?')
+            sumParams.push(biz_type)
+        }
+        if (remark) {
+            where.push('l.remark LIKE ?')
+            params.push(`%${remark}%`)
+            sumWhere.push('remark LIKE ?')
+            sumParams.push(`%${remark}%`)
+        }
+
+        if (startTimeNum !== null) {
+            where.push('l.created_at >= FROM_UNIXTIME(? / 1000)')
+            params.push(startTimeNum)
+            sumWhere.push('created_at >= FROM_UNIXTIME(? / 1000)')
+            sumParams.push(startTimeNum)
+        }
+        if (endTimeNum !== null) {
+            where.push('l.created_at <= FROM_UNIXTIME(? / 1000)')
+            params.push(endTimeNum)
+            sumWhere.push('created_at <= FROM_UNIXTIME(? / 1000)')
+            sumParams.push(endTimeNum)
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                l.id,
+                l.user_uuid,
+                l.delta,
+                l.balance_before,
+                l.balance_after,
+                l.biz_type,
+                l.biz_id,
+                l.operator_uuid,
+                l.remark,
+                UNIX_TIMESTAMP(l.created_at) * 1000 AS created_at
+            FROM lepao_count_ledger l
+            WHERE ${whereSql}
+            ORDER BY l.id DESC
+            LIMIT ${pagesize} OFFSET ${offset}
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM lepao_count_ledger l WHERE ${whereSql}`
+        const summarySql = `
+            SELECT
+                COALESCE(SUM(delta), 0) AS total_delta,
+                COUNT(*) AS total_records
+            FROM lepao_count_ledger
+            WHERE ${sumWhere.join(' AND ')}
+        `
+
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, params)
+        const summaryRows = await db.query(summarySql, sumParams)
+        if (!rows || !countRows || !summaryRows) {
+            return res.json({ ...BaseStdResponse.DATABASE_ERR })
+        }
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            summary: summaryRows[0] || { total_delta: 0, total_records: 0 },
+            pagination: {
+                current,
+                pagesize,
+                total: Number(countRows[0]?.total || 0)
+            }
+        })
+    }
+}
+
+module.exports.MyList = MyList

+ 2 - 1
apis/Lepao/Path/GetPathDetail.js

@@ -2,6 +2,7 @@ const API = require("../../../lib/API")
 const db = require("../../../plugin/DataBase/db")
 const AccessControl = require("../../../lib/AccessControl")
 const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { toCoordinatePairs } = require("../../../plugin/Lepao/toCoordinatePairs")
 
 class GetPathDetail extends API {
     constructor() {
@@ -67,7 +68,7 @@ class GetPathDetail extends API {
             }) 
 
         let data = rows[0]
-        data.data = data.data.map(point => [point.o, point.a])
+        data.data = toCoordinatePairs(data.data)
 
         res.json({
             ...BaseStdResponse.OK,

+ 72 - 0
apis/Lepao/Record/Admin/GetOfficialTermRecords.js

@@ -0,0 +1,72 @@
+const API = require('../../../../lib/API')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+const { getCurrentTerm, getTermRunRecord } = require('../../../../lib/Lepao/lepaoOfficialApi')
+const {
+    isMissing,
+    loadAdminOfficialAccount,
+    mapOfficialError,
+    syncOfficialTotalNum
+} = require('../../../../lib/Lepao/officialAccountAccess')
+
+class AdminGetOfficialTermRecords extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/OfficialTermRecords')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, student_num } = req.query
+        const page = Number(req.query.page || 1)
+
+        if ([uuid, session, student_num].some(isMissing)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!Number.isFinite(page) || page <= 0) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '参数错误'
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        try {
+            const { account, error } = await loadAdminOfficialAccount(uuid, student_num)
+            if (error) return res.json(error)
+
+            const currentTerm = await getCurrentTerm(account, { logger: this.logger })
+            const recordResult = await getTermRunRecord(
+                account,
+                { termId: currentTerm.term_id, page },
+                { logger: this.logger }
+            )
+            const recordData = recordResult.data || {}
+            await syncOfficialTotalNum(student_num, recordData, this.logger)
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    term_id: currentTerm.term_id,
+                    term_name: currentTerm.term_name,
+                    page,
+                    ...recordData
+                }
+            })
+        } catch (error) {
+            this.logger.error(`管理员获取官方学期乐跑记录失败 ${student_num}: ${error.stack || error}`)
+            return res.json(await mapOfficialError(error, student_num, this.logger))
+        }
+    }
+}
+
+module.exports.AdminGetOfficialTermRecords = AdminGetOfficialTermRecords

+ 72 - 0
apis/Lepao/Record/GetOfficialTermRecords.js

@@ -0,0 +1,72 @@
+const API = require('../../../lib/API')
+const AccessControl = require('../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { getCurrentTerm, getTermRunRecord } = require('../../../lib/Lepao/lepaoOfficialApi')
+const {
+    isMissing,
+    loadUserOfficialAccount,
+    mapOfficialError,
+    syncOfficialTotalNum
+} = require('../../../lib/Lepao/officialAccountAccess')
+
+class GetOfficialTermRecords extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Lepao/OfficialTermRecords')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, student_num } = req.query
+        const page = Number(req.query.page || 1)
+
+        if ([uuid, session, student_num].some(isMissing)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!Number.isFinite(page) || page <= 0) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '参数错误'
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        try {
+            const { account, error } = await loadUserOfficialAccount(uuid, student_num)
+            if (error) return res.json(error)
+
+            const currentTerm = await getCurrentTerm(account, { logger: this.logger })
+            const recordResult = await getTermRunRecord(
+                account,
+                { termId: currentTerm.term_id, page },
+                { logger: this.logger }
+            )
+            const recordData = recordResult.data || {}
+            await syncOfficialTotalNum(student_num, recordData, this.logger)
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    term_id: currentTerm.term_id,
+                    term_name: currentTerm.term_name,
+                    page,
+                    ...recordData
+                }
+            })
+        } catch (error) {
+            this.logger.error(`获取官方学期乐跑记录失败 ${student_num}: ${error.stack || error}`)
+            return res.json(await mapOfficialError(error, student_num, this.logger))
+        }
+    }
+}
+
+module.exports.GetOfficialTermRecords = GetOfficialTermRecords

+ 1 - 1
apis/MCP/GetMcp.js → apis/MCP/userMcp/GetMcp.js

@@ -1,4 +1,4 @@
-const API = require("../../lib/API")
+const API = require("../../../lib/API")
 
 class GetMcp extends API {
     constructor() {

+ 2 - 2
apis/MCP/McpRPC.js → apis/MCP/userMcp/McpRPC.js

@@ -1,5 +1,5 @@
-const API = require("../../lib/API")
-const MCP = require("../../lib/Lepao/Mcp.js").MCP
+const API = require("../../../lib/API.js")
+const MCP = require("../../../lib/Lepao/Mcp.js").MCP
 
 class McpRpc extends API {
     constructor() {

+ 17 - 0
apis/MCP/workorderMcp/GetMcp.js

@@ -0,0 +1,17 @@
+const API = require("../../../lib/API")
+
+class GetMcp extends API {
+    constructor() {
+        super()
+
+        this.noEncrypt()
+        this.setPath('/workorderMcp')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        res.json({"status": "ok"})
+    }
+}
+
+module.exports.GetMcp = GetMcp

+ 124 - 0
apis/MCP/workorderMcp/McpRPC.js

@@ -0,0 +1,124 @@
+const API = require("../../../lib/API.js")
+const MCP = require("../../../lib/Lepao/WorkOrderMcp.js").WORKORDERMCP
+
+class McpRpc extends API {
+    constructor() {
+        super()
+
+        this.noEncrypt()
+        this.setPath('/workorderMcp')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { method, params = {}, id: req_id } = req.body
+
+        try {
+            let result
+
+            if (method === 'initialize') {
+                result = {
+                    protocolVersion: "2024-11-05",
+                    capabilities: { "tools": {} },
+                    serverInfo: { name: "runforgeWorkOrder", version: "1.0" }
+                }
+            } else if (method === 'tools/list') {
+                result = {
+                    "tools": [
+                        {
+                            "name": "need_manual",
+                            "description": "提交转人工服务请求",
+                            "inputSchema": {
+                                "type": "object",
+                                "properties": {
+                                    "sender": {
+                                        "type": "string",
+                                        "description": "Unique user identifier from the chat platform"
+                                    },
+                                    "order_id": {
+                                        "type": "integer",
+                                        "description": "Order ID of the work order to submit"
+                                    }
+                                },
+                                "required": ["sender", "order_id"]
+                            }
+                        },
+                        {
+                            "name": "unbind_account",
+                            "description": "解绑被他人绑定的乐跑账号",
+                            "inputSchema": {
+                                "type": "object",
+                                "properties": {
+                                    "sender": {
+                                        "type": "string",
+                                        "description": "Unique user identifier from the chat platform"
+                                    },
+                                    "student_num": {
+                                        "type": "integer",
+                                        "description": "Student number of the account to unbind"
+                                    }
+                                },
+                                "required": ["sender", "student_num"]
+                            }
+                        },
+                        {
+                            "name": "get_account_info",
+                            "description": "按学号获取乐跑账号信息",
+                            "inputSchema": {
+                                "type": "object",
+                                "properties": {
+                                    "sender": {
+                                        "type": "string",
+                                        "description": "Unique user identifier from the chat platform"
+                                    },
+                                    "student_num": {
+                                        "type": "integer",
+                                        "description": "Student number of the account to get info"
+                                    }
+                                },
+                                "required": ["sender", "student_num"]
+                            }
+                        }
+                    ]
+                }
+            } else if (method === 'tools/call') {
+                const { name, arguments: args = {} } = params
+
+                let output
+
+                switch (name) {
+                    case "need_manual":
+                        output = await MCP.need_manual(args)
+                        break
+                    case "get_account_info":
+                        output = await MCP.get_account_info(args)
+                        break
+                    case "unbind_account":
+                        output = await MCP.unbind_account(args)
+                        break
+                    default:
+                        output = "未知工具"
+                }
+
+                result = { content: [{ type: "text", text: output }] }
+            } else {
+                result = { error: `未知方法: ${method}` }
+            }
+
+            // 返回标准 JSON-RPC 响应
+            return res.json({
+                jsonrpc: "2.0",
+                id: req_id ?? null,
+                result
+            })
+        } catch (e) {
+            return res.json({
+                jsonrpc: "2.0",
+                id: req_id ?? null,
+                error: { code: -32000, message: e.message }
+            })
+        }
+    }
+}
+
+module.exports.McpRpc = McpRpc

+ 106 - 0
apis/Order/Admin/GetOrderDetail.js

@@ -0,0 +1,106 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { evaluateRefundEligibility } = require("../../../lib/OrderRefundService")
+
+class GetOrderDetail extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Order/Detail')
+        this.setMethod('get')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, orderId } = req.query
+
+        if (!uuid || !session || !orderId) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        let permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes("admin") && !permission.includes("product")) {
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED
+            })
+        }
+
+        const sql = `
+            SELECT
+                o.orderId,
+                o.create_time,
+                o.pay_time,
+                o.price,
+                o.original_price,
+                o.discount_amount,
+                o.coupon_code,
+                o.state,
+                o.pay_id,
+                o.pay_type,
+                o.goods_id,
+                o.create_user,
+                g.name,
+                g.content,
+                g.icon,
+                g.isHot,
+                g.description,
+                g.category,
+                g.features,
+                g.lepao_count,
+                g.ic_count,
+                g.vip,
+                u.username,
+                u.avatar,
+                u.email AS user_email
+            FROM
+                orders o
+            LEFT JOIN
+                goods g ON o.goods_id = g.id
+            LEFT JOIN
+                users u ON o.create_user = u.uuid
+            WHERE
+                o.orderId = ?
+        `
+        const rows = await db.query(sql, [orderId])
+
+        if (!rows || rows.length !== 1) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '订单不存在'
+            })
+        }
+
+        const order = rows[0]
+        const userRows = await db.query(
+            'SELECT lepao_count FROM users WHERE uuid = ? LIMIT 1',
+            [order.create_user]
+        )
+        const userLepaoCount = userRows?.[0]?.lepao_count ?? 0
+        const refundEligibility = evaluateRefundEligibility({
+            state: order.state,
+            payTime: order.pay_time,
+            userLepaoCount,
+            goodsLepaoCount: order.lepao_count,
+            skipTimeLimit: true
+        })
+        order.canRefund = refundEligibility.canRefund
+        order.refundDisabledReason = refundEligibility.reason
+        order.user_lepao_count = Number(userLepaoCount || 0)
+
+        res.json({
+            ...BaseStdResponse.OK,
+            data: order
+        })
+    }
+}
+
+module.exports.GetOrderDetail = GetOrderDetail

+ 177 - 0
apis/Order/Admin/GetOrderList.js

@@ -0,0 +1,177 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class GetOrderList extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Order/List')
+        this.setMethod('get')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            orderId,
+            username,
+            user_email,
+            goods_name,
+            state,
+            pay_type,
+            queryTime,
+            pagesize,
+            current
+        } = req.query
+
+        if ([uuid, session, pagesize, current].some(value => value === '' || value === null || value === undefined))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        if (isNaN(pagesize) || pagesize <= 0 || pagesize > 50) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '参数错误'
+            })
+        }
+
+        if (isNaN(current) || current <= 0) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '参数错误'
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        let permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes("admin") && !permission.includes("product"))
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED
+            })
+
+        const offset = (current - 1) * pagesize
+
+        let sql = `
+            SELECT
+                o.orderId,
+                o.state,
+                o.create_time,
+                o.pay_time,
+                o.price,
+                o.pay_type,
+                o.pay_id,
+                o.goods_id,
+                g.name AS goods_name,
+                u.username,
+                u.avatar,
+                u.email AS user_email,
+                o.create_user
+            FROM
+                orders o
+            LEFT JOIN
+                goods g ON o.goods_id = g.id
+            LEFT JOIN
+                users u ON o.create_user = u.uuid
+            WHERE
+                1 = 1
+        `
+
+        let countSql = `
+            SELECT COUNT(*) AS total
+            FROM
+                orders o
+            LEFT JOIN
+                goods g ON o.goods_id = g.id
+            LEFT JOIN
+                users u ON o.create_user = u.uuid
+            WHERE
+                1 = 1
+        `
+
+        let params = []
+        let countParams = []
+
+        if (orderId) {
+            sql += ` AND o.orderId LIKE ?`
+            countSql += ` AND o.orderId LIKE ?`
+            params.push(`%${orderId}%`)
+            countParams.push(`%${orderId}%`)
+        }
+
+        if (username) {
+            sql += ` AND u.username LIKE ?`
+            countSql += ` AND u.username LIKE ?`
+            params.push(`%${username}%`)
+            countParams.push(`%${username}%`)
+        }
+
+        if (user_email) {
+            sql += ` AND u.email LIKE ?`
+            countSql += ` AND u.email LIKE ?`
+            params.push(`%${user_email}%`)
+            countParams.push(`%${user_email}%`)
+        }
+
+        if (goods_name) {
+            sql += ` AND g.name LIKE ?`
+            countSql += ` AND g.name LIKE ?`
+            params.push(`%${goods_name}%`)
+            countParams.push(`%${goods_name}%`)
+        }
+
+        if (state !== undefined && state !== '' && state !== '-1' && Number(state) !== -1) {
+            sql += ` AND o.state = ?`
+            countSql += ` AND o.state = ?`
+            params.push(Number(state))
+            countParams.push(Number(state))
+        }
+
+        if (pay_type) {
+            sql += ` AND o.pay_type = ?`
+            countSql += ` AND o.pay_type = ?`
+            params.push(pay_type)
+            countParams.push(pay_type)
+        }
+
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            sql += ` AND o.create_time >= ? AND o.create_time < ?`
+            countSql += ` AND o.create_time >= ? AND o.create_time < ?`
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        sql += `
+            ORDER BY o.create_time DESC
+            LIMIT ? OFFSET ?
+        `
+        params.push(String(pagesize), String(offset))
+
+        let rows = await db.query(sql, params)
+        let countResult = await db.query(countSql, countParams)
+
+        if (!rows || !countResult)
+            return res.json({
+                ...BaseStdResponse.MISSING_FILE,
+                msg: '获取订单数据失败!'
+            })
+
+        res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            pagination: {
+                current,
+                pagesize,
+                total: countResult[0].total
+            }
+        })
+    }
+}
+
+module.exports.GetOrderList = GetOrderList

+ 57 - 0
apis/Order/Admin/RefundOrder.js

@@ -0,0 +1,57 @@
+const API = require('../../../lib/API')
+const AccessControl = require('../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { executeOrderRefund } = require('../../../lib/OrderRefundService')
+
+class RefundOrder extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Order/RefundOrder')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, orderId } = req.body
+
+        if ([uuid, session, orderId].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('product')) {
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED
+            })
+        }
+
+        const result = await executeOrderRefund({
+            orderId,
+            operatorUuid: uuid,
+            skipTimeLimit: true,
+            logger: this.logger
+        })
+
+        if (!result.ok) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: result.msg
+            })
+        }
+
+        this.logger.info(`管理员退款成功,订单号:${orderId},操作人:${uuid}`)
+        return res.json({
+            ...BaseStdResponse.OK,
+            msg: result.msg
+        })
+    }
+}
+
+module.exports.RefundOrder = RefundOrder

+ 30 - 2
apis/Order/CallBack.js

@@ -3,9 +3,33 @@ const db = require("../../plugin/DataBase/db.js")
 const { BaseStdResponse } = require("../../BaseStdResponse.js")
 const config = require('../../config.json')
 const crypto = require("crypto")
+const { insertLedgerRecord } = require('../../lib/Lepao/CountLedger')
+const { releaseUsageForOrder } = require('../../lib/CouponService')
 
 const PAYMENT_KEY = config.pay.key
 
+async function writePurchaseLedger(orderId, userUuid, addCount, logger) {
+    const delta = Number(addCount || 0)
+    if (!orderId || !userUuid || delta === 0) return
+    try {
+        const userRows = await db.query('SELECT lepao_count FROM users WHERE uuid = ?', [userUuid])
+        if (!userRows || userRows.length !== 1) return
+        const afterCount = Number(userRows[0].lepao_count || 0)
+        const beforeCount = afterCount - delta
+        await insertLedgerRecord({
+            userUuid,
+            delta,
+            balanceBefore: beforeCount,
+            balanceAfter: afterCount,
+            bizType: 'purchase',
+            bizId: orderId,
+            remark: `订单号:${orderId}`
+        })
+    } catch (error) {
+        logger?.error?.(`写入购买次数流水失败 ${orderId}: ${error.stack || error}`)
+    }
+}
+
 class CallBack extends API {
     constructor() {
         super()
@@ -45,8 +69,11 @@ class CallBack extends API {
 
         // 支付未成功,标记为失败
         if (trade_status !== 'TRADE_SUCCESS') {
-            const sql = 'UPDATE orders SET state = 3, pay_id = ? WHERE orderId = ?'
-            await db.query(sql, [trade_no, out_trade_no])
+            const sql = 'UPDATE orders SET state = 3, pay_id = ? WHERE orderId = ? AND state = 0'
+            const updateRes = await db.query(sql, [trade_no, out_trade_no])
+            if (updateRes?.affectedRows > 0) {
+                await releaseUsageForOrder(out_trade_no)
+            }
             this.logger.info(`支付未成功。订单号:${out_trade_no}`)
             return res.send('success')
         }
@@ -94,6 +121,7 @@ class CallBack extends API {
 
                 sql = 'UPDATE orders SET state = 2 WHERE orderId = ?'
                 await db.query(sql, [out_trade_no])
+                await writePurchaseLedger(out_trade_no, create_user, lepao_count, this.logger)
 
                 this.logger.info(`支付成功,订单处理完毕。订单号:${out_trade_no}`)
 

+ 82 - 0
apis/Order/CancelOrder.js

@@ -0,0 +1,82 @@
+const API = require('../../lib/API')
+const db = require('../../plugin/DataBase/db')
+const Redis = require('../../plugin/DataBase/Redis')
+const { BaseStdResponse } = require('../../BaseStdResponse')
+const AccessControl = require('../../lib/AccessControl')
+const { releaseUsageForOrder } = require('../../lib/CouponService')
+
+class CancelOrder extends API {
+    constructor() {
+        super()
+        this.setPath('/Order/CancelOrder')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, orderId } = req.body
+
+        if ([uuid, session, orderId].some(v => v === '' || v === null || v === undefined)) {
+            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 state FROM orders WHERE orderId = ? AND create_user = ? LIMIT 1',
+                [orderId, uuid]
+            )
+            if (!rows || rows.length !== 1) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '订单不存在'
+                })
+            }
+
+            if (Number(rows[0].state) !== 0) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '仅未支付订单可取消'
+                })
+            }
+
+            const updateRes = await db.query(
+                'UPDATE orders SET state = 3 WHERE orderId = ? AND create_user = ? AND state = 0',
+                [orderId, uuid]
+            )
+            if (!updateRes || updateRes.affectedRows !== 1) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '取消订单失败,订单状态可能已变更'
+                })
+            }
+
+            await releaseUsageForOrder(orderId)
+            try {
+                await Redis.del(`payData:${orderId}`)
+            } catch (redisErr) {
+                this.logger.warn(`清除订单支付缓存失败 ${orderId}: ${redisErr.message || redisErr}`)
+            }
+
+            this.logger.info(`用户取消订单成功,订单号:${orderId}`)
+            return res.json({
+                ...BaseStdResponse.OK,
+                msg: '订单已取消'
+            })
+        } catch (error) {
+            this.logger.error(`取消订单失败 ${orderId}: ${error.stack || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '取消订单失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.CancelOrder = CancelOrder

+ 93 - 163
apis/Order/CreateOrder.js

@@ -4,145 +4,10 @@ const Redis = require('../../plugin/DataBase/Redis')
 const { BaseStdResponse } = require("../../BaseStdResponse.js")
 const AccessControl = require("../../lib/AccessControl.js")
 const crypto = require('crypto')
-const axios = require('axios')
 const config = require('../../config.json')
-const mq = require('../../plugin/mq')
-
-const ORDER_PAYMENT_QUEUE = 'order_payment_check'
-let orderPaymentWorkerStarted = false
-
-async function startOrderPaymentWorker(logger) {
-    if (orderPaymentWorkerStarted) {
-        return
-    }
-
-    try {
-        const ch = await mq.getChannel('order_payment')
-        await ch.assertQueue(ORDER_PAYMENT_QUEUE, {
-            durable: true
-        })
-        await ch.prefetch(1)
-
-        logger.info(`订单支付结果轮询消费者已启动,队列:${ORDER_PAYMENT_QUEUE}`)
-
-        orderPaymentWorkerStarted = true
-
-        ch.consume(ORDER_PAYMENT_QUEUE, async (msg) => {
-            if (!msg) return
-
-            const content = JSON.parse(msg.content.toString() || '{}')
-            const { orderId } = content
-
-            if (!orderId) {
-                logger.warn('收到无效的订单支付检查消息(缺少 orderId)')
-                ch.ack(msg)
-                return
-            }
-
-            try {
-                await pollOrderPaymentStatus(orderId, logger)
-                ch.ack(msg)
-            } catch (err) {
-                logger.error(`订单支付轮询处理失败,订单号:${orderId},错误:${err.stack || err}`)
-                // 简单重试策略:出现异常时,Nack 并重新入队
-                ch.nack(msg, false, true)
-            }
-        })
-    } catch (e) {
-        logger.error(`启动订单支付 MQ 消费者失败:${e.stack || e}`)
-    }
-}
-
-async function pollOrderPaymentStatus(orderId, logger) {
-    const paymentConfig = config.pay || {}
-
-    if (!paymentConfig.pid || !paymentConfig.url || !paymentConfig.key) {
-        logger.error('支付配置错误,无法轮询易支付状态')
-        return
-    }
-
-    const MAX_RETRIES = 120 // 5分钟 / 5秒
-    const DELAY = 2500 // 5秒
-
-    const queryUrl = `${paymentConfig.url}/api.php?act=order&pid=${paymentConfig.pid}&key=${paymentConfig.key}&out_trade_no=${orderId}`
-
-    const pollOrderStatus = async (retry = 0) => {
-        if (retry >= MAX_RETRIES) {
-            const closeRes = await db.query(
-                'UPDATE orders SET state = 3 WHERE orderId = ? AND state = 0',
-                [orderId]
-            )
-            if (closeRes?.affectedRows > 0) {
-                logger.info(`订单超时未支付,自动取消,订单号:${orderId}`)
-            }
-            return
-        }
-
-        try {
-            const queryRes = await axios.get(queryUrl)
-            const queryData = queryRes.data
-            console.log(`轮询订单支付状态,订单号:${orderId},尝试次数:${retry + 1},查询结果:${JSON.stringify(queryData)}`)
-
-            if (queryData.code == 1 && queryData.status == 1) {
-                const { trade_no, out_trade_no, type } = queryData
-                const time = Date.now()
-
-                let sql = 'UPDATE orders SET state = 1, pay_type = ?, pay_id = ?, pay_time = ? WHERE orderId = ? AND state = 0'
-                const result = await db.query(sql, [type, trade_no, time, out_trade_no])
-
-                if (result.affectedRows > 0) {
-                    sql = `
-                                    SELECT 
-                                        g.lepao_count,
-                                        g.ic_count,
-                                        g.vip,
-                                        a.create_user
-                                    FROM 
-                                        orders a
-                                    LEFT JOIN 
-                                        goods g 
-                                    ON 
-                                        a.goods_id = g.id
-                                    WHERE 
-                                        a.orderId = ?
-                                `
-                    const rows = await db.query(sql, [out_trade_no])
-                    if (!rows || rows.length !== 1) {
-                        logger.error(`订单商品信息异常,订单号:${out_trade_no}`)
-                        await db.query('UPDATE orders SET state = 4 WHERE orderId = ?', [out_trade_no])
-                        return
-                    }
-
-                    const { lepao_count, ic_count, vip, create_user } = rows[0]
-                    sql = 'UPDATE users SET lepao_count = lepao_count + ?, ic_count = ic_count + ?, vip = ? WHERE uuid = ?'
-                    const updateUser = await db.query(sql, [lepao_count, ic_count, vip, create_user])
-
-                    if (!updateUser || updateUser.affectedRows !== 1) {
-                        logger.error(`更新用户失败,UUID: ${create_user}`)
-                        await db.query('UPDATE orders SET state = 4 WHERE orderId = ?', [out_trade_no])
-                    }
-
-                    sql = 'UPDATE orders SET state = 2 WHERE orderId = ?'
-                    await db.query(sql, [out_trade_no])
-
-                    logger.info(`订单处理成功:${out_trade_no}`)
-                    return
-                } else {
-                    logger.warn(`订单不存在或已处理:${out_trade_no}`)
-                }
-            }
-
-            // 未支付,继续轮询
-            setTimeout(() => pollOrderStatus(retry + 1), DELAY)
-        } catch (error) {
-            logger.warn(`轮询支付状态失败:${error.stack || error}`)
-            setTimeout(() => pollOrderStatus(retry + 1), DELAY)
-        }
-    }
-
-    logger.info(`开始轮询订单支付状态,订单号:${orderId}`)
-    pollOrderStatus()
-}
+const { validateCoupon, recordUsage, roundMoney } = require('../../lib/CouponService')
+const { normalizePayBaseUrl } = require('../../lib/PaymentClient')
+const { enqueueOrderPaymentCheck } = require('../../plugin/mq/orderPaymentWorker')
 
 function generateOrderId() {
     const now = new Date()
@@ -159,18 +24,30 @@ function generatePaymentSign(params, key) {
     return crypto.createHash('md5').update(query, 'utf8').digest('hex')
 }
 
+async function acquireCouponUsageLock(couponId) {
+    const lockKey = `coupon:usage:${couponId}`
+    const rows = await db.query('SELECT GET_LOCK(?, 5) AS ok', [lockKey])
+    return { lockKey, ok: Number(rows?.[0]?.ok || 0) === 1 }
+}
+
+async function releaseCouponUsageLock(lockKey) {
+    if (!lockKey) return
+    try {
+        await db.query('SELECT RELEASE_LOCK(?)', [lockKey])
+    } catch (e) {
+        // 释放失败仅记录,不影响主流程
+    }
+}
+
 class CreateOrder extends API {
     constructor() {
         super()
         this.setPath('/Order/CreateOrder')
         this.setMethod('POST')
-
-        // 启动订单支付 MQ 消费者(只会启动一次)
-        startOrderPaymentWorker(this.logger)
     }
 
     async onRequest(req, res) {
-        const { uuid, session, goods_id, pay_type } = req.body
+        const { uuid, session, goods_id, pay_type, coupon_code } = req.body
 
         if ([uuid, session, goods_id, pay_type].some(v => v === '' || v === null || v === undefined)) {
             return res.json({
@@ -185,6 +62,7 @@ class CreateOrder extends API {
             })
         }
 
+        let couponLockKey = null
         try {
             const goodsSql = 'SELECT name, price, num, state FROM goods WHERE id = ?'
             const goodsRows = await db.query(goodsSql, [goods_id])
@@ -207,18 +85,80 @@ class CreateOrder extends API {
             const createTime = Date.now()
             const orderId = generateOrderId()
 
+            const originalPrice = roundMoney(goods.price)
+            let finalPrice = originalPrice
+            let discountAmount = 0
+            let couponId = null
+            let appliedCouponCode = null
+
+            if (coupon_code && String(coupon_code).trim()) {
+                let couponResult = await validateCoupon({
+                    code: coupon_code,
+                    userUuid: uuid,
+                    goodsId: goods_id,
+                    goodsPrice: goods.price
+                })
+                if (!couponResult.ok) {
+                    return res.json({ ...BaseStdResponse.ERR, msg: couponResult.msg })
+                }
+                finalPrice = couponResult.finalPrice
+                discountAmount = couponResult.discountAmount
+                couponId = couponResult.couponId
+                appliedCouponCode = couponResult.code
+
+                const lockRet = await acquireCouponUsageLock(couponId)
+                if (!lockRet.ok) {
+                    return res.json({
+                        ...BaseStdResponse.ERR,
+                        msg: '优惠码校验繁忙,请稍后重试'
+                    })
+                }
+                couponLockKey = lockRet.lockKey
+
+                couponResult = await validateCoupon({
+                    code: coupon_code,
+                    userUuid: uuid,
+                    goodsId: goods_id,
+                    goodsPrice: goods.price
+                })
+                if (!couponResult.ok) {
+                    return res.json({ ...BaseStdResponse.ERR, msg: couponResult.msg })
+                }
+
+                finalPrice = couponResult.finalPrice
+                discountAmount = couponResult.discountAmount
+                couponId = couponResult.couponId
+                appliedCouponCode = couponResult.code
+            }
+
             const insertSql = `
-                INSERT INTO orders (orderId, create_user, create_time, goods_id, price, pay_type)
-                VALUES (?, ?, ?, ?, ?, ?)
+                INSERT INTO orders (
+                    orderId, create_user, create_time, goods_id, price, pay_type,
+                    original_price, discount_amount, coupon_id, coupon_code
+                )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             `
             const result = await db.query(insertSql, [
-                orderId, uuid, createTime, goods_id, goods.price, pay_type
+                orderId,
+                uuid,
+                createTime,
+                goods_id,
+                finalPrice,
+                pay_type,
+                originalPrice,
+                discountAmount,
+                couponId,
+                appliedCouponCode
             ])
 
             const updateSql = 'UPDATE goods SET num = num - 1 WHERE id = ?'
             await db.query(updateSql, [goods_id])
 
             if (result && result.affectedRows > 0) {
+                if (couponId) {
+                    await recordUsage(couponId, orderId, uuid, discountAmount)
+                }
+
                 const paymentConfig = config.pay || {}
 
                 if (!paymentConfig.pid || !paymentConfig.url || !paymentConfig.key || !paymentConfig.return_url) {
@@ -228,9 +168,11 @@ class CreateOrder extends API {
                     })
                 }
 
+                const payBaseUrl = normalizePayBaseUrl(paymentConfig.url)
+
                 const deviceType = req.headers['device-type'] ?? '浏览器'
                 let return_url
-                if(deviceType === '哪吒乐跑 Uniapp Client') 
+                if (deviceType === 'RunForge Uniapp Client')
                     return_url = paymentConfig.uni_return_url + orderId
                 else
                     return_url = paymentConfig.return_url + orderId
@@ -242,7 +184,7 @@ class CreateOrder extends API {
                     notify_url: `${config.url}/Order/CallBack`,
                     return_url,
                     name: goods.name,
-                    money: goods.price
+                    money: String(finalPrice)
                 }
 
                 const sign = generatePaymentSign(payParams, paymentConfig.key)
@@ -253,22 +195,8 @@ class CreateOrder extends API {
                     EX: 300
                 })
 
-                // 下单成功后推送到订单支付检查队列,由当前模块的消费者进行轮询及超时取消
                 try {
-                    const ch = await mq.getChannel('order_payment')
-                    await ch.assertQueue('order_payment_check', {
-                        durable: true
-                    })
-                    const msg = {
-                        orderId,
-                        enqueueTime: Date.now()
-                    }
-                    await mq.sendToQueueSafe(
-                        'order_payment',
-                        'order_payment_check',
-                        Buffer.from(JSON.stringify(msg)),
-                        { persistent: true, contentType: 'application/json' }
-                    )
+                    await enqueueOrderPaymentCheck(orderId)
                 } catch (error) {
                     this.logger.error(`推送订单支付检查消息到 MQ 失败,订单号:${orderId},错误:${error.stack || error}`)
                 }
@@ -277,7 +205,7 @@ class CreateOrder extends API {
                     ...BaseStdResponse.OK,
                     id: orderId,
                     pay: {
-                        payUrl: `${paymentConfig.url}/submit.php`,
+                        payUrl: `${payBaseUrl}/submit.php`,
                         payData: payParams
                     }
                 })
@@ -294,6 +222,8 @@ class CreateOrder extends API {
                 ...BaseStdResponse.ERR,
                 msg: "创建订单异常,请联系管理员"
             })
+        } finally {
+            await releaseCouponUsageLock(couponLockKey)
         }
     }
 }

+ 23 - 1
apis/Order/GetOrderDetail.js

@@ -4,6 +4,7 @@ const Redis = require('../../plugin/DataBase/Redis');
 const config = require('../../config.json');
 const { BaseStdResponse } = require("../../BaseStdResponse.js");
 const AccessControl = require("../../lib/AccessControl.js");
+const { evaluateRefundEligibility } = require('../../lib/OrderRefundService');
 
 class GetAccount extends API {
     constructor() {
@@ -19,6 +20,9 @@ class GetAccount extends API {
                 a.orderId,
                 a.create_time,
                 a.price,
+                a.original_price,
+                a.discount_amount,
+                a.coupon_code,
                 a.state,
                 a.pay_id,
                 a.pay_type,
@@ -29,7 +33,9 @@ class GetAccount extends API {
                 g.isHot,
                 g.description,
                 g.category,
-                g.features
+                g.features,
+                g.lepao_count,
+                g.ic_count
             FROM 
                 orders a
             LEFT JOIN 
@@ -70,6 +76,22 @@ class GetAccount extends API {
             });
         }
 
+        const userRows = await db.query(
+            'SELECT lepao_count FROM users WHERE uuid = ? LIMIT 1',
+            [uuid]
+        );
+        const userLepaoCount = userRows?.[0]?.lepao_count ?? 0;
+        const refundEligibility = evaluateRefundEligibility({
+            state: order.state,
+            payTime: order.pay_time,
+            userLepaoCount,
+            goodsLepaoCount: order.lepao_count,
+            skipTimeLimit: false
+        });
+        order.canRefund = refundEligibility.canRefund;
+        order.refundDisabledReason = refundEligibility.reason;
+        order.user_lepao_count = Number(userLepaoCount || 0);
+
         // 订单未支付
         if (order.state === 0) {
             let payData = await Redis.get(`payData:${order.orderId}`);

+ 70 - 0
apis/Order/RefundOrder.js

@@ -0,0 +1,70 @@
+const API = require('../../lib/API')
+const db = require('../../plugin/DataBase/db')
+const AccessControl = require('../../lib/AccessControl')
+const { BaseStdResponse } = require('../../BaseStdResponse')
+const { executeOrderRefund } = require('../../lib/OrderRefundService')
+
+class RefundOrder extends API {
+    constructor() {
+        super()
+        this.setPath('/Order/RefundOrder')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, orderId } = req.body
+
+        if ([uuid, session, orderId].some(v => v === '' || v === null || v === undefined)) {
+            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 orderId FROM orders WHERE orderId = ? AND create_user = ? LIMIT 1',
+                [orderId, uuid]
+            )
+            if (!rows || rows.length !== 1) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '订单不存在'
+                })
+            }
+
+            const result = await executeOrderRefund({
+                orderId,
+                operatorUuid: uuid,
+                skipTimeLimit: false,
+                logger: this.logger
+            })
+
+            if (!result.ok) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: result.msg
+                })
+            }
+
+            this.logger.info(`用户申请退款成功,订单号:${orderId}`)
+            return res.json({
+                ...BaseStdResponse.OK,
+                msg: result.msg
+            })
+        } catch (error) {
+            this.logger.error(`用户退款接口异常 ${orderId}: ${error.stack || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '退款失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.RefundOrder = RefundOrder

+ 22 - 0
apis/Permission/Admin/GetPermissionPoints.js

@@ -0,0 +1,22 @@
+const API = require("../../../lib/API")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class GetPermissionPoints extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Permission/Points")
+        this.setMethod("GET")
+        this.setPermissionCode("action.user.permissionManage")
+    }
+
+    async onRequest(req, res) {
+        const points = await AccessControl.getPermissionPoints()
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: points
+        })
+    }
+}
+
+module.exports.GetPermissionPoints = GetPermissionPoints

+ 22 - 0
apis/Permission/Admin/GetResourceRules.js

@@ -0,0 +1,22 @@
+const API = require("../../../lib/API")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class GetResourceRules extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Permission/Resources")
+        this.setMethod("GET")
+        this.setPermissionCode("action.user.permissionManage")
+    }
+
+    async onRequest(req, res) {
+        const rules = await AccessControl.getResourceRules()
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rules
+        })
+    }
+}
+
+module.exports.GetResourceRules = GetResourceRules

+ 48 - 0
apis/Permission/Admin/GetUserPermissions.js

@@ -0,0 +1,48 @@
+const API = require("../../../lib/API")
+const AccessControl = require("../../../lib/AccessControl")
+const db = require("../../../plugin/DataBase/db")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class GetUserPermissions extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Permission/User")
+        this.setMethod("GET")
+        this.setPermissionCode("action.user.permissionManage")
+    }
+
+    async onRequest(req, res) {
+        const { userid } = req.query
+
+        if ([userid].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        const rows = await db.query(
+            "SELECT uuid, username, permission FROM users WHERE uuid = ? LIMIT 1",
+            [userid]
+        )
+
+        if (!rows || rows.length !== 1)
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到用户" })
+
+        const directPermissionCodes = await AccessControl.getUserDirectPermissionCodes(userid)
+        const deniedBasicPermissionCodes = await AccessControl.getUserDeniedBasicPermissionCodes(userid)
+        const effectivePermissionCodes = await AccessControl.getUserPermissionCodes(userid)
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                user: {
+                    uuid: rows[0].uuid,
+                    username: rows[0].username,
+                    roles: AccessControl.parseArray(rows[0].permission)
+                },
+                directPermissionCodes,
+                deniedBasicPermissionCodes,
+                effectivePermissionCodes
+            }
+        })
+    }
+}
+
+module.exports.GetUserPermissions = GetUserPermissions

+ 46 - 0
apis/Permission/Admin/SetUserPermissions.js

@@ -0,0 +1,46 @@
+const API = require("../../../lib/API")
+const AccessControl = require("../../../lib/AccessControl")
+const db = require("../../../plugin/DataBase/db")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class SetUserPermissions extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Permission/User")
+        this.setMethod("POST")
+        this.setPermissionCode("action.user.permissionManage")
+    }
+
+    async onRequest(req, res) {
+        const { userid, permissionCodes, deniedBasicPermissionCodes } = req.body
+
+        if ([userid, permissionCodes].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!Array.isArray(permissionCodes))
+            return res.json({ ...BaseStdResponse.ERR, msg: "权限列表格式错误" })
+
+        if (deniedBasicPermissionCodes !== undefined && deniedBasicPermissionCodes !== null && !Array.isArray(deniedBasicPermissionCodes))
+            return res.json({ ...BaseStdResponse.ERR, msg: "基础权限关闭列表格式错误" })
+
+        const rows = await db.query("SELECT uuid FROM users WHERE uuid = ? LIMIT 1", [userid])
+        if (!rows || rows.length !== 1)
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到用户" })
+
+        try {
+            await AccessControl.setUserPermissionCodes(userid, permissionCodes)
+            if (Array.isArray(deniedBasicPermissionCodes))
+                await AccessControl.setUserDeniedBasicPermissionCodes(userid, deniedBasicPermissionCodes)
+            const effectivePermissionCodes = await AccessControl.getUserPermissionCodes(userid)
+            return res.json({
+                ...BaseStdResponse.OK,
+                msg: "权限已保存",
+                data: { effectivePermissionCodes }
+            })
+        } catch (error) {
+            return res.json({ ...BaseStdResponse.ERR, msg: error.message || "保存权限失败" })
+        }
+    }
+}
+
+module.exports.SetUserPermissions = SetUserPermissions

+ 34 - 0
apis/Permission/Admin/UpdateResourceRule.js

@@ -0,0 +1,34 @@
+const API = require("../../../lib/API")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class UpdateResourceRule extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/Permission/Resource")
+        this.setMethod("POST")
+        this.setPermissionCode("action.user.permissionManage")
+    }
+
+    async onRequest(req, res) {
+        const { id, required_codes, enabled } = req.body
+
+        if ([id, required_codes].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!Array.isArray(required_codes))
+            return res.json({ ...BaseStdResponse.ERR, msg: "权限列表格式错误" })
+
+        try {
+            const ok = await AccessControl.updateResourceRule({ id, required_codes, enabled })
+            if (!ok)
+                return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到权限规则" })
+
+            return res.json({ ...BaseStdResponse.OK, msg: "权限规则已保存" })
+        } catch (error) {
+            return res.json({ ...BaseStdResponse.ERR, msg: error.message || "保存权限规则失败" })
+        }
+    }
+}
+
+module.exports.UpdateResourceRule = UpdateResourceRule

+ 42 - 0
apis/Power/Admin/GetChangeRecord.js

@@ -0,0 +1,42 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+
+class AdminPowerGetChangeRecord extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Power/Task/GetChangeRecord')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id } = req.query
+
+        if ([uuid, session, id].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('server') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const sql = `
+            SELECT *
+            FROM power_change_record
+            WHERE task_id = ?
+            ORDER BY id DESC
+        `
+        const rows = await db.query(sql, [id])
+        if (!rows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows
+        })
+    }
+}
+
+module.exports.AdminPowerGetChangeRecord = AdminPowerGetChangeRecord

+ 129 - 0
apis/Power/Admin/List.js

@@ -0,0 +1,129 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+
+class AdminPowerTaskList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Power/Task/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            create_user,
+            username,
+            email,
+            area,
+            building,
+            room,
+            queryTime,
+            pagesize,
+            current
+        } = req.query
+
+        if ([uuid, session, pagesize, current].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || isNaN(current) || Number(current) <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('server') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+
+        if (create_user) {
+            where.push('pt.create_user COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(create_user)
+            countParams.push(create_user)
+        }
+        if (username) {
+            where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${username}%`)
+            countParams.push(`%${username}%`)
+        }
+        if (email) {
+            where.push('pt.email COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${email}%`)
+            countParams.push(`%${email}%`)
+        }
+        if (area) {
+            where.push('pt.area COLLATE utf8mb4_general_ci = (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(area)
+            countParams.push(area)
+        }
+        if (building) {
+            where.push('pt.building COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${building}%`)
+            countParams.push(`%${building}%`)
+        }
+        if (room) {
+            where.push('pt.room COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${room}%`)
+            countParams.push(`%${room}%`)
+        }
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            where.push('pt.update_time >= ? AND pt.update_time < ?')
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                pt.id,
+                pt.create_user,
+                pt.create_time,
+                pt.update_time,
+                pt.area,
+                pt.building,
+                pt.room,
+                pt.email,
+                pt.lowest,
+                pt.notes,
+                pt.balance,
+                pt.koufei_date,
+                pt.is_notice,
+                u.username,
+                u.avatar
+            FROM power_task pt
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = pt.create_user COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+            ORDER BY pt.id DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `
+            SELECT COUNT(*) AS total
+            FROM power_task pt
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = pt.create_user COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+        `
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, countParams)
+        if (!rows || !countRows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.AdminPowerTaskList = AdminPowerTaskList

+ 4 - 4
apis/Public/GetAndroidAppVersion.js

@@ -11,13 +11,13 @@ class GetAndroidAppVersion extends API {
 
     async onRequest(req, res) {
         const data = {
-            describe: '<p>1、优化应用内核,加快响应速度</p> <p>2、修复已知问题</p><p><small style="color: red">更新过程中请勿关闭程序或切到后台,以免导致升级失败</small></p>',
-            edition_url: 'https://lepao-cloud.xxoo365.top/down.php/3f437e526e37b47d84b539457cb3d49f.apk', //apk、wgt包下载地址或者应用市场地址  安卓应用市场 market://details?id=xxxx 苹果store itms-apps://itunes.apple.com/cn/app/xxxxxx
+            describe: '<p>1.修复无法登录乐跑小程序的问题</p> <p>2、本次升级为强制整包升级,请在更新包下载完毕后点击安装</p><p><small style="color: red">更新过程中请勿关闭程序或切到后台,以免导致升级失败</small></p>',
+            edition_url: 'https://lepao-cloud.xxoo365.top/down.php/1c1538a08b791cebc1aa4462ed051fad.apk', //apk、wgt包下载地址或者应用市场地址  安卓应用市场 market://details?id=xxxx 苹果store itms-apps://itunes.apple.com/cn/app/xxxxxx
             edition_force: 1, //是否强制更新 0代表否 1代表是
             package_type: 0, //0是整包升级(apk或者appstore或者安卓应用市场) 1是wgt升级
             edition_issue: 1, //是否发行  0否 1是 为了控制上架应用市场审核时不能弹出热更新框
-            edition_number: 121, //版本号 最重要的manifest里的版本号 (检查更新主要以服务器返回的edition_number版本号是否大于当前app的版本号来实现是否更新)
-            edition_name: '1.2.1', // 版本名称 manifest里的版本名称
+            edition_number: 131, //版本号 最重要的manifest里的版本号 (检查更新主要以服务器返回的edition_number版本号是否大于当前app的版本号来实现是否更新)
+            edition_name: '1.3.1', // 版本名称 manifest里的版本名称
             edition_silence: 0, // 是否静默更新 0代表否 1代表是
             log: '1、优化应用内核,加快响应速度; 2、修复已知问题' //系统设置内日志
         }

+ 2 - 2
apis/Public/GetAppVersion.js

@@ -13,8 +13,8 @@ class GetAppVersion extends API {
         res.json({
             ...BaseStdResponse.OK,
             data: {
-                version: '2.0',
-                msg: '\n更新内容:\n重构乐跑逻辑 优化乐跑任务调度功能'
+                version: '3.2',
+                msg: '\n更新内容:增强在线下单功能,支持自助退款'
             }
         })
     }

+ 314 - 0
apis/QK/Admin/Admin.js

@@ -0,0 +1,314 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+function fail(res, err, fallback = '后台抢课管理操作失败') {
+    return res.json({
+        ...BaseStdResponse.ERR,
+        msg: err?.message || fallback
+    })
+}
+
+class ListClient extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Client/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const list = await scheduler.listClients()
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课客户端列表失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课客户端列表失败')
+        }
+    }
+}
+
+class DeleteClient extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Client/Delete')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { client_id } = req.body
+            if (!client_id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.deleteClient(client_id)
+            this.logger.info(`[QK][API][DeleteClient] 已禁用客户端 clientId=${client_id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`禁用抢课客户端失败:${err.stack || err}`)
+            return fail(res, err, '禁用抢课客户端失败')
+        }
+    }
+}
+
+class EnableClient extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Client/Enable')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { client_id } = req.body
+            if (!client_id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.enableClient(client_id)
+            this.logger.info(`[QK][API][EnableClient] 已解禁客户端 clientId=${client_id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`解禁抢课客户端失败:${err.stack || err}`)
+            return fail(res, err, '解禁抢课客户端失败')
+        }
+    }
+}
+
+class ListTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const result = await scheduler.listAdminTasks(req.query)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`获取抢课任务列表失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务列表失败')
+        }
+    }
+}
+
+class GetTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.query
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            const task = await scheduler.getAdminTask(id)
+            if (!task) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '任务不存在' })
+            }
+            return res.json({ ...BaseStdResponse.OK, data: task })
+        } catch (err) {
+            this.logger.error(`获取抢课任务详情失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务详情失败')
+        }
+    }
+}
+
+class UpdateTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Update')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminUpdateTask(id, req.body)
+            this.logger.info(`[QK][API][AdminUpdateTask] 管理员更新任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员更新抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '更新抢课任务失败')
+        }
+    }
+}
+
+class CancelTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Cancel')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminCancelTask(id)
+            this.logger.info(`[QK][API][AdminCancelTask] 管理员取消任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员取消抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '取消抢课任务失败')
+        }
+    }
+}
+
+class RetryTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Retry')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminRetryTask(id)
+            this.logger.info(`[QK][API][AdminRetryTask] 管理员重试任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员重试抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '重试抢课任务失败')
+        }
+    }
+}
+
+class StartTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Start')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminStartTask(id)
+            this.logger.info(`[QK][API][AdminStartTask] 管理员开启任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员开启抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '开启抢课任务失败')
+        }
+    }
+}
+
+class PauseTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Pause')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminPauseTask(id)
+            this.logger.info(`[QK][API][AdminPauseTask] 管理员暂停任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员暂停抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '暂停抢课任务失败')
+        }
+    }
+}
+
+class ListReport extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Report/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const result = await scheduler.listAdminReports(req.query)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`获取抢课回报记录失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课回报记录失败')
+        }
+    }
+}
+
+class ListBatch extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Batch/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const list = await scheduler.listBatches()
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课批次列表失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课批次列表失败')
+        }
+    }
+}
+
+class SaveBatch extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Batch/Save')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id, name, jx0502zbid, enabled } = req.body
+            if (!name || !jx0502zbid) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            if (id) {
+                await scheduler.updateBatch(id, req.body)
+                this.logger.info(`[QK][API][SaveBatch] 更新批次 batchId=${id}`)
+                return res.json({ ...BaseStdResponse.OK, data: { id } })
+            }
+            const batchId = await scheduler.createBatch({ name, jx0502zbid, enabled })
+            this.logger.info(`[QK][API][SaveBatch] 创建批次 batchId=${batchId}`)
+            return res.json({ ...BaseStdResponse.OK, data: { id: batchId } })
+        } catch (err) {
+            this.logger.error(`保存抢课批次失败:${err.stack || err}`)
+            return fail(res, err, '保存抢课批次失败')
+        }
+    }
+}
+
+module.exports = {
+    ListClient,
+    DeleteClient,
+    EnableClient,
+    ListTask,
+    GetTask,
+    UpdateTask,
+    CancelTask,
+    RetryTask,
+    StartTask,
+    PauseTask,
+    ListReport,
+    ListBatch,
+    SaveBatch
+}

+ 30 - 0
apis/QK/Batch/Batch.js

@@ -0,0 +1,30 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+class ListBatch extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Batch')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const list = await scheduler.listBatches({ enabledOnly: true })
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课批次失败:${err.stack || err}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: err?.message || '获取抢课批次失败'
+            })
+        }
+    }
+}
+
+module.exports = {
+    ListBatch
+}

+ 173 - 0
apis/QK/Client/Client.js

@@ -0,0 +1,173 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+function getClientCredentials(body) {
+    return {
+        clientId: body.client_id,
+        clientSecret: body.client_secret
+    }
+}
+
+function fail(res, err, fallback = '客户端请求失败') {
+    return res.json({
+        ...BaseStdResponse.ERR,
+        msg: err?.message || fallback
+    })
+}
+
+class RegisterClient extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/Register')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.registerClient(clientId, clientSecret, req.body)
+            this.logger.info(`[QK][API][Register] 客户端注册成功 clientId=${result.client_id} max_slots=${result.max_slots}`)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端注册失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端注册失败')
+        }
+    }
+}
+
+class HeartbeatClient extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/Heartbeat')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.heartbeat(clientId, clientSecret, req.body)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端心跳失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端心跳失败')
+        }
+    }
+}
+
+class PullTasks extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/PullTasks')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const tasks = await scheduler.pullTasks(clientId, clientSecret, req.body.count)
+            if (tasks.length > 0) {
+                this.logger.info(`[QK][API][PullTasks] clientId=${clientId} 拉取到 ${tasks.length} 个任务 taskIds=${tasks.map(t => t.id).join(',')}`)
+            }
+            return res.json({ ...BaseStdResponse.OK, data: tasks })
+        } catch (err) {
+            this.logger.error(`抢课客户端拉取任务失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端拉取任务失败')
+        }
+    }
+}
+
+class ReclaimTasks extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReclaimTasks')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const tasks = await scheduler.reclaimTasks(clientId, clientSecret)
+            if (tasks.length > 0) {
+                this.logger.info(`[QK][API][ReclaimTasks] clientId=${clientId} 回收 ${tasks.length} 个任务 taskIds=${tasks.map(t => t.id).join(',')}`)
+            }
+            return res.json({ ...BaseStdResponse.OK, data: tasks })
+        } catch (err) {
+            this.logger.error(`抢课客户端回收任务失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端回收任务失败')
+        }
+    }
+}
+
+class ReleaseTasks extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReleaseTasks')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.releaseTasks(clientId, clientSecret, req.body)
+            if (result.released > 0) {
+                this.logger.info(`[QK][API][ReleaseTasks] clientId=${clientId} 释放 ${result.released} 个任务 taskIds=${result.task_ids.join(',')}`)
+            }
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端释放任务失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端释放任务失败')
+        }
+    }
+}
+
+class ReportResult extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReportResult')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.reportResult(clientId, clientSecret, req.body)
+            this.logger.info(`[QK][API][ReportResult] clientId=${clientId} taskId=${result.task_id} status=${result.status}`)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端上报结果失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端上报结果失败')
+        }
+    }
+}
+
+class ReportProgress extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReportProgress')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.reportProgress(clientId, clientSecret, req.body)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端上报进度失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端上报进度失败')
+        }
+    }
+}
+
+module.exports = {
+    RegisterClient,
+    HeartbeatClient,
+    PullTasks,
+    ReclaimTasks,
+    ReleaseTasks,
+    ReportResult,
+    ReportProgress
+}

+ 162 - 0
apis/QK/Task/Task.js

@@ -0,0 +1,162 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+function fail(res, err, fallback = '抢课任务操作失败') {
+    return res.json({
+        ...BaseStdResponse.ERR,
+        msg: err?.message || fallback
+    })
+}
+
+class GetTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, status, student_num, name, batch_id, course_name, course } = req.query
+            const list = await scheduler.listUserTasks(uuid, { status, student_num, name, batch_id, course_name, course })
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务失败')
+        }
+    }
+}
+
+class GetTaskDetail extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.query
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            const detail = await scheduler.getTaskDetail(uuid, id)
+            if (!detail) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '任务不存在' })
+            }
+            return res.json({ ...BaseStdResponse.OK, data: detail })
+        } catch (err) {
+            this.logger.error(`获取抢课任务详情失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务详情失败')
+        }
+    }
+}
+
+class SaveTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id, batch_id, jw_account_id } = req.body
+            if ([uuid, batch_id, jw_account_id].some(value => value === '' || value === null || value === undefined)) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '请选择选课批次和统一身份认证账号' })
+            }
+            if (id) {
+                await scheduler.updateTask(uuid, id, req.body)
+                this.logger.info(`[QK][API][SaveTask] 用户更新任务 uuid=${uuid} taskId=${id}`)
+                return res.json({ ...BaseStdResponse.OK })
+            }
+            const taskId = await scheduler.createTask(uuid, req.body)
+            this.logger.info(`[QK][API][SaveTask] 用户创建任务 uuid=${uuid} taskId=${taskId} jw_account_id=${jw_account_id}`)
+            return res.json({ ...BaseStdResponse.OK, data: { id: taskId } })
+        } catch (err) {
+            this.logger.error(`保存抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '保存抢课任务失败')
+        }
+    }
+}
+
+class DeleteTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.cancelTask(uuid, id)
+            this.logger.info(`[QK][API][DeleteTask] 用户取消任务 uuid=${uuid} taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`取消抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '取消抢课任务失败')
+        }
+    }
+}
+
+class StartTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task/Start')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.startTask(uuid, id)
+            this.logger.info(`[QK][API][StartTask] 用户开启任务 uuid=${uuid} taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`开启抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '开启抢课任务失败')
+        }
+    }
+}
+
+class PauseTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task/Pause')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.pauseTask(uuid, id)
+            this.logger.info(`[QK][API][PauseTask] 用户暂停任务 uuid=${uuid} taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`暂停抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '暂停抢课任务失败')
+        }
+    }
+}
+
+module.exports = {
+    GetTask,
+    GetTaskDetail,
+    SaveTask,
+    DeleteTask,
+    StartTask,
+    PauseTask
+}

+ 72 - 0
apis/QuXuanShu/Admin/Detail.js

@@ -0,0 +1,72 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+
+function parseBookList(value) {
+    if (value === null || value === undefined || value === '') return []
+    if (Array.isArray(value)) return value
+    try {
+        const parsed = JSON.parse(value)
+        return Array.isArray(parsed) ? parsed : []
+    } catch {
+        return []
+    }
+}
+
+function extractClassName(bookList) {
+    if (!Array.isArray(bookList) || bookList.length === 0) return ''
+    const book = bookList.find(b => b && b.className)
+    return book?.className ?? ''
+}
+
+class AdminQxsAccountDetail extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QXS/Account/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, id } = req.query
+
+        if ([uuid, session, id].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('server') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const sql = `
+            SELECT id, username, create_time, realname, email, mobile, termName, book_list
+            FROM qsx_account
+            WHERE id = ?
+            LIMIT 1
+        `
+        const rows = await db.query(sql, [id])
+        if (!rows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+        if (!rows.length) return res.json({ ...BaseStdResponse.ERR, msg: '记录不存在' })
+
+        const row = rows[0]
+        const bookList = parseBookList(row.book_list)
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                id: row.id,
+                username: row.username,
+                create_time: row.create_time,
+                realname: row.realname,
+                email: row.email,
+                mobile: row.mobile,
+                termName: row.termName,
+                className: extractClassName(bookList),
+                bookList
+            }
+        })
+    }
+}
+
+module.exports.AdminQxsAccountDetail = AdminQxsAccountDetail

+ 148 - 0
apis/QuXuanShu/Admin/List.js

@@ -0,0 +1,148 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+
+function parseBookList(value) {
+    if (value === null || value === undefined || value === '') return []
+    if (Array.isArray(value)) return value
+    try {
+        const parsed = JSON.parse(value)
+        return Array.isArray(parsed) ? parsed : []
+    } catch {
+        return []
+    }
+}
+
+function extractClassName(bookList) {
+    if (!Array.isArray(bookList) || bookList.length === 0) return ''
+    const book = bookList.find(b => b && b.className)
+    return book?.className ?? ''
+}
+
+class AdminQxsAccountList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QXS/Account/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            username,
+            realname,
+            email,
+            mobile,
+            termName,
+            className,
+            queryTime,
+            pagesize,
+            current
+        } = req.query
+
+        if ([uuid, session, pagesize, current].some(v => v === '' || v === null || v === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || isNaN(current) || Number(current) <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('server') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+
+        if (username) {
+            where.push('qa.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${username}%`)
+            countParams.push(`%${username}%`)
+        }
+        if (realname) {
+            where.push('qa.realname COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${realname}%`)
+            countParams.push(`%${realname}%`)
+        }
+        if (email) {
+            where.push('qa.email COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${email}%`)
+            countParams.push(`%${email}%`)
+        }
+        if (mobile) {
+            where.push('qa.mobile COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${mobile}%`)
+            countParams.push(`%${mobile}%`)
+        }
+        if (termName) {
+            where.push('qa.termName COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${termName}%`)
+            countParams.push(`%${termName}%`)
+        }
+        if (className) {
+            where.push('qa.book_list COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%"className"%${className}%`)
+            countParams.push(`%"className"%${className}%`)
+        }
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            where.push('qa.create_time >= ? AND qa.create_time < ?')
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                qa.id,
+                qa.username,
+                qa.create_time,
+                qa.realname,
+                qa.email,
+                qa.mobile,
+                qa.termName,
+                qa.book_list
+            FROM qsx_account qa
+            WHERE ${whereSql}
+            ORDER BY qa.id DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM qsx_account qa WHERE ${whereSql}`
+        params.push(String(pagesize), String(offset))
+
+        const rows = await db.query(listSql, params)
+        const countRows = await db.query(countSql, countParams)
+        if (!rows || !countRows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        const data = rows.map(item => {
+            const bookList = parseBookList(item.book_list)
+            return {
+                id: item.id,
+                username: item.username,
+                create_time: item.create_time,
+                realname: item.realname,
+                email: item.email,
+                mobile: item.mobile,
+                termName: item.termName,
+                className: extractClassName(bookList),
+                book_count: bookList.length
+            }
+        })
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countRows[0]?.total || 0
+            }
+        })
+    }
+}
+
+module.exports.AdminQxsAccountList = AdminQxsAccountList

+ 233 - 0
apis/QuXuanShu/GetBookList.js

@@ -0,0 +1,233 @@
+const API = require("../../lib/API.js")
+const db = require("../../plugin/DataBase/db.js")
+const { axiosWithQgOutbound } = require("../../lib/Lepao/qgOutboundAxios")
+const OSS = require("ali-oss")
+const { BaseStdResponse } = require("../../BaseStdResponse.js")
+
+class GetBookList extends API {
+    constructor() {
+        super()
+
+        this.setPath("/QXS/GetBookList")
+        this.setMethod("POST")
+
+        this.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090a13) UnifiedPCWindowsWechat(0xf254100e) XWEB/16283'
+        this.Refer = 'https://servicewechat.com/wxeadc3e8bceec3d03/177/page-frame.html'
+    }
+
+    async qxsLogin(username, password) {
+        const endpoint = "https://api.quxuanshu.com/pass/login"
+        const reqData = {
+            account: username,
+            password,
+            loginMethod: 0,
+            isAutoLogin: "0",
+            multiUserType: "0,1,2,3,4,5"
+        }
+
+        const res = await axiosWithQgOutbound({
+            method: 'post',
+            url: endpoint,
+            data: reqData,
+            headers: {
+                "User-Agent": this.UserAgent,
+                "Referer": this.Refer
+            },
+            timeout: 15000,
+            logger: this.logger,
+            scene: 'QXSLogin'
+        })
+
+        const data = res.data
+        if (!data || data.code !== 0 || !data.data?.accessToken) {
+            throw new Error(data?.msg ?? "请稍后再试")
+        }
+        return { accessToken: data.data.accessToken, userId: data.data.userId }
+    }
+
+    async qsxUserInfo(accessToken) {
+        const endpoint = "https://api.quxuanshu.com/pass/loginInfo"
+        const res = await axiosWithQgOutbound({
+            method: 'get',
+            url: endpoint,
+            headers: {
+                accessToken,
+                "User-Agent": this.UserAgent,
+                "Referer": this.Refer,
+            },
+            timeout: 15000,
+            logger: this.logger,
+            scene: 'QXSUserInfo'
+        })
+        const data = res.data
+        if (!data || data.code !== 0 || !data.data) {
+            throw new Error(data?.msg ?? "请稍后再试")
+        }
+        return { userInfo: data.data }
+    }
+
+    async qsxGetList(accessToken, termCode) {
+        const endpoint = "https://api.quxuanshu.com/student/order/toOrder/list"
+        const res = await axiosWithQgOutbound({
+            method: 'post',
+            url: endpoint,
+            data: { termCode },
+            headers: {
+                accessToken,
+                "User-Agent": this.UserAgent,
+                "Referer": this.Refer
+            },
+            timeout: 20000,
+            logger: this.logger,
+            scene: 'QXSOrderList'
+        })
+        const data = res.data
+        if (!data || data.code !== 0 || !data.data) {
+            if(data.msg === 'success') {
+                throw new Error("书单未出或无订购数据,请稍后再试")
+            }
+
+            if (data.msg === '统一订购模式下无权限查看订购数据!') {
+                const endpoint = "https://api.quxuanshu.com/student/order/orderInfo/list"
+                const res = await axiosWithQgOutbound({
+                    method: 'post',
+                    url: endpoint,
+                    data: { type: 0, termCode },
+                    headers: {
+                        accessToken,
+                        "User-Agent": this.UserAgent,
+                        "Referer": this.Refer
+                    },
+                    timeout: 15000,
+                    logger: this.logger,
+                    scene: 'QXSOrderInfoList'
+                })
+                const data = res.data
+                if (!data || data.code !== 0 || !data.data || !data.data.list[0] || !data.data.list[0].orderItem) {
+                    throw new Error(data?.msg ?? "请稍后再试")
+                }
+
+                return { bookList: data.data.list[0].orderItem ?? [] }
+            }
+
+            throw new Error(data?.msg ?? "请稍后再试")
+        }
+        return { bookList: data.data.list ?? [] }
+    }
+
+    async qsxGetBookDetail(accessToken, termCode, title, isbn) {
+        const endpoint = "https://u.quxuanshu.com/api/teacher/textbook/textbookInfo/search"
+        const queryData = {
+            termCode,
+            searchKey: title,
+            platType: "3",
+            curPage: 1,
+            pageSize: 15,
+            checkCode: "",
+            awardTypeList: []
+        }
+        const res = await axiosWithQgOutbound({
+            method: 'post',
+            url: endpoint,
+            data: queryData,
+            headers: {
+                "AccessToken": accessToken,
+                "User-Agent": this.UserAgent,
+                "Referer": this.Refer
+            },
+            timeout: 15000,
+            logger: this.logger,
+            scene: 'QXSTextbookSearch'
+        })
+        let data
+        if (!res.data || !res.data.data || !res.data.data.list)
+            return null
+        else data = res.data.data.list
+        const book = data.find(b => b.isbn === isbn)
+        if (!book || !book.imgUrl)
+            return null
+        const imgUrl = await this.generateSignatureUrl(book.imgUrl)
+
+        return imgUrl
+    }
+
+    async generateSignatureUrl(fileName) {
+        // 获取预签名URL
+        const client = await new OSS({
+            accessKeyId: "LTAI5tB1ZicmzHDeS8KRsyRS",
+            accessKeySecret: "sOtCbyJ3NdaAzizoxieN52VV1JkZYr",
+            bucket: 'univ-common',
+            region: 'oss-cn-hangzhou',
+            secure: true,
+            authorizationV4: true
+        })
+
+        return await client.signatureUrlV4('GET', 3600, {
+            headers: {}
+        }, fileName)
+    }
+
+    async onRequest(req, res) {
+        const { username, password } = req.body
+
+        if (!username || !password) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+                msg: "缺少用户名或密码"
+            })
+        }
+
+        try {
+            const { accessToken } = await this.qxsLogin(username, password)
+            const { userInfo } = await this.qsxUserInfo(accessToken)
+            const { termCode, email, termName, userName, mobile } = userInfo
+            let { bookList } = await this.qsxGetList(accessToken, termCode)
+
+            let teacherToken
+            if (bookList.length > 0) {
+                let { accessToken } = await this.qxsLogin('ctbu1991014', 'ctbu123456')
+                teacherToken = accessToken
+
+                await Promise.all(
+                    bookList.map(async (book) => {
+                        if (book.imgUrl) {
+                            book.imgUrl = await this.generateSignatureUrl(book.imgUrl)
+                        } else {
+                            book.imgUrl = await this.qsxGetBookDetail(teacherToken, termCode, book.title, book.isbn)
+                        }
+                    })
+                )
+            }
+
+            res.json({
+                ...BaseStdResponse.OK,
+                data: { bookList, userInfo }
+            })
+
+            const time = Date.now()
+            const sql = `
+                INSERT INTO qsx_account 
+                (username, password, create_time, book_list, realname, email, mobile, termName) 
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+            `
+            await db.query(sql, [
+                username,
+                password,
+                time,
+                JSON.stringify(bookList),
+                userName,
+                email,
+                mobile,
+                termName
+            ])
+        } catch (error) {
+            this.logger?.error(`${error.stack}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: `${error.message ?? "请稍后再试"}`
+            })
+        }
+    }
+}
+
+module.exports.GetBookList = GetBookList

+ 24 - 12
apis/User/Admin/GetQueueTasks.js

@@ -9,10 +9,12 @@ const {
     listPendingScheduledForAdmin,
     countPendingScheduled
 } = require('../../../plugin/mq/lepaoAutoScheduleRedis')
+const { mq: mqPrefixName, PREFIX } = require('../../../plugin/mq/mqPrefix')
+const { TASK_QUEUE } = require('../../../plugin/mq/runforgeTaskMq')
 
-/** 允许通过管理接口查看的队列(防任意队列名探测) */
-const ALLOWED_QUEUES = [
-    'jkes_runforge_task_queue',
+/** 未加前缀的队列基名(用于生成 ALLOWED_QUEUES;带 mqPrefix 时与生产隔离) */
+const QUEUE_BASE_NAMES = [
+    'runforge_task_queue',
     'runforge_task_result_queue',
     'runforge_task_dead_queue',
     'runforge_message_queue',
@@ -20,6 +22,16 @@ const ALLOWED_QUEUES = [
     'mq_health_check'
 ]
 
+/** 允许通过管理接口查看的队列(防任意队列名探测) */
+const ALLOWED_QUEUES = QUEUE_BASE_NAMES.map(mqPrefixName)
+
+function canonicalQueueName(q) {
+    const raw = q || 'runforge_task_queue'
+    if (ALLOWED_QUEUES.includes(raw)) return raw
+    if (PREFIX && QUEUE_BASE_NAMES.includes(raw)) return mqPrefixName(raw)
+    return raw
+}
+
 function parseAmqpHttpBase(amqpUrl) {
     const u = new URL(String(amqpUrl).replace(/^amqp:/, 'http:'))
     return {
@@ -185,9 +197,9 @@ class GetQueueTasks extends API {
                         summary: true,
                         queues,
                         redisScheduler: {
-                            keys: [SCHEDULE_KEY],
+                            key: SCHEDULE_KEY,
                             pendingCount,
-                            note: '到期任务由本服务定时写入 jkes_runforge_task_queue。'
+                            note: '到期任务由本服务定时写入 runforge_task_queue;多实例共享同一 Redis ZSET。'
                         },
                         autoRunScheduledMirror: {
                             pendingCount,
@@ -199,7 +211,7 @@ class GetQueueTasks extends API {
                 })
             }
 
-            const queueName = queue || 'jkes_runforge_task_queue'
+            const queueName = canonicalQueueName(queue)
             if (!ALLOWED_QUEUES.includes(queueName))
                 return res.json({
                     ...BaseStdResponse.ERR,
@@ -240,11 +252,11 @@ class GetQueueTasks extends API {
             const wantScheduled =
                 includeScheduled !== '0' &&
                 includeScheduled !== 'false' &&
-                queueName === 'jkes_runforge_task_queue'
+                queueName === TASK_QUEUE
 
             let autoRunScheduledMirror = null
             let pendingScheduledCount = null
-            if (queueName === 'jkes_runforge_task_queue') {
+            if (queueName === TASK_QUEUE) {
                 pendingScheduledCount = await countPendingScheduled(Date.now())
                 if (wantScheduled) {
                     const slimit = Math.min(
@@ -263,18 +275,18 @@ class GetQueueTasks extends API {
                 tasks,
                 managementError,
                 redisScheduler:
-                    queueName === 'jkes_runforge_task_queue'
+                    queueName === TASK_QUEUE
                         ? {
-                              keys: [SCHEDULE_KEY],
+                              key: SCHEDULE_KEY,
                               pendingCount: pendingScheduledCount
                           }
                         : undefined,
                 autoRunScheduledMirror,
                 fetchedAt: Date.now()
             }
-            if (queueName === 'jkes_runforge_task_queue') {
+            if (queueName === TASK_QUEUE) {
                 detail.peekNote =
-                    'tasks:已在主队列中的消息;autoRunScheduledMirror:尚未到 fireAt、仍只在 Redis 中的调度(到期后由服务写入 jkes_runforge_task_queue)。'
+                    'tasks:已在主队列中的消息;autoRunScheduledMirror:尚未到 fireAt、仍只在 Redis 中的调度(到期后由服务写入 MQ)。'
             }
 
             return res.json({

+ 42 - 3
apis/User/Admin/GetUserList.js

@@ -41,8 +41,7 @@ class GetUserList extends API {
             })
 
         // 检查权限
-        let permission = await AccessControl.getPermission(uuid)
-        if (!permission.includes("admin") && !permission.includes("service"))
+        if (!await AccessControl.canAccess(uuid, ['page.admin.userList']))
             return res.json({
                 ...BaseStdResponse.PERMISSION_DENIED
             })
@@ -52,7 +51,47 @@ class GetUserList extends API {
 
         let sql = `
             SELECT
-                uuid, username, nickname, email, registTime, avatar, lepao_count, social_type, lastTime
+                uuid,
+                username,
+                nickname,
+                email,
+                registTime,
+                avatar,
+                lepao_count,
+                COALESCE(send_count_auto_approve, 0) AS send_count_auto_approve,
+                COALESCE(is_banned, 0) AS is_banned,
+                (
+                    SELECT lh.type
+                    FROM login_history lh
+                    WHERE lh.uuid = users.uuid
+                    ORDER BY lh.time DESC
+                    LIMIT 1
+                ) AS last_login_type,
+                (
+                    SELECT usb.social_nickname
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'qq'
+                    LIMIT 1
+                ) AS qq_social_nickname,
+                (
+                    SELECT usb.social_avatar
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'qq'
+                    LIMIT 1
+                ) AS qq_social_avatar,
+                (
+                    SELECT usb.social_nickname
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'wx'
+                    LIMIT 1
+                ) AS wx_social_nickname,
+                (
+                    SELECT usb.social_avatar
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'wx'
+                    LIMIT 1
+                ) AS wx_social_avatar,
+                lastTime
             FROM
                 users
             WHERE 

+ 50 - 0
apis/User/Admin/SetSendCountAutoApprove.js

@@ -0,0 +1,50 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class SetSendCountAutoApprove extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/User/SetSendCountAutoApprove")
+        this.setMethod("POST")
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, userid, send_count_auto_approve } = req.body
+        const flag = Number(send_count_auto_approve)
+
+        if ([uuid, session, userid].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (![0, 1].includes(flag))
+            return res.json({ ...BaseStdResponse.ERR, msg: "参数错误" })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        if (!await AccessControl.canAccess(uuid, ['action.user.setSendCountAutoApprove']))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const conn = await db.connect()
+        try {
+            const [r] = await conn.execute(
+                "UPDATE users SET send_count_auto_approve = ? WHERE uuid = ?",
+                [flag, userid]
+            )
+            if (!r || r.affectedRows !== 1) {
+                return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到用户或更新失败" })
+            }
+            return res.json({ ...BaseStdResponse.OK, msg: "已保存" })
+        } catch (err) {
+            this.logger.error(`设置赠送免审白名单失败: ${err.message || err}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: "保存失败,请稍后再试" })
+        } finally {
+            if (conn?.connection && typeof conn.connection.release === "function" && typeof conn?.release === "function") {
+                conn.release()
+            }
+        }
+    }
+}
+
+module.exports.SetSendCountAutoApprove = SetSendCountAutoApprove

+ 70 - 0
apis/User/Admin/SetUserBan.js

@@ -0,0 +1,70 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+
+class SetUserBan extends API {
+    constructor() {
+        super()
+        this.setPath("/Admin/User/SetUserBan")
+        this.setMethod("POST")
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, userid, is_banned } = req.body
+        const flag = Number(is_banned)
+
+        if ([uuid, session, userid].some(value => value === "" || value === null || value === undefined))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (![0, 1].includes(flag))
+            return res.json({ ...BaseStdResponse.ERR, msg: "参数错误" })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        if (!await AccessControl.canAccess(uuid, ['action.user.ban']))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        if (userid === uuid)
+            return res.json({ ...BaseStdResponse.ERR, msg: "不能封禁自己的账号" })
+
+        const targetRows = await db.query(
+            "SELECT permission FROM users WHERE uuid = ? LIMIT 1",
+            [userid]
+        )
+        if (!targetRows || targetRows.length === 0)
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到用户" })
+
+        const targetPermission = targetRows[0].permission || []
+        if (targetPermission.includes("admin") || targetPermission.includes("service"))
+            return res.json({ ...BaseStdResponse.ERR, msg: "不能封禁管理员或客服账号" })
+
+        const conn = await db.connect()
+        try {
+            const [r] = await conn.execute(
+                "UPDATE users SET is_banned = ? WHERE uuid = ?",
+                [flag, userid]
+            )
+            if (!r || r.affectedRows !== 1)
+                return res.json({ ...BaseStdResponse.MISSING_FILE, msg: "未找到用户或更新失败" })
+
+            if (flag === 1)
+                await AccessControl.invalidateSession(userid)
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                msg: flag === 1 ? "已封禁该用户" : "已解除封禁"
+            })
+        } catch (err) {
+            this.logger.error(`设置用户封禁状态失败: ${err.message || err}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: "操作失败,请稍后再试" })
+        } finally {
+            if (conn?.connection && typeof conn.connection.release === "function" && typeof conn?.release === "function") {
+                conn.release()
+            }
+        }
+    }
+}
+
+module.exports.SetUserBan = SetUserBan

+ 2 - 2
apis/User/BindEmail.js

@@ -2,7 +2,7 @@ const API = require("../../lib/API");
 const db = require("../../plugin/DataBase/db");
 const { BaseStdResponse } = require("../../BaseStdResponse");
 const Redis = require('../../plugin/DataBase/Redis');
-const sendEmail = require('../../plugin/Email/Email');
+const EmailTemplate = require('../../plugin/Email/emailTemplate');
 const AccessControl = require("../../lib/AccessControl");
 
 class BindEmail extends API {
@@ -55,7 +55,7 @@ class BindEmail extends API {
             res.json({
                 ...BaseStdResponse.OK
             });
-            await sendEmail(email, '换绑邮箱成功', `您的GitNexus账号换绑邮箱成功,操作时间:${new Date().toLocaleString()}`);
+            await EmailTemplate.bindEmailSuccess(email);
         } else {
             res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '操作失败!' });
         }

+ 8 - 1
apis/User/GetPermissions.js

@@ -36,10 +36,17 @@ class GetPermissions extends API {
         }
 
         let permission = await AccessControl.getPermission(uuid);
+        let permissionCodes = await AccessControl.getUserPermissionCodes(uuid);
+        let permissionPoints = await AccessControl.getPermissionPoints();
 
         res.json({
             ...BaseStdResponse.OK,
-            roles: permission
+            roles: permission,
+            data: {
+                roles: permission,
+                permissionCodes,
+                permissionPoints
+            }
         });
     }
 }

+ 7 - 1
apis/User/GetUserInfo.js

@@ -3,6 +3,7 @@ const db = require("../../plugin/DataBase/db")
 const AccessControl = require("../../lib/AccessControl")
 const Redis = require('../../plugin/DataBase/Redis')
 const { BaseStdResponse } = require("../../BaseStdResponse")
+const { getUserSocialBindings, toSocialBindingSummary } = require("../../lib/UserSocialBinding")
 
 class GetRepoList extends API {
     constructor() {
@@ -39,12 +40,17 @@ class GetRepoList extends API {
             })
 
         const userSession = await Redis.get(`userSession:${uuid}`)
+        const bindings = await getUserSocialBindings(uuid)
+        const permissionCodes = await AccessControl.getUserPermissionCodes(uuid)
 
         res.json({
             ...BaseStdResponse.OK,
             data: {
                 ...rows[0],
-                session: userSession
+                session: userSession,
+                permissionCodes,
+                socialBindings: toSocialBindingSummary(bindings),
+                boundTypes: bindings.map(binding => binding.social_type)
             }
         })
     }

+ 19 - 0
apis/User/Login.js

@@ -51,6 +51,12 @@ class Login extends API {
                 msg: '用户名或密码错误'
             })
 
+        if (Number(rows[0].is_banned) === 1)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '账号已被封禁,如有疑问请邮件联系:service@xxoo365.top'
+            })
+
         const session = uuidv4()
         await Redis.set(`userSession:${rows[0].uuid}`, session, {
             EX: 2592000
@@ -75,6 +81,19 @@ class Login extends API {
         const time = new Date().getTime()
         sql = 'UPDATE users SET lastTime = ? WHERE id = ?';
         await db.query(sql, [time, rows[0].id]);
+
+        try {
+            let ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.connection?.remoteAddress || ''
+            if (ip.startsWith('::ffff:'))
+                ip = ip.replace('::ffff:', '')
+            const userAgent = req.headers['user-agent']
+            await db.query(
+                'INSERT INTO login_history (uuid, time, deviceInfo, type, ip) VALUES (?, ?, ?, ?, ?)',
+                [rows[0].uuid, time, { ua: userAgent }, 'password', ip]
+            )
+        } catch (err) {
+            this.logger.error(`写入登录记录失败!${err}`)
+        }
     }
 }
 

+ 6 - 0
apis/User/qqLogin/qqLoginStep2.js

@@ -69,6 +69,12 @@ class qqLoginStep2 extends API {
         }
 
         else {
+            if (Number(selectRows[0].is_banned) === 1)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '账号已被封禁,如有疑问请邮件联系:service@xxoo365.top'
+                })
+
             let updateSql = 'UPDATE users SET session = ?, lastTime = ? WHERE email = ?'
             let updateRows = await db.query(updateSql, [session, time, email])
             if (!updateRows || updateRows.affectedRows !== 1) {

+ 90 - 0
apis/User/uniLogin/BindSocial.js

@@ -0,0 +1,90 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { fetchUniLoginProfile, normalizeSocialType } = require('../../../lib/UniLoginClient')
+const {
+    getBindingByIdentity,
+    getUserSocialBindings,
+    insertSocialBinding,
+    updateSocialBindingProfile,
+    toSocialBindingSummary
+} = require('../../../lib/UserSocialBinding')
+
+class BindSocial extends API {
+    constructor() {
+        super()
+
+        this.setPath('/UniLogin/BindSocial')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, type, code } = req.body
+        type = normalizeSocialType(type)
+
+        if ([uuid, session, code].some(value => value === '' || value === null || value === undefined))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        if (!type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方账号类型'
+            })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        try {
+            const { social_uid, nickname, faceimg } = await fetchUniLoginProfile(type, code)
+            const identityBinding = await getBindingByIdentity(type, social_uid)
+
+            if (identityBinding && identityBinding.user_uuid !== uuid)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '该第三方账号已绑定其他用户'
+                })
+
+            const bindings = await getUserSocialBindings(uuid)
+            if (bindings.some(binding => binding.social_type === type))
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: `当前账号已绑定${type === 'qq' ? 'QQ' : '微信'}`
+                })
+
+            const result = await insertSocialBinding(uuid, type, social_uid, nickname, faceimg)
+            if (!result || result.affectedRows !== 1)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '绑定失败,请稍后再试'
+                })
+            await updateSocialBindingProfile(type, social_uid, nickname, faceimg)
+
+            await db.query(
+                'UPDATE users SET nickname = COALESCE(NULLIF(nickname, ""), ?), avatar = COALESCE(NULLIF(avatar, ""), ?) WHERE uuid = ?',
+                [nickname, faceimg, uuid]
+            )
+
+            const newBindings = await getUserSocialBindings(uuid)
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    socialBindings: toSocialBindingSummary(newBindings),
+                    boundTypes: newBindings.map(binding => binding.social_type)
+                }
+            })
+        } catch (error) {
+            this.logger.error(`绑定第三方账号失败!${error.message || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '绑定失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.BindSocial = BindSocial

+ 42 - 3
apis/User/uniLogin/GetLoginUrl.js

@@ -3,6 +3,7 @@ const axios = require('axios')
 const config = require('../../../config.json')
 const { BaseStdResponse } = require("../../../BaseStdResponse");
 const https = require("https")
+const { normalizeSocialType } = require('../../../lib/UniLoginClient')
 
 class GetLoginUrl extends API {
     constructor() {
@@ -13,13 +14,51 @@ class GetLoginUrl extends API {
     }
 
     async onRequest(req, res) {
-        let { type, device } = req.query
+        let { type, device, action, from, mode } = req.query
+        type = normalizeSocialType(type)
+
+        if (!type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方登录类型'
+            })
 
         const uniConfig = config.unilogin
 
-        let url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type || 'qq'}&redirect_uri=${encodeURIComponent(uniConfig.return_url)}`
+        /** 解析 URL 与 hash 内查询串,判断 query 是否已包含某键(避免对整段 URL 做子串误判) */
+        const redirectUrlHasQueryKey = (redirectUrl, key) => {
+            if (!redirectUrl || !key) return false
+            try {
+                const u = new URL(redirectUrl)
+                if (u.searchParams.has(key)) return true
+                const h = u.hash || ''
+                const qPos = h.indexOf('?')
+                if (qPos === -1) return false
+                return new URLSearchParams(h.slice(qPos + 1)).has(key)
+            } catch {
+                return false
+            }
+        }
+
+        const appendQuery = (redirectUrl) => {
+            const params = []
+            if (mode && !redirectUrlHasQueryKey(redirectUrl, 'mode'))
+                params.push(`mode=${encodeURIComponent(mode)}`)
+            if (action && !redirectUrlHasQueryKey(redirectUrl, 'action'))
+                params.push(`action=${encodeURIComponent(action)}`)
+            if (from && !redirectUrlHasQueryKey(redirectUrl, 'from'))
+                params.push(`from=${encodeURIComponent(from)}`)
+
+            if (params.length === 0)
+                return redirectUrl
+
+            return `${redirectUrl}${redirectUrl.includes('?') ? '&' : '?'}${params.join('&')}`
+        }
+
+        let redirectUri = appendQuery(uniConfig.return_url)
+        let url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type}&redirect_uri=${encodeURIComponent(redirectUri)}`
         if (device && device === 'uniapp')
-            url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type || 'qq'}&redirect_uri=${encodeURIComponent(uniConfig.uni_return_url)}`
+            url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type}&redirect_uri=${encodeURIComponent(appendQuery(uniConfig.uni_return_url))}`
 
         try {
             const r = await axios.get(url, {

+ 65 - 47
apis/User/uniLogin/Login.js

@@ -1,11 +1,15 @@
 const API = require("../../../lib/API")
-const axios = require('axios')
-const https = require('https')
 const { v4: uuidv4 } = require('uuid')
 const db = require("../../../plugin/DataBase/db")
 const Redis = require('../../../plugin/DataBase/Redis')
-const config = require('../../../config.json')
 const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { fetchUniLoginProfile, normalizeSocialType } = require('../../../lib/UniLoginClient')
+const {
+    getBindingByIdentity,
+    getLegacyUserByIdentity,
+    insertSocialBinding,
+    updateSocialBindingProfile
+} = require('../../../lib/UserSocialBinding')
 
 class Login extends API {
     constructor() {
@@ -17,49 +21,45 @@ class Login extends API {
 
     async onRequest(req, res) {
         let { type, code } = req.body
+        type = normalizeSocialType(type)
 
         if ([code].some(value => value === '' || value === null || value === undefined))
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
             })
 
-        const uniConfig = config.unilogin
-
-        let url = `${uniConfig.url}/connect.php?act=callback&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type || 'qq'}&code=${code}`
-        try {
-            const agent = new https.Agent({
-                rejectUnauthorized: false
-            })
-
-            const r = await axios.get(url, {
-                httpsAgent: agent,
-                proxy: false
+        if (!type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方登录类型'
             })
 
-            if (!r || r.data?.code !== 0) {
-                this.logger.error(`获取用户信息失败!${r.data?.msg || 'api接口错误'}`)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '获取用户信息失败!'
-                })
-            }
-
-            let { social_uid, nickname, faceimg, ip } = r.data
+        try {
+            let { social_uid, nickname, faceimg, ip } = await fetchUniLoginProfile(type, code)
             const session = uuidv4()
             const time = new Date().getTime()
 
-            let selectSql = 'SELECT uuid, username, permission FROM users WHERE social_uid = ? AND social_type = ?'
-            let selectRows = await db.query(selectSql, [social_uid, type || 'qq'])
-
-            let uuid, username, permission
+            let binding = await getBindingByIdentity(type, social_uid)
+            let uuid = binding?.user_uuid
+            let selectRows = []
+
+            if (uuid) {
+                selectRows = await db.query('SELECT * FROM users WHERE uuid = ? LIMIT 1', [uuid])
+            } else {
+                const legacyUser = await getLegacyUserByIdentity(type, social_uid)
+                if (legacyUser?.uuid) {
+                    uuid = legacyUser.uuid
+                    await insertSocialBinding(uuid, type, social_uid, nickname, faceimg)
+                    selectRows = await db.query('SELECT * FROM users WHERE uuid = ? LIMIT 1', [uuid])
+                }
+            }
 
-            // 用户不存在 执行注册操作
-            if (selectRows.length == 0) {
+            if (!uuid) {
                 uuid = uuidv4()
-                username = `用户${uuid.slice(0, 8)}`
+                const username = `用户${uuid.slice(0, 8)}`
 
-                let regSql = 'INSERT INTO users (uuid, username, registTime, social_uid, social_type, nickname, avatar, email) VALUES (?,?,?,?,?,?,?,?) '
-                let regRows = await db.query(regSql, [uuid, username, time, social_uid, type || 'qq', nickname, faceimg, '未设置'])
+                let regSql = 'INSERT INTO users (uuid, username, registTime, nickname, avatar, email) VALUES (?,?,?,?,?,?) '
+                let regRows = await db.query(regSql, [uuid, username, time, nickname, faceimg, '未设置'])
                 if (!regRows || regRows.affectedRows !== 1) {
                     this.logger.error(`聚合登录用户注册失败!数据库错误`)
                     return res.json({
@@ -67,40 +67,58 @@ class Login extends API {
                         msg: '用户注册失败!'
                     })
                 }
+
+                await insertSocialBinding(uuid, type, social_uid, nickname, faceimg)
+                selectRows = await db.query('SELECT * FROM users WHERE uuid = ? LIMIT 1', [uuid])
             }
-            else {
-                uuid = selectRows[0].uuid
-                username = selectRows[0].username
-                permission = selectRows[0].permission
-            }
+
+            if (!selectRows || selectRows.length === 0)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '用户登录失败!请稍后再试'
+                })
+
+            const user = selectRows[0]
+
+            if (Number(user.is_banned) === 1)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '账号已被封禁,如有疑问请邮件联系:service@xxoo365.top'
+                })
 
             await Redis.set(`userSession:${uuid}`, session, {
                 EX: 2592000
             })
 
+            await db.query(
+                'UPDATE users SET lastTime = ?, nickname = ? WHERE uuid = ?',
+                [time, nickname, uuid]
+            )
+            await updateSocialBindingProfile(type, social_uid, nickname, faceimg)
+
             res.json({
                 ...BaseStdResponse.OK,
                 data: {
                     uuid,
-                    username,
+                    username: user.username,
                     session,
                     nickname,
-                    type: type || 'qq',
-                    roles: permission || [],
-                    avatar: faceimg,
+                    type,
+                    roles: user.permission || [],
+                    vip: user.vip,
+                    ic_count: user.ic_count,
+                    lepao_count: user.lepao_count,
+                    crouse_count: user.crouse_count,
+                    avatar: faceimg || user.avatar,
+                    email: user.email
                 }
             })
 
             // 增加登录记录
             try {
-                if (selectRows.length !== 0) {
-                    let updateSql = 'UPDATE users SET lastTime = ?, avatar = ?, nickname = ? WHERE social_uid = ? AND social_type = ?'
-                    await db.query(updateSql, [time, faceimg, nickname, social_uid, type || 'qq'])
-                }
-
                 const userAgent = req.headers['user-agent']
                 let insertSql = 'INSERT INTO login_history (uuid, time, deviceInfo, type, ip) VALUES (?, ?, ?, ?, ?)'
-                await db.query(insertSql, [uuid, time, { 'ua': userAgent }, type || 'qq', ip])
+                await db.query(insertSql, [uuid, time, { 'ua': userAgent }, type, ip])
             } catch (error) {
                 this.logger.error(`写入登录记录失败!${error}`)
             }

+ 86 - 0
apis/User/uniLogin/UnbindSocial.js

@@ -0,0 +1,86 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { normalizeSocialType } = require('../../../lib/UniLoginClient')
+const {
+    getUserSocialBindings,
+    removeSocialBinding,
+    toSocialBindingSummary
+} = require('../../../lib/UserSocialBinding')
+
+class UnbindSocial extends API {
+    constructor() {
+        super()
+
+        this.setPath('/UniLogin/UnbindSocial')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, type, social_type } = req.body
+        const channelType = normalizeSocialType(type ?? social_type)
+
+        if ([uuid, session].some(value => value === '' || value === null || value === undefined))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        if (!channelType)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方账号类型'
+            })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        try {
+            const bindings = await getUserSocialBindings(uuid)
+            if (!bindings.some(binding => binding.social_type === channelType))
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '当前账号未绑定该第三方账号'
+                })
+
+            const userRows = await db.query('SELECT password FROM users WHERE uuid = ? LIMIT 1', [uuid])
+            if (!userRows || userRows.length === 0)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '用户不存在'
+                })
+
+            if (bindings.length <= 1 && !userRows[0].password)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '请先设置登录密码或绑定其他第三方账号后再解绑'
+                })
+
+            const result = await removeSocialBinding(uuid, channelType)
+            if (!result || result.affectedRows !== 1)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '解绑失败,请稍后再试'
+                })
+
+            const newBindings = await getUserSocialBindings(uuid)
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    socialBindings: toSocialBindingSummary(newBindings),
+                    boundTypes: newBindings.map(binding => binding.social_type)
+                }
+            })
+        } catch (error) {
+            this.logger.error(`解绑第三方账号失败!${error.message || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '解绑失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.UnbindSocial = UnbindSocial

+ 107 - 0
config.example.json

@@ -0,0 +1,107 @@
+{
+    "port": 30003,
+    "serverRole": "all",
+    "proxyForwardServer": {
+        "url": "http://127.0.0.1:30010",
+        "enabled": true,
+        "timeout": 120000
+    },
+    "server": "YOUR_SERVER_LABEL",
+    "database": {
+        "host": "YOUR_MYSQL_HOST",
+        "database": "YOUR_DB_NAME",
+        "port": 3306,
+        "user": "YOUR_DB_USER",
+        "password": "YOUR_DB_PASSWORD",
+        "connectTimeout": 30000
+    },
+    "redis": {
+        "host": "localhost",
+        "port": 6379,
+        "password": ""
+    },
+    "rabbitmq": {
+        "url": "amqp://USER:PASSWORD@HOST:5672",
+        "managementBaseUrl": "http://HOST:15672",
+        "reconnectInterval": 5000,
+        "lepaoScheduleTickMs": 2000,
+        "lepaoScheduleBatch": 100,
+        "mqPrefix": ""
+    },
+    "qk": {
+        "passwordAesKey": "CHANGE_ME_TO_A_RANDOM_SECRET",
+        "leaseMs": 90000,
+        "heartbeatTtlSeconds": 45,
+        "leaseWatcherIntervalMs": 30000,
+        "staleTaskMs": 3600000,
+        "memPerSlotMb": 3072,
+        "memReserveMb": 1024,
+        "maxSlotsCap": 10,
+        "slotCapacityFactor": 1.5
+    },
+    "email": [
+        {
+            "host": "smtp.primary.example.com",
+            "port": 587,
+            "secure": false,
+            "user": "noreply@primary.example.com",
+            "password": "YOUR_SMTP_PASSWORD_1",
+            "from": "Display Name <noreply@primary.example.com>"
+        },
+        {
+            "host": "smtp.secondary.example.com",
+            "port": 465,
+            "secure": true,
+            "user": "mail@secondary.example.com",
+            "password": "YOUR_SMTP_PASSWORD_2",
+            "from": "Display Name <mail@secondary.example.com>"
+        },
+        {
+            "host": "smtp.zeptomail.example.com",
+            "port": 587,
+            "secure": true,
+            "user": "noreply@mail.secondary.example.com",
+            "password": "YOUR_SMTP_PASSWORD_3",
+            "from": "Display Name <noreply@mail.secondary.example.com>"
+        },
+        {
+            "host": "smtp.exmail.example.com",
+            "port": 465,
+            "secure": true,
+            "user": "mail@domain.example.com",
+            "password": "YOUR_SMTP_PASSWORD_4",
+            "from": "Display Name <mail@domain.example.com>"
+        }
+    ],
+    "url": "https://your-lepao-api.example.com",
+    "url2": "http://127.0.0.1:30004",
+    "url3": "http://127.0.0.1:30001",
+    "pay": {
+        "url": "https://pay.example.com",
+        "pid": 0,
+        "key": "YOUR_PAY_KEY",
+        "return_url": "https://your-site.example/#/store/orderDetail/",
+        "uni_return_url": "https://m.your-site.example/#/pages/order/order-detail?id="
+    },
+    "unilogin": {
+        "url": "https://open.example.com",
+        "appid": "YOUR_APPID",
+        "appkey": "YOUR_APPKEY",
+        "return_url": "https://your-site.example/uniLogin/loginSuccess",
+        "uni_return_url": "https://m.your-site.example/#/pages/login/login"
+    },
+    "onebotv11": {
+        "enabled": false,
+        "transport": "reverse_ws",
+        "reverseWsUrl": "ws://127.0.0.1:15700",
+        "reverseWsPath": "/ws",
+        "reverseWsHost": "0.0.0.0",
+        "reverseWsPort": 15700,
+        "reverseWsToken": "YOUR_WS_TOKEN",
+        "callbackToken": "YOUR_CALLBACK_TOKEN",
+        "ticketSenderNickname": "工单机器人",
+        "selfId": "your-bot-id",
+        "botName": "BotName",
+        "botUuid": "your-bot-uuid"
+    }
+}

+ 0 - 61
config.json

@@ -1,61 +0,0 @@
-{
-    "port": 30003,
-    "database": {
-        "host": "8.137.37.202",
-        "database": "ic",
-        "port": 41306,
-        "user": "ic",
-        "password": "yx3ud937",
-        "connectTimeout": 30000
-    },
-    "redis": {
-        "host": "localhost",
-        "port": 5173,
-        "password": ""
-    },
-    "email": [
-        {
-            "host": "smtp.zoho.com",
-            "port": 465,
-            "secure": true,
-            "user": "mail@xxoo365.top",
-            "password": "yx3ud937",
-            "from": "RunForge智能校园乐跑平台 <mail@xxoo365.top>"
-        },
-        {
-            "host": "smtp.zeptomail.com",
-            "port": 587,
-            "secure": true,
-            "user": "noreply@mail.xxoo365.top",
-            "password": "wSsVR60k+xX5X6osmGCsL708nFUHAw6gE09/ilGo7iL6G/3Cp8dowRDKVlenTvkfGGFvQmMbrOkgnUtV2zMJit4smQlSXiiF9mqRe1U4J3x17qnvhDzIXWlakBeLKo8Aww1omGBoEsgn+g==",
-            "from": "RunForge智能校园乐跑平台 <noreply@mail.xxoo365.top>"
-        },
-        {
-            "host": "smtp.exmail.qq.com",
-            "port": 465,
-            "secure": true,
-            "user": "mail@ctbu.top",
-            "password": "FPhxUVrt5J9WxgiP",
-            "from": "RunForge智能校园乐跑平台 <mail@ctbu.top>"
-        }
-    ],
-    "url": "https://lepao-api.xxoo365.top",
-    "url2": "http://127.0.0.1:30004",
-    "url3": "http://127.0.0.1:30001",
-    "runpy": "http://127.0.0.1:58000/api",
-    "pay": {
-        "url": "https://pay.gggua.com",
-        "pid": 1296,
-        "key": "MW222B33ytTv23tf2Z2tFV2k23iHVQ3q",
-        "return_url": "https://xxoo365.top/#/store/orderDetail/",
-        "uni_return_url": "https://m.xxoo365.top/#/pages/order/order-detail?id="
-    },
-    "unilogin": {
-        "url": "https://open.losfer.cn",
-        "appid": "1274",
-        "appkey": "e9dd049e0d991b997901957200036b30",
-        "return_url": "https://xxoo365.top/uniLogin/loginSuccess",
-        "uni_return_url": "https://m.xxoo365.top/#/pages/login/login"
-    },
-    "server": "CTBU_CLUB 重庆1号服务器"
-}

+ 41 - 2
lib/API.js

@@ -6,6 +6,7 @@ const fs = require('fs')
 const forge = require('node-forge')
 const crypto = require('crypto')
 const { BaseStdResponse } = require("../BaseStdResponse")
+const AccessControl = require('./AccessControl')
 
 class API {
     constructor() {
@@ -14,6 +15,7 @@ class API {
         this.path = ''
         this.method = 'get'
         this.encrypt = true
+        this.permissionCodes = []
 
         this.privateKey = fs.readFileSync(path.join(__dirname, '../keys/private_key.pem'), 'utf8')
 
@@ -33,6 +35,14 @@ class API {
         this.method = method.toLowerCase()
     }
 
+    setPermissionCode(code) {
+        this.permissionCodes = code ? [code] : []
+    }
+
+    setPermissionCodes(codes) {
+        this.permissionCodes = Array.isArray(codes) ? codes.filter(Boolean) : []
+    }
+
     getRouter() {
         return this.router
     }
@@ -90,7 +100,6 @@ class API {
 
             const requestId = req.headers['x-request-id'] || ''
             res.setHeader('X-Request-ID', requestId)
-
             next()
         } catch (err) {
             console.error('解密失败:', err)
@@ -115,8 +124,38 @@ class API {
         return encryptedData
     }
 
+    async checkPermission(req, res, next) {
+        try {
+            const payload = this.method === 'get' ? req.query : req.body
+            const requiredCodes = this.permissionCodes.length > 0
+                ? this.permissionCodes
+                : await AccessControl.getResourceRequiredCodes({
+                    method: this.method,
+                    path: this.path
+                })
+
+            if (!requiredCodes || requiredCodes.length === 0)
+                return next()
+
+            const { uuid, session } = payload || {}
+            if ([uuid, session].some(value => value === '' || value === null || value === undefined))
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+            if (!await AccessControl.checkSession(uuid, session))
+                return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+            if (!await AccessControl.canAccess(uuid, requiredCodes))
+                return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+            return next()
+        } catch (err) {
+            this.logger.error(`权限校验失败: ${err.stack || err}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '权限校验失败,请稍后再试' })
+        }
+    }
+
     setupRoute() {
-        this.router[this.method](this.path, this.decryptPayload.bind(this), async (req, res) => {
+        this.router[this.method](this.path, this.decryptPayload.bind(this), this.checkPermission.bind(this), async (req, res) => {
             // 拦截返回值统一加密
             const originalJson = res.json.bind(res)
             res.json = (data) => {

+ 281 - 1
lib/AccessControl.js

@@ -1,16 +1,277 @@
 const db = require('../plugin/DataBase/db')
 const Redis = require('../plugin/DataBase/Redis')
+const {
+    DEFAULT_PERMISSION_POINTS,
+    DEFAULT_PERMISSION_RESOURCE_RULES,
+    DEFAULT_BASIC_USER_PERMISSION_CODES,
+    LEGACY_ROLE_PERMISSION_MAP
+} = require('./PermissionCatalog')
 
 class AccessControl {
+    constructor() {
+        this.schemaReady = false
+    }
+
+    parseArray(value) {
+        if (Array.isArray(value)) return value
+        if (!value) return []
+        if (typeof value !== 'string') return []
+        try {
+            const parsed = JSON.parse(value)
+            return Array.isArray(parsed) ? parsed : []
+        } catch (_) {
+            return []
+        }
+    }
+
+    normalizeCodes(codes) {
+        if (!codes) return []
+        const list = Array.isArray(codes) ? codes : [codes]
+        return [...new Set(list.map(code => String(code || '').trim()).filter(Boolean))]
+    }
+
+    async ensurePermissionSchema() {
+        if (this.schemaReady) return
+
+        for (const point of DEFAULT_PERMISSION_POINTS) {
+            await db.query(
+                `INSERT INTO permission_points
+                (code, name, category, scope_type, page_route_name, enabled, remark)
+                VALUES (?, ?, ?, ?, ?, 1, ?)
+                ON DUPLICATE KEY UPDATE
+                    name = VALUES(name),
+                    category = VALUES(category),
+                    scope_type = VALUES(scope_type),
+                    page_route_name = VALUES(page_route_name),
+                    remark = VALUES(remark)`,
+                [point.code, point.name, point.category, point.scope_type, point.page_route_name || null, point.remark || '']
+            )
+        }
+
+        for (const rule of DEFAULT_PERMISSION_RESOURCE_RULES) {
+            const requiredCodes = JSON.stringify(this.normalizeCodes(rule.required_codes))
+            await db.query(
+                `INSERT INTO permission_resource_rules
+                (resource_type, resource_key, api_method, api_path, required_codes, enabled, remark)
+                VALUES (?, ?, ?, ?, ?, 1, ?)
+                ON DUPLICATE KEY UPDATE
+                    api_method = VALUES(api_method),
+                    api_path = VALUES(api_path),
+                    required_codes = VALUES(required_codes),
+                    remark = VALUES(remark)`,
+                [
+                    rule.resource_type,
+                    rule.resource_key,
+                    rule.api_method || null,
+                    rule.api_path || null,
+                    requiredCodes,
+                    rule.remark || ''
+                ]
+            )
+        }
+
+        this.schemaReady = true
+    }
+
     async checkSession(uuid, session) {
         return (await Redis.get(`userSession:${uuid}`)) === session
     }
 
+    async isBanned(uuid) {
+        const sql = 'SELECT COALESCE(is_banned, 0) AS is_banned FROM users WHERE uuid = ?'
+        const rows = await db.query(sql, [uuid])
+        return Number(rows[0]?.is_banned) === 1
+    }
+
+    async invalidateSession(uuid) {
+        await Redis.del(`userSession:${uuid}`)
+    }
+
     async getPermission(uuid) {
         const sql = 'SELECT permission FROM users WHERE uuid = ?'
         const rows = await db.query(sql, [uuid])
 
-        return rows[0]?.permission || []
+        return this.parseArray(rows?.[0]?.permission)
+    }
+
+    isSuperPermission(permission) {
+        return this.parseArray(permission).includes('admin')
+    }
+
+    async isSuperAdmin(uuid) {
+        const permission = await this.getPermission(uuid)
+        return this.isSuperPermission(permission)
+    }
+
+    async getPermissionPoints() {
+        await this.ensurePermissionSchema()
+        const rows = await db.query(`
+            SELECT id, code, name, category, scope_type, page_route_name, enabled, remark
+            FROM permission_points
+            ORDER BY category, id
+        `)
+        return rows || []
+    }
+
+    async getUserDirectPermissionCodes(uuid) {
+        await this.ensurePermissionSchema()
+        const rows = await db.query(
+            `SELECT permission_code FROM user_permission_points WHERE user_uuid = ? ORDER BY permission_code`,
+            [uuid]
+        )
+        return (rows || []).map(row => row.permission_code)
+    }
+
+    async getUserDeniedBasicPermissionCodes(uuid) {
+        await this.ensurePermissionSchema()
+        const rows = await db.query(
+            `SELECT permission_code FROM user_basic_permission_denials WHERE user_uuid = ? ORDER BY permission_code`,
+            [uuid]
+        )
+        const denied = (rows || []).map(row => row.permission_code)
+        const basicSet = new Set(DEFAULT_BASIC_USER_PERMISSION_CODES)
+        return this.normalizeCodes(denied.filter(code => basicSet.has(code)))
+    }
+
+    getEnabledBasicPermissionCodes(deniedBasicCodes = []) {
+        const deniedSet = new Set(this.normalizeCodes(deniedBasicCodes))
+        return DEFAULT_BASIC_USER_PERMISSION_CODES.filter(code => !deniedSet.has(code))
+    }
+
+    async getUserPermissionCodes(uuid) {
+        await this.ensurePermissionSchema()
+        const legacyRoles = await this.getPermission(uuid)
+        const directCodes = await this.getUserDirectPermissionCodes(uuid)
+        const deniedBasicCodes = await this.getUserDeniedBasicPermissionCodes(uuid)
+        const roleCodes = legacyRoles.flatMap(role => LEGACY_ROLE_PERMISSION_MAP[role] || [])
+
+        return this.normalizeCodes([
+            ...this.getEnabledBasicPermissionCodes(deniedBasicCodes),
+            ...legacyRoles,
+            ...roleCodes,
+            ...directCodes
+        ])
+    }
+
+    async setUserPermissionCodes(uuid, codes) {
+        await this.ensurePermissionSchema()
+        const permissionCodes = this.normalizeCodes(codes)
+        const points = await this.getPermissionPoints()
+        const validCodes = new Set(points.map(point => point.code))
+        const invalidCodes = permissionCodes.filter(code => !validCodes.has(code))
+
+        if (invalidCodes.length > 0)
+            throw new Error(`存在无效权限点:${invalidCodes.join(', ')}`)
+
+        const conn = await db.connect()
+        await conn.beginTransaction()
+        try {
+            await conn.execute(`DELETE FROM user_permission_points WHERE user_uuid = ?`, [uuid])
+            for (const code of permissionCodes) {
+                await conn.execute(
+                    `INSERT INTO user_permission_points (user_uuid, permission_code) VALUES (?, ?)`,
+                    [uuid, code]
+                )
+            }
+            await conn.commit()
+        } catch (error) {
+            try { await conn.rollback() } catch (_) { }
+            throw error
+        }
+    }
+
+    async setUserDeniedBasicPermissionCodes(uuid, codes) {
+        await this.ensurePermissionSchema()
+        const basicSet = new Set(DEFAULT_BASIC_USER_PERMISSION_CODES)
+        const deniedCodes = this.normalizeCodes(codes).filter(code => basicSet.has(code))
+        const invalidCodes = this.normalizeCodes(codes).filter(code => !basicSet.has(code))
+
+        if (invalidCodes.length > 0)
+            throw new Error(`仅可关闭基础权限:${invalidCodes.join(', ')}`)
+
+        const conn = await db.connect()
+        await conn.beginTransaction()
+        try {
+            await conn.execute(`DELETE FROM user_basic_permission_denials WHERE user_uuid = ?`, [uuid])
+            for (const code of deniedCodes) {
+                await conn.execute(
+                    `INSERT INTO user_basic_permission_denials (user_uuid, permission_code) VALUES (?, ?)`,
+                    [uuid, code]
+                )
+            }
+            await conn.commit()
+        } catch (error) {
+            try { await conn.rollback() } catch (_) { }
+            throw error
+        }
+    }
+
+    async getResourceRules() {
+        await this.ensurePermissionSchema()
+        const rows = await db.query(`
+            SELECT id, resource_type, resource_key, api_method, api_path, required_codes, enabled, remark
+            FROM permission_resource_rules
+            ORDER BY FIELD(resource_type, 'page', 'action', 'api'), id
+        `)
+
+        return (rows || []).map(row => ({
+            ...row,
+            required_codes: this.parseArray(row.required_codes)
+        }))
+    }
+
+    async getResourceRequiredCodes({ resourceType, resourceKey, method, path }) {
+        await this.ensurePermissionSchema()
+        let rows = []
+
+        if (resourceType && resourceKey) {
+            rows = await db.query(
+                `SELECT required_codes
+                FROM permission_resource_rules
+                WHERE resource_type = ? AND resource_key = ? AND enabled = 1
+                LIMIT 1`,
+                [resourceType, resourceKey]
+            )
+        } else if (method && path) {
+            rows = await db.query(
+                `SELECT required_codes
+                FROM permission_resource_rules
+                WHERE resource_type = 'api' AND api_method = ? AND api_path = ? AND enabled = 1
+                LIMIT 1`,
+                [String(method).toUpperCase(), path]
+            )
+        }
+
+        return this.parseArray(rows?.[0]?.required_codes)
+    }
+
+    async updateResourceRule({ id, required_codes, enabled }) {
+        await this.ensurePermissionSchema()
+        const requiredCodes = this.normalizeCodes(required_codes)
+        const points = await this.getPermissionPoints()
+        const validCodes = new Set(points.map(point => point.code))
+        const invalidCodes = requiredCodes.filter(code => !validCodes.has(code))
+
+        if (invalidCodes.length > 0)
+            throw new Error(`存在无效权限点:${invalidCodes.join(', ')}`)
+
+        const rows = await db.query(
+            `UPDATE permission_resource_rules
+            SET required_codes = ?, enabled = ?
+            WHERE id = ?`,
+            [JSON.stringify(requiredCodes), Number(enabled) === 0 ? 0 : 1, id]
+        )
+
+        return rows?.affectedRows === 1
+    }
+
+    async canAccess(uuid, requiredCodes) {
+        const codes = this.normalizeCodes(requiredCodes)
+        if (codes.length === 0) return true
+        if (await this.isSuperAdmin(uuid)) return true
+
+        const userCodes = await this.getUserPermissionCodes(uuid)
+        return codes.some(code => userCodes.includes(code))
     }
 
     async checkJwAccount(uuid, username) {
@@ -20,6 +281,25 @@ class AccessControl {
             return false
         return rows[0]?.password
     }
+
+    async getVerifiedJwAccount(uuid, accountId) {
+        const id = Number(accountId)
+        if (!id) return null
+        const rows = await db.query(
+            'SELECT id, username, password, realname FROM jw_account WHERE id = ? AND create_user = ? AND state = 1',
+            [id, uuid]
+        )
+        if (!rows?.length) return null
+        return rows[0]
+    }
+
+    async listVerifiedJwAccounts(uuid) {
+        const rows = await db.query(
+            'SELECT id, username, realname, deptName, className FROM jw_account WHERE create_user = ? AND state = 1 ORDER BY create_time DESC',
+            [uuid]
+        )
+        return rows || []
+    }
 }
 
 module.exports = new AccessControl();

+ 164 - 0
lib/CouponService.js

@@ -0,0 +1,164 @@
+const db = require('../plugin/DataBase/db')
+
+function roundMoney(n) {
+    return Math.round(Number(n) * 100) / 100
+}
+
+function calcDiscount(price, discountType, discountValue) {
+    const p = Number(price)
+    let discount = 0
+    if (discountType === 'percent') {
+        const pct = Math.min(100, Math.max(0, Number(discountValue)))
+        discount = roundMoney((p * pct) / 100)
+    } else {
+        discount = roundMoney(Math.min(p, Math.max(0, Number(discountValue))))
+    }
+    let finalPrice = roundMoney(p - discount)
+    if (finalPrice < 0.01) finalPrice = 0.01
+    if (finalPrice > p) finalPrice = p
+    discount = roundMoney(p - finalPrice)
+    return { discountAmount: discount, finalPrice }
+}
+
+async function countActiveUsage(couponId, userUuid) {
+    const totalRows = await db.query(
+        `SELECT COUNT(*) AS cnt FROM coupon_usage
+         WHERE coupon_id = ?`,
+        [couponId]
+    )
+    const userRows = await db.query(
+        `SELECT COUNT(*) AS cnt FROM coupon_usage
+         WHERE coupon_id = ? AND user_uuid = ?`,
+        [couponId, userUuid]
+    )
+    return {
+        total: Number(totalRows?.[0]?.cnt || 0),
+        perUser: Number(userRows?.[0]?.cnt || 0)
+    }
+}
+
+/**
+ * 校验优惠码并计算优惠后价格
+ */
+async function validateCoupon({ code, userUuid, goodsId, goodsPrice }) {
+    const normalizedCode = String(code || '').trim().toUpperCase()
+    if (!normalizedCode) {
+        return { ok: false, msg: '请输入优惠码' }
+    }
+
+    const rows = await db.query(
+        `SELECT * FROM coupons WHERE code = ? AND state = 1 LIMIT 1`,
+        [normalizedCode]
+    )
+    if (!rows || rows.length !== 1) {
+        return { ok: false, msg: '优惠码不存在或已失效' }
+    }
+
+    const coupon = rows[0]
+    const now = Date.now()
+
+    if (coupon.start_time && now < Number(coupon.start_time)) {
+        return { ok: false, msg: '优惠码尚未生效' }
+    }
+    if (coupon.end_time && now > Number(coupon.end_time)) {
+        return { ok: false, msg: '优惠码已过期' }
+    }
+
+    const price = Number(goodsPrice)
+    const minAmount = Number(coupon.min_amount || 0)
+    if (minAmount > 0 && price < minAmount) {
+        return { ok: false, msg: `订单满 ¥${minAmount} 才可使用该优惠码` }
+    }
+
+    if (Number(coupon.goods_scope) === 1) {
+        const goodsRows = await db.query(
+            'SELECT 1 FROM coupon_goods WHERE coupon_id = ? AND goods_id = ? LIMIT 1',
+            [coupon.id, goodsId]
+        )
+        if (!goodsRows || goodsRows.length === 0) {
+            return { ok: false, msg: '该优惠码不适用于当前商品' }
+        }
+    }
+
+    if (Number(coupon.user_scope) === 1) {
+        const userRows = await db.query(
+            'SELECT 1 FROM coupon_users WHERE coupon_id = ? AND user_uuid = ? LIMIT 1',
+            [coupon.id, userUuid]
+        )
+        if (!userRows || userRows.length === 0) {
+            return { ok: false, msg: '您暂无使用该优惠码的权限' }
+        }
+    }
+
+    const usage = await countActiveUsage(coupon.id, userUuid)
+    const totalLimit = Number(coupon.total_limit || 0)
+    const perUserLimit = Number(coupon.per_user_limit || 1)
+
+    if (totalLimit > 0 && usage.total >= totalLimit) {
+        return { ok: false, msg: '优惠码已达使用上限' }
+    }
+    if (usage.perUser >= perUserLimit) {
+        return { ok: false, msg: '您已达到该优惠码的使用次数上限' }
+    }
+
+    const { discountAmount, finalPrice } = calcDiscount(
+        price,
+        coupon.discount_type,
+        coupon.discount_value
+    )
+
+    if (discountAmount <= 0) {
+        return { ok: false, msg: '优惠码无效,未产生优惠' }
+    }
+
+    return {
+        ok: true,
+        couponId: coupon.id,
+        code: coupon.code,
+        name: coupon.name,
+        discountType: coupon.discount_type,
+        discountValue: Number(coupon.discount_value),
+        originalPrice: roundMoney(price),
+        discountAmount,
+        finalPrice,
+        displayDiscount:
+            coupon.discount_type === 'percent'
+                ? `${coupon.discount_value}% 折扣`
+                : `立减 ¥${coupon.discount_value}`
+    }
+}
+
+async function recordUsage(couponId, orderId, userUuid, discountAmount) {
+    const time = Date.now()
+    await db.query(
+        `INSERT INTO coupon_usage (coupon_id, order_id, user_uuid, discount_amount, create_time)
+         VALUES (?, ?, ?, ?, ?)`,
+        [couponId, orderId, userUuid, discountAmount, time]
+    )
+    await db.query('UPDATE coupons SET used_count = used_count + 1 WHERE id = ?', [couponId])
+}
+
+async function releaseUsageForOrder(orderId) {
+    const rows = await db.query(
+        'SELECT coupon_id FROM orders WHERE orderId = ? AND coupon_id IS NOT NULL LIMIT 1',
+        [orderId]
+    )
+    if (!rows || !rows[0]?.coupon_id) return
+
+    const couponId = rows[0].coupon_id
+    const del = await db.query('DELETE FROM coupon_usage WHERE order_id = ?', [orderId])
+    if (del?.affectedRows > 0) {
+        await db.query(
+            'UPDATE coupons SET used_count = GREATEST(used_count - 1, 0) WHERE id = ?',
+            [couponId]
+        )
+    }
+}
+
+module.exports = {
+    roundMoney,
+    calcDiscount,
+    validateCoupon,
+    recordUsage,
+    releaseUsageForOrder
+}

+ 50 - 0
lib/Lepao/BindAudit.js

@@ -0,0 +1,50 @@
+const db = require('../../plugin/DataBase/db')
+
+const BindAuditAction = Object.freeze({
+    PLATFORM_BIND: 'platform_bind',
+    PLATFORM_UNBIND: 'platform_unbind',
+    BOT_BIND: 'bot_bind',
+    BOT_UNBIND: 'bot_unbind'
+})
+
+const BindAuditSource = Object.freeze({
+    USER_API: 'user_api',
+    ADMIN_API: 'admin_api',
+    SERVICE_API: 'service_api',
+    MCP_QQ: 'mcp_qq',
+    MCP_WORK_ORDER: 'mcp_work_order'
+})
+
+async function insertBindAudit({
+    studentNum,
+    ownerUuid = null,
+    action,
+    source,
+    operatorUuid = null,
+    detail = null,
+    createdAt = Date.now()
+}) {
+    if (!studentNum || !action || !source) return false
+
+    const sql = `
+        INSERT INTO lepao_bind_audit
+            (student_num, owner_uuid, action, source, operator_uuid, detail_json, created_at)
+        VALUES (?, ?, ?, ?, ?, ?, ?)
+    `
+    const rows = await db.query(sql, [
+        String(studentNum),
+        ownerUuid || null,
+        action,
+        source,
+        operatorUuid || null,
+        detail ? JSON.stringify(detail) : null,
+        Number(createdAt) || Date.now()
+    ])
+    return !!rows && rows.affectedRows === 1
+}
+
+module.exports = {
+    BindAuditAction,
+    BindAuditSource,
+    insertBindAudit
+}

+ 49 - 0
lib/Lepao/CountLedger.js

@@ -0,0 +1,49 @@
+const db = require('../../plugin/DataBase/db')
+
+function normalizeResult(rawResult) {
+    if (Array.isArray(rawResult)) return rawResult[0]
+    return rawResult
+}
+
+async function executeSql(executor, sql, params) {
+    if (executor && typeof executor.execute === 'function') {
+        return executor.execute(sql, params)
+    }
+    return db.query(sql, params)
+}
+
+async function insertLedgerRecord({
+    executor,
+    userUuid,
+    delta,
+    balanceBefore,
+    balanceAfter,
+    bizType,
+    bizId,
+    operatorUuid = null,
+    remark = ''
+}) {
+    if (!userUuid || !bizType) return false
+
+    const result = await executeSql(
+        executor,
+        `INSERT IGNORE INTO lepao_count_ledger
+        (user_uuid, delta, balance_before, balance_after, biz_type, biz_id, operator_uuid, remark, created_at)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
+        [
+            userUuid,
+            Number(delta || 0),
+            Number(balanceBefore || 0),
+            Number(balanceAfter || 0),
+            bizType,
+            String(bizId || ''),
+            operatorUuid,
+            String(remark || '')
+        ]
+    )
+
+    const rows = normalizeResult(result)
+    return !!rows && Number(rows.affectedRows || 0) > 0
+}
+
+module.exports = { insertLedgerRecord }

+ 41 - 4
lib/Lepao/Mcp.js

@@ -2,13 +2,15 @@ const db = require('../../plugin/DataBase/db')
 const path = require('path')
 const Logger = require('../Logger')
 const mq = require('../../plugin/mq')
+const { mq: mqName } = require('../../plugin/mq/mqPrefix')
 const EmailTemplate = require('../../plugin/Email/emailTemplate')
+const { insertBindAudit, BindAuditAction, BindAuditSource } = require('./BindAudit')
 
 class Mcp {
     constructor() {
         this.logger = new Logger(path.join(__dirname, '../logs/MCP.log'), 'INFO')
 
-        this.messageQueue = 'runforge_message_queue'
+        this.messageQueue = mqName('runforge_message_queue')
 
         this.auto_day = [
             { label: '周一', value: 1 },
@@ -58,7 +60,7 @@ class Mcp {
             let umo = `QQ2749761853:FriendMessage:${sender}`
             let sql = `
                 SELECT 
-                    f.student_num, a.name, f.bot_account
+                    f.student_num, a.name, a.create_user, f.bot_account
                 FROM 
                     lepao_extra f
                 LEFT JOIN 
@@ -71,7 +73,7 @@ class Mcp {
             const rows = await db.query(sql, [bind_code])
             if (!rows || rows.length === 0) return '系统出错,请稍后再试'
 
-            if (rows[0].bot_account !== null) {
+            if (rows[0].bot_account !== null && rows[0].bot_account !== '') {
                 if (rows[0].bot_account !== sender) return '该账号已被他人绑定,请联系客服解绑'
                 return '该账号您已绑定'
             }
@@ -82,6 +84,19 @@ class Mcp {
             let insertRows = await db.query(insertSql, [sender, umo, bind_code])
             if (!insertRows || insertRows.affectedRows !== 1)
                 return '系统出错,请稍后再试'
+
+            const auditOk = await insertBindAudit({
+                studentNum: rows[0].student_num,
+                ownerUuid: rows[0].create_user || null,
+                action: BindAuditAction.BOT_BIND,
+                source: BindAuditSource.MCP_QQ,
+                operatorUuid: null,
+                detail: { sender },
+                createdAt: Date.now()
+            })
+            if (!auditOk) {
+                this.logger.warn(`MCP绑定审计写入失败 student_num=${rows[0].student_num}`)
+            }
             return `绑定成功,姓名:${rows[0].name ?? '未更新,请使用乐跑登录器更新账号信息'},学号:${rows[0].student_num}`
         } catch (error) {
             this.logger.error(`MCP绑定账号出错:${error.stack}`)
@@ -157,12 +172,34 @@ class Mcp {
                 return '缺少参数'
 
             this.logger.info(`MCP接收解绑账号请求:${sender}`)
+            const selectSql = `
+                SELECT e.student_num, a.create_user
+                FROM lepao_extra e
+                LEFT JOIN lepao_account a ON a.student_num = e.student_num
+                WHERE e.bot_account = ?
+            `
+            const selectRows = await db.query(selectSql, [sender])
+            if (!selectRows || selectRows.length === 0) return '您尚未绑定乐跑账号,请先绑定'
+
             let insertSql = `
                 UPDATE lepao_extra SET bot_account = NULL, bot_umo = NULL WHERE bot_account = ?
             `
             let insertRows = await db.query(insertSql, [sender])
             if (!insertRows || insertRows.affectedRows !== 1)
                 return '系统出错,请稍后再试'
+
+            const auditOk = await insertBindAudit({
+                studentNum: selectRows[0].student_num,
+                ownerUuid: selectRows[0].create_user || null,
+                action: BindAuditAction.BOT_UNBIND,
+                source: BindAuditSource.MCP_QQ,
+                operatorUuid: null,
+                detail: { sender },
+                createdAt: Date.now()
+            })
+            if (!auditOk) {
+                this.logger.warn(`MCP解绑审计写入失败 student_num=${selectRows[0].student_num}`)
+            }
             return `解绑成功`
         } catch (error) {
             this.logger.error(`MCP解绑账号出错:${error.stack}`)
@@ -231,7 +268,7 @@ class Mcp {
             if (!rows || rows.length == 0) return '您尚未绑定乐跑账号,请先绑定'
 
             if (!rows[0].email) return '该账号还未设置邮箱,请先使用change_email设置邮箱'
-            if (rows[0].notice_type === 'mode') return `当前已是${mode}通知方式,无需修改`
+            if (rows[0].notice_type === mode) return `当前已是${mode}通知方式,无需修改`
 
             let insertSql = `
                 UPDATE 

+ 203 - 0
lib/Lepao/WorkOrderMcp.js

@@ -0,0 +1,203 @@
+const db = require('../../plugin/DataBase/db')
+const path = require('path')
+const Logger = require('../Logger')
+const mq = require('../../plugin/mq')
+const { mq: mqName } = require('../../plugin/mq/mqPrefix')
+const Redis = require('../../plugin/DataBase/Redis')
+const EmailTemplate = require('../../plugin/Email/emailTemplate')
+const { insertBindAudit, BindAuditAction, BindAuditSource } = require('./BindAudit')
+
+class WorkOrderMcp {
+    constructor() {
+        this.logger = new Logger(path.join(__dirname, '../logs/MCP.log'), 'INFO')
+
+        this.messageQueue = mqName('runforge_message_queue')
+
+        this.auto_day = [
+            { label: '周一', value: 1 },
+            { label: '周二', value: 2 },
+            { label: '周三', value: 3 },
+            { label: '周四', value: 4 },
+            { label: '周五', value: 5 },
+            { label: '周六', value: 6 },
+            { label: '周日', value: 0 }
+        ]
+
+        this.area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区", "随机分配"]
+
+        this.auto_time = [
+            { label: '随机分配', value: -1 },
+            ...Array.from({ length: 17 }, (_, i) => {
+                const hour = i + 7
+                return { label: `${hour} ~ ${hour + 1}时`, value: hour }
+            })
+        ]
+
+        this.autoTimeLabel = (record) => {
+            if (record.auto_time === -1) {
+                if (record.today_auto_time)
+                    return `随机-今日${record.today_auto_time}时`
+                return '随机-待分配'
+            }
+            const match = this.auto_time.find(item => item.value === record.auto_time)
+            return match ? match.label : '-'
+        }
+
+        this.emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+        this.banEmailList = ['icloud.com']
+        this.dailyUnbindLimit = 10
+    }
+
+
+    async get_account_info({ sender, student_num }) {
+        try {
+            if ([sender, student_num].some(value => value === '' || value === null || value === undefined))
+                return '缺少参数'
+
+            this.logger.info(`MCP接收获取账号信息请求:${sender},student_num:${student_num}`)
+            let sql = `
+                SELECT 
+                    l.name,
+                    l.student_num,
+                    l.state,
+                    l.area,
+                    l.auto_time,
+                    l.total_num,
+                    l.term_num,
+                    l.academy_name,
+                    l.sex,
+                    l.grade_id,
+                    l.email,
+                    l.auto_run,
+                    l.today_auto_time,
+                    l.target_count,
+                    l.auto_day,
+                    l.notice_type
+                FROM 
+                    lepao_account l
+                WHERE 
+                    l.student_num = ? AND l.create_user = ?
+            `
+            const rows = await db.query(sql, [student_num, sender])
+            if (!rows || rows.length == 0) return '该账号未被您绑定,无法查看'
+
+            let data = rows[0]
+            let returnMsg = `
+                姓名:${data.name ?? '未更新,请使用乐跑登录器更新账号信息'};学号:${data.student_num};账号状态:${data.state === 1 ? '正常' : '需使用乐跑登录器更新账号信息'};乐跑跑区:${data.area == '' ? "随机分配" : data.area};自动乐跑状态:${data.auto_run === 1 ? '开启' : '关闭'}
+                `
+            if (data.auto_run === 1) {
+                returnMsg += `自动乐跑时间:${this.autoTimeLabel(data)};`
+                returnMsg += `自动乐跑星期:${data.auto_day.slice().sort((a, b) => {
+                    if (a === 0) return 1; if (b === 0) return -1; return a - b;
+                }).map(day => this.auto_day.find(item => item.value === day)?.label).join(',')};`
+            }
+            if (data.sex) returnMsg += `性别:${data.sex === 1 ? '男' : '女'};`
+            if (data.email) returnMsg += `邮箱:${data.email};`
+            if (data.grade) returnMsg += `邮箱:${data.grade};`
+            if (data.academy_name) returnMsg += `学院:${data.academy_name};`
+            if (data.grade_id) returnMsg += `年级:${data.grade_id}级;`
+            if (data.target_count) returnMsg += `目标乐跑次数:${data.target_count};`
+            if (data.total_num) returnMsg += `累计乐跑次数:${data.total_num};`
+            if (data.notice_type) returnMsg += `通知方式:${data.notice_type};`
+
+            return returnMsg
+        } catch (error) {
+            this.logger.error(`MCP查询账号信息出错:${error.stack}`)
+            return '系统出错,请稍后再试'
+        }
+    }
+
+    getDailyUnbindRedisKey(sender) {
+        const now = new Date()
+        const year = now.getFullYear()
+        const month = `${now.getMonth() + 1}`.padStart(2, '0')
+        const day = `${now.getDate()}`.padStart(2, '0')
+        return `mcp:unbind:daily:${sender}:${year}${month}${day}`
+    }
+
+    getSecondsToDayEnd() {
+        const now = new Date()
+        const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0)
+        return Math.max(1, Math.floor((tomorrow.getTime() - now.getTime()) / 1000))
+    }
+
+    async unbind_account({ sender, student_num }) {
+        try {
+            if ([sender, student_num].some(value => value === '' || value === null || value === undefined))
+                return '缺少参数'
+
+            this.logger.info(`工单MCP接收解绑账号请求:${sender}`)
+            const dailyUnbindKey = this.getDailyUnbindRedisKey(sender)
+            const dailyUnbindCount = Number(await Redis.get(dailyUnbindKey) || 0)
+            if (dailyUnbindCount >= this.dailyUnbindLimit) return '今日通过MCP解绑次数已超过限制,只能人工处理'
+
+            let selectSql = `SELECT create_user, auto_run FROM lepao_account WHERE student_num = ? AND create_user IS NOT NULL`
+            let selectRows = await db.query(selectSql, [student_num])
+            if (!selectRows || selectRows.length === 0 || !selectRows[0]?.create_user) return '该账号未绑定,无需解绑'
+
+            if (selectRows[0].auto_run === 1) return '该账号已开启自动乐跑,请用原绑定账号关闭自动乐跑后再解绑或联系人工客服处理'
+            let updateSql = `UPDATE lepao_account SET create_user = NULL, auto_run = 0, update_time = ? WHERE student_num = ?`
+            let updateRows = await db.query(updateSql, [Date.now(), student_num])
+            if (!updateRows || updateRows.affectedRows !== 1)
+                return '系统出错,请稍后再试'
+
+            const auditOk = await insertBindAudit({
+                studentNum: student_num,
+                ownerUuid: selectRows[0].create_user || null,
+                action: BindAuditAction.PLATFORM_UNBIND,
+                source: BindAuditSource.MCP_WORK_ORDER,
+                operatorUuid: sender || null,
+                detail: { sender },
+                createdAt: Date.now()
+            })
+            if (!auditOk) {
+                this.logger.warn(`工单解绑审计写入失败 student_num=${student_num}`)
+            }
+
+            const latestUnbindCount = await Redis.incr(dailyUnbindKey)
+            if (latestUnbindCount === 1) {
+                await Redis.expire(dailyUnbindKey, this.getSecondsToDayEnd())
+            }
+            return `解绑成功,您现在可绑定该账号`
+        } catch (error) {
+            this.logger.error(`工单MCP解绑账号出错:${error.stack}`)
+            return '系统出错,请稍后再试'
+        }
+    }
+
+    async need_manual({ sender, order_id }) {
+        try {
+            if ([sender, order_id].some(value => value === '' || value === null || value === undefined))
+                return '缺少参数'
+
+            this.logger.info(`MCP接收转人工服务请求:${order_id}`)
+
+            const selectSql = 'SELECT msg, state, email, need_manual FROM work_order WHERE id = ? AND create_user = ?'
+            const selectRows = await db.query(selectSql, [order_id, sender])
+            if (!selectRows || selectRows.length !== 1)
+                return '未找到你创建的工单,请核对工单ID'
+            if (selectRows[0].state === 2) return '工单已关闭,无法提交转人工服务,请提交新工单'
+            if (selectRows[0].need_manual === 1) return '该工单已提交转人工服务,无需重复提交'
+            
+            let msg = selectRows[0].msg
+            msg.push({
+                time: Date.now(),
+                content: '人工处理请求已提交,请耐心等待人工客服处理',
+                uuid: 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281',
+                type: 'system'
+            })
+
+            let updateSql = `UPDATE work_order SET msg = ?, need_manual = 1, update_time = ? WHERE id = ? AND create_user = ?`
+            let updateRows = await db.query(updateSql, [msg, Date.now(), order_id, sender])
+            if (!updateRows || updateRows.affectedRows !== 1) return '系统出错,请稍后再试'
+            
+            return `转人工服务请求已提交,后续消息AI助理小妍将不再回复`
+        } catch (error) {
+            this.logger.error(`MCP转人工服务出错:${error.stack}`)
+            return '系统出错,请稍后再试'
+        }
+    }
+}
+
+const WORKORDERMCP = new WorkOrderMcp()
+module.exports.WORKORDERMCP = WORKORDERMCP

+ 111 - 0
lib/Lepao/lepaoOfficialApi.js

@@ -0,0 +1,111 @@
+const { URLSearchParams } = require('url')
+const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
+const { postLepaoSchool } = require('./lepaoSchoolHttp')
+
+const BASE_URL = 'https://lepao.ctbu.edu.cn/v3/api.php'
+const DEFAULT_USER_AGENT = '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'
+
+function lepaoTimestamp() {
+    return Number((Date.now() / 1000).toFixed(3))
+}
+
+function lepaoNonce() {
+    return String(Math.floor(Math.random() * 900000 + 100000))
+}
+
+function buildSignedRaw(account, extraFields = {}) {
+    const studentNum = account.student_num || account.student_id
+    const raw = {
+        school_id: account.school_id,
+        term_id: 0,
+        course_id: 0,
+        class_id: 0,
+        student_num: studentNum,
+        card_id: studentNum,
+        uid: account.uid,
+        token: account.token,
+        timestamp: lepaoTimestamp(),
+        version: 1,
+        nonce: lepaoNonce(),
+        ostype: 5,
+        ...extraFields
+    }
+    raw.sign = dataSign(raw)
+    return raw
+}
+
+function unwrapLepaoResponse(result) {
+    if (result?.data && Number(result.is_encrypt) === 1) {
+        const decrypted = dataDecrypt(result.data)
+        if (!decrypted) {
+            const err = new Error('官方乐跑接口解密失败')
+            err.code = 'LEPAO_DECRYPT_FAILED'
+            throw err
+        }
+        result.data = JSON.parse(decrypted)
+    }
+
+    const failedByStatus = Object.prototype.hasOwnProperty.call(result || {}, 'status') && Number(result.status) !== 1
+    const failedByCode = Object.prototype.hasOwnProperty.call(result || {}, 'code') && Number(result.code) !== 1 && Number(result.code) !== 200
+    if (failedByStatus || failedByCode) {
+        const msg = result?.info || result?.msg || '官方乐跑接口请求失败'
+        const err = new Error(msg)
+        err.code = Number(result?.status) === 101 ? 'LEPAO_LOGIN_EXPIRED' : 'LEPAO_OFFICIAL_FAILED'
+        err.result = result
+        throw err
+    }
+
+    return result
+}
+
+async function postLepaoApi(pathSuffix, account, extraFields = {}, options = {}) {
+    const raw = buildSignedRaw(account, extraFields)
+    const form = new URLSearchParams()
+    form.append('ostype', '5')
+    form.append('data', dataEncrypt(JSON.stringify(raw)))
+
+    const res = await postLepaoSchool(`${BASE_URL}${pathSuffix}`, form, {
+        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 || DEFAULT_USER_AGENT
+        },
+        timeout: options.timeout || 20000,
+        logger: options.logger
+    })
+
+    return unwrapLepaoResponse(res.data)
+}
+
+async function getCurrentTerm(account, options = {}) {
+    const result = await postLepaoApi('/WpRun/getTermList', account, {}, options)
+    const list = Array.isArray(result.data) ? result.data : []
+    const current = list.find(item => String(item.status) === '1')
+    if (!current?.term_id) {
+        const err = new Error('未找到当前学期')
+        err.code = 'LEPAO_TERM_NOT_FOUND'
+        throw err
+    }
+    return current
+}
+
+async function getTermRunRecord(account, { termId, page = 1 } = {}, options = {}) {
+    const currentPage = Number(page)
+    return postLepaoApi(
+        '/WpRun/getTermRunRecord',
+        account,
+        {
+            term_id: String(termId),
+            page: Number.isFinite(currentPage) && currentPage > 0 ? currentPage : 1
+        },
+        options
+    )
+}
+
+module.exports = {
+    getCurrentTerm,
+    getTermRunRecord
+}

+ 42 - 0
lib/Lepao/lepaoSchoolHttp.js

@@ -0,0 +1,42 @@
+const { forwardRequest, briefUrlPath } = require('../ProxyForwardClient')
+
+function lepaoHttpLogLabel(traceId, mqTaskId) {
+    let s = ''
+    if (traceId) s += `[${traceId}] `
+    s += '[lepaoSchoolHttp]'
+    if (mqTaskId) s += ` [${mqTaskId}]`
+    return s
+}
+
+/**
+ * 对 lepao 学校 API 的 POST,经 RunForge-ProxyServer 转发(失败时由代理服务自动直连回退)。
+ */
+async function postLepaoSchool(url, data, options = {}) {
+    const {
+        headers = {},
+        timeout = 15000,
+        logger = null,
+        outboundMode = 'auto',
+        mqTaskId = null,
+        traceId = null
+    } = options
+
+    const logPrefix = lepaoHttpLogLabel(traceId, mqTaskId)
+    logger?.info?.(`${logPrefix} POST ${briefUrlPath(url)} mode=${outboundMode}`)
+
+    return forwardRequest({
+        method: 'post',
+        url,
+        data,
+        headers,
+        timeout,
+        outboundMode,
+        logger,
+        logPrefix,
+        scene: 'lepao_school'
+    })
+}
+
+module.exports = {
+    postLepaoSchool
+}

+ 121 - 0
lib/Lepao/officialAccountAccess.js

@@ -0,0 +1,121 @@
+const db = require('../../plugin/DataBase/db')
+const AccessControl = require('../AccessControl')
+const { BaseStdResponse } = require('../../BaseStdResponse')
+
+function isMissing(value) {
+    return value === '' || value === null || value === undefined
+}
+
+function errorResponse(base, msg) {
+    return { ...base, msg }
+}
+
+async function queryAccount(studentNum) {
+    const rows = await db.query(
+        `SELECT uid, token, school_id, student_num, userAgent, state, create_user
+         FROM lepao_account
+         WHERE student_num = ?`,
+        [studentNum]
+    )
+    return rows?.[0] || null
+}
+
+function validateRunnableAccount(account) {
+    if (!account) {
+        return errorResponse(BaseStdResponse.ERR, '未找到该乐跑账号')
+    }
+    if (account.state !== 1) {
+        return errorResponse(BaseStdResponse.ERR, '仅状态为正常的账号可查看官方记录')
+    }
+    if (!account.uid || !account.token || !account.school_id) {
+        return errorResponse(BaseStdResponse.ERR, '乐跑账号登录信息不完整,请使用登录器更新账号')
+    }
+    return null
+}
+
+async function loadUserOfficialAccount(uuid, studentNum) {
+    const account = await queryAccount(studentNum)
+    if (!account || account.create_user !== uuid) {
+        return {
+            error: errorResponse(BaseStdResponse.ERR, '未找到该乐跑账号或无权限操作')
+        }
+    }
+
+    const invalid = validateRunnableAccount(account)
+    if (invalid) return { error: invalid }
+
+    return { account }
+}
+
+async function loadAdminOfficialAccount(uuid, studentNum) {
+    const permission = await AccessControl.getPermission(uuid)
+    if (!permission.includes('admin') && !permission.includes('service') && !permission.includes('server')) {
+        return {
+            error: BaseStdResponse.PERMISSION_DENIED
+        }
+    }
+
+    const account = await queryAccount(studentNum)
+    const invalid = validateRunnableAccount(account)
+    if (invalid) return { error: invalid }
+
+    return { account }
+}
+
+function isLoginExpiredError(error) {
+    const msg = error?.message || ''
+    return (
+        error?.code === 'LEPAO_LOGIN_EXPIRED' ||
+        String(msg).includes('重新登录') ||
+        String(msg).includes('登录已过期') ||
+        String(msg).includes('登录失效')
+    )
+}
+
+async function markAccountLoginExpired(studentNum, logger) {
+    if (!studentNum) return
+    try {
+        await db.query('UPDATE lepao_account SET state = 0, update_time = ? WHERE student_num = ?', [
+            Date.now(),
+            studentNum
+        ])
+        logger?.warn?.(`${studentNum} 官方乐跑登录已失效,账号状态已更新为未登录`)
+    } catch (updateError) {
+        logger?.error?.(`更新乐跑账号登录状态失败 ${studentNum}: ${updateError.stack || updateError}`)
+    }
+}
+
+async function mapOfficialError(error, studentNum, logger) {
+    if (isLoginExpiredError(error)) {
+        await markAccountLoginExpired(studentNum, logger)
+        return errorResponse(BaseStdResponse.ERR, '乐跑登录已过期,请使用登录器更新账号')
+    }
+    const msg = error?.message || '获取官方乐跑记录失败'
+    return errorResponse(BaseStdResponse.ERR, msg)
+}
+
+/** 官方学期记录接口返回的 total_score_num 同步到 lepao_account.total_num */
+async function syncOfficialTotalNum(studentNum, officialData, logger) {
+    if (!studentNum || !officialData) return
+    const totalScoreNum = Number(officialData.total_score_num)
+    if (!Number.isFinite(totalScoreNum)) return
+
+    try {
+        const rows = await db.query(
+            'UPDATE lepao_account SET total_num = ? WHERE student_num = ?',
+            [totalScoreNum, studentNum]
+        )
+    } catch (error) {
+        logger?.error?.(`同步官方 total_num 失败 ${studentNum}: ${error.stack || error}`)
+    }
+}
+
+module.exports = {
+    isMissing,
+    loadUserOfficialAccount,
+    loadAdminOfficialAccount,
+    isLoginExpiredError,
+    markAccountLoginExpired,
+    mapOfficialError,
+    syncOfficialTotalNum
+}

+ 311 - 0
lib/Lepao/qgOssPut.js

@@ -0,0 +1,311 @@
+/**
+
+ * OSS 上传:直连阿里云(ali-oss SDK 自行签名);乐跑学校 API 仍经 RunForge 代理。
+
+ */
+
+const OSS = require('ali-oss')
+
+const { forwardRequest } = require('../ProxyForwardClient')
+
+
+
+function ossLogLabel(traceId, taskId) {
+
+    let s = ''
+
+    if (traceId) s += `[${traceId}] `
+
+    s += '[ossPut]'
+
+    if (taskId) s += ` [${taskId}]`
+
+    return s
+
+}
+
+
+
+function buildOssClient(sts, timeoutMs) {
+
+    return new OSS({
+
+        bucket: sts.bucket,
+
+        region: sts.region || 'oss-cn-hangzhou',
+
+        accessKeyId: sts.AccessKeyId,
+
+        accessKeySecret: sts.AccessKeySecret,
+
+        stsToken: sts.SecurityToken,
+
+        secure: true,
+
+        timeout: timeoutMs
+
+    })
+
+}
+
+
+
+/** ali-oss urllib 期望 { status, statusCode, headers, data, res } */
+
+function toUrllibResponse(resp) {
+
+    const status = resp?.status ?? resp?.statusCode
+
+    if (status == null) {
+
+        const err = new Error('代理转发未返回有效 HTTP status')
+
+        err.code = 'PROXY_FORWARD_BAD_RESPONSE'
+
+        throw err
+
+    }
+
+
+
+    let data = resp.data
+
+    if (data == null || data === '') {
+
+        data = Buffer.alloc(0)
+
+    } else if (!Buffer.isBuffer(data)) {
+
+        data = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data))
+
+    }
+
+
+
+    const headers = resp.headers || {}
+
+    return {
+
+        status,
+
+        statusCode: status,
+
+        headers,
+
+        data,
+
+        res: { statusCode: status, headers }
+
+    }
+
+}
+
+
+
+function patchOssClientForForward(client, { outboundMode, timeout, logger, logPrefix, scene, ossPath }) {
+
+    const urllibMod = client.urllib
+
+    const originalRequest = urllibMod.request.bind(urllibMod)
+
+
+
+    async function runForward(reqUrl, reqOpt) {
+
+        const method = (reqOpt.method || 'GET').toUpperCase()
+
+        const headers = reqOpt.headers || {}
+
+        const body = reqOpt.content ?? reqOpt.body
+
+
+
+        if (outboundMode === 'direct') {
+
+            return originalRequest(reqUrl, reqOpt)
+
+        }
+
+
+
+        try {
+
+            const resp = await forwardRequest({
+
+                method,
+
+                url: reqUrl,
+
+                data: body,
+
+                headers,
+
+                timeout: reqOpt.timeout || timeout,
+
+                outboundMode,
+
+                logger,
+
+                logPrefix,
+
+                scene: scene || 'oss_put',
+
+                responseType: 'arraybuffer',
+
+                validateStatus: () => true
+
+            })
+
+
+
+            return toUrllibResponse(resp)
+
+        } catch (e) {
+
+            if (outboundMode === 'proxy') throw e
+
+            logger?.warn?.(`${logPrefix} OSS 经代理失败,改 SDK 直连: ${e.message || e}`)
+
+            return originalRequest(reqUrl, reqOpt)
+
+        }
+
+    }
+
+
+
+    // 兼容 urllib 的 callback / Promise 双模式,避免 ali-oss 收到 undefined status
+
+    urllibMod.request = function patchedRequest(url, args, callback) {
+
+        if (arguments.length === 2 && typeof args === 'function') {
+
+            callback = args
+
+            args = null
+
+        }
+
+        args = args || {}
+
+
+
+        if (typeof callback === 'function') {
+
+            runForward(url, args).then(
+
+                (result) => {
+
+                    if (!result || result.status == null) {
+
+                        callback(new Error('OSS HTTP 响应缺少 status'))
+
+                        return
+
+                    }
+
+                    callback(null, result.data, result.res)
+
+                },
+
+                (err) => callback(err)
+
+            )
+
+            return
+
+        }
+
+
+
+        return runForward(url, args)
+
+    }
+
+
+
+    logger?.info?.(`${logPrefix} PUT ${ossPath} mode=${outboundMode}`)
+
+}
+
+
+
+/**
+
+ * @param {object} sts OSS STS 凭证
+
+ * @param {string} ossPath 对象 key
+
+ * @param {Buffer|string} content
+
+ * @param {{ logger?: object, traceId?: string, taskId?: string, outboundMode?: 'auto'|'direct'|'proxy', timeout?: number }} options
+
+ */
+
+async function putOssWithQgOutbound(sts, ossPath, content, options = {}) {
+
+    const {
+
+        logger = null,
+
+        traceId = null,
+
+        taskId = null,
+
+        outboundMode = 'direct',
+
+        timeout = 60000
+
+    } = options
+
+
+
+    const logPrefix = () => ossLogLabel(traceId, taskId)
+
+
+
+    if (outboundMode === 'direct') {
+
+        logger?.info?.(`${logPrefix()} PUT 直连 ${ossPath}`)
+
+        const client = buildOssClient(sts, timeout)
+
+        return client.put(ossPath, content)
+
+    }
+
+
+
+    const client = buildOssClient(sts, timeout)
+
+    patchOssClientForForward(client, {
+
+        outboundMode,
+
+        timeout,
+
+        logger,
+
+        logPrefix: logPrefix(),
+
+        scene: 'oss_put',
+
+        ossPath
+
+    })
+
+
+
+    return client.put(ossPath, content)
+
+}
+
+
+
+module.exports = {
+
+    putOssWithQgOutbound
+
+}
+
+

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