Browse Source

✨ feat: 增加公告管理接口并补充联调文档

新增 notice 横幅公告管理 API(列表/保存/删除),并同步更新功能契约文档,支持前端独立公告管理板块接入。

Made-with: Cursor
Pchen0 6 days ago
parent
commit
893a87c77f

+ 32 - 0
apis/Notice/Admin/Delete.js

@@ -0,0 +1,32 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+
+class AdminNoticeDelete extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Notice')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, key } = req.body
+        if ([uuid, session, key].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") && !permission.includes("server"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const rows = await db.query('DELETE FROM notice WHERE `key` = ?', [String(key)])
+        if (!rows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+        if (rows.affectedRows !== 1) return res.json({ ...BaseStdResponse.ERR, msg: '公告不存在' })
+
+        return res.json({ ...BaseStdResponse.OK })
+    }
+}
+
+module.exports.AdminNoticeDelete = AdminNoticeDelete

+ 63 - 0
apis/Notice/Admin/List.js

@@ -0,0 +1,63 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+
+class AdminNoticeList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Notice/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, keyword, 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("service") && !permission.includes("server"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+        if (keyword) {
+            where.push('(`key` LIKE ? OR content LIKE ?)')
+            params.push(`%${keyword}%`, `%${keyword}%`)
+            countParams.push(`%${keyword}%`, `%${keyword}%`)
+        }
+        const whereSql = where.join(' AND ')
+
+        const listSql = `
+            SELECT \`key\`, content
+            FROM notice
+            WHERE ${whereSql}
+            ORDER BY \`key\` ASC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM notice 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.AdminNoticeList = AdminNoticeList

+ 39 - 0
apis/Notice/Admin/Upsert.js

@@ -0,0 +1,39 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+
+class AdminNoticeUpsert extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Notice')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, key, content } = req.body
+        if ([uuid, session, key, content].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") && !permission.includes("server"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const safeKey = String(key).trim()
+        if (!safeKey) return res.json({ ...BaseStdResponse.ERR, msg: '公告标识不能为空' })
+
+        const sql = `
+            INSERT INTO notice (\`key\`, content)
+            VALUES (?, ?)
+            ON DUPLICATE KEY UPDATE content = VALUES(content)
+        `
+        const rows = await db.query(sql, [safeKey, String(content)])
+        if (!rows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({ ...BaseStdResponse.OK })
+    }
+}
+
+module.exports.AdminNoticeUpsert = AdminNoticeUpsert

+ 65 - 0
docs/lepao_feature_contract_20260430.md

@@ -0,0 +1,65 @@
+# 乐跑功能改造联调说明(2026-04-30)
+
+本文用于前端联调,覆盖本次后端新增的绑定审计、乐跑记录类型/公开 ID、首页弹窗公告接口。
+
+## 1) 乐跑记录接口变更
+
+- `GET /Lepao/Records`
+  - 新增字段:`public_id`、`run_mode`(`manual` / `auto`)
+  - 不再返回 `id`(用户侧)
+- `GET /Lepao/GetRecordDetail`
+  - 优先参数:`public_id`
+  - 兼容参数:`id`(仅过渡期)
+  - 返回含 `public_id`、`run_mode`
+- `GET /Admin/Lepao/Records`
+  - 新增字段:`public_id`、`run_mode`
+- `GET /Admin/Lepao/GetRecordDetail`
+  - 支持 `public_id`,兼容 `id`
+  - 返回含 `public_id`、`run_mode`
+
+## 2) 绑定/解绑审计接口
+
+- 用户侧(本人相关)
+  - `GET /Lepao/BindAudit/List`
+  - 参数:`uuid` `session` `pagesize` `current`,可选 `student_num` `queryTime[]`
+  - 说明:工单/管理员等解绑在该接口统一映射为 `系统解绑`
+- 管理员账号行内查看
+  - `GET /Admin/Lepao/BindAudit/ByAccount`
+  - 参数:`uuid` `session` `student_num` `pagesize` `current`,可选 `queryTime[]`
+- 管理员全局审计页
+  - `GET /Admin/Lepao/BindAudit/List`
+  - 参数:`uuid` `session` `pagesize` `current`
+  - 可选筛选:`student_num` `owner_uuid` `operator_uuid` `action` `source` `queryTime[]`
+
+## 3) 首页弹窗公告接口
+
+- 用户侧
+  - `GET /Popup/Unread`
+    - 参数:`uuid` `session`,可选 `limit`
+    - 说明:当公告 `repeat_show=1` 时,即使该用户已读,后续进入站点仍会继续展示
+  - `POST /Popup/MarkRead`
+    - body:`uuid` `session` `popup_id`
+- 管理员侧
+  - `GET /Admin/Popup/List`(列表)
+    - 参数:`uuid` `session` `pagesize` `current`,可选 `title` `is_active`
+  - `POST /Admin/Popup`(新增)
+    - body:`uuid` `session` `title` `content_html`,可选 `priority` `is_active` `repeat_show` `start_at` `end_at`
+  - `PUT /Admin/Popup`(编辑)
+    - body:`uuid` `session` `id`,其余字段按需提交
+  - `DELETE /Admin/Popup`(删除)
+    - body:`uuid` `session` `id`
+  - `GET /Admin/Popup/ReadList`(已读用户)
+    - 参数:`uuid` `session` `popup_id` `pagesize` `current`,可选 `keyword`
+
+## 4) 字段说明
+
+- `run_mode`
+  - `manual`:用户单次乐跑触发
+  - `auto`:定时/补跑/队列触发
+- 审计 `action`
+  - `platform_bind` / `platform_unbind` / `bot_bind` / `bot_unbind`
+- 审计 `source`
+  - `user_api` / `admin_api` / `service_api` / `mcp_qq` / `mcp_work_order`
+- 弹窗 `repeat_show`
+  - `1`:连续展示(已读后再次进入仍展示)
+  - `0`:默认逻辑(已读后不再展示)