Browse Source

✨ feat: 新增首页弹窗公告与连续展示配置

补充弹窗公告的用户未读/已读与管理端 CRUD/已读列表能力,并支持 repeat_show 连续展示开关及内容清洗,提升公告运营可控性与安全性。

Made-with: Cursor
Pchen0 6 days ago
parent
commit
d06e746293

+ 76 - 0
apis/Popup/Admin/Create.js

@@ -0,0 +1,76 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+const { sanitizeHtml } = require("../../../lib/SanitizeHtml.js")
+
+function normalizeDateTime(value) {
+    if (value === undefined || value === null || value === '') return null
+    const n = Number(value)
+    if (!Number.isNaN(n) && Number.isFinite(n)) {
+        const d = new Date(n)
+        if (!Number.isNaN(d.getTime())) {
+            return d.toISOString().slice(0, 19).replace('T', ' ')
+        }
+    }
+    return String(value)
+}
+
+class AdminCreatePopup extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Popup')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            title,
+            content_html,
+            priority,
+            is_active,
+            repeat_show,
+            start_at,
+            end_at
+        } = req.body
+
+        if ([uuid, session, title, content_html].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"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const cleanHtml = sanitizeHtml(content_html)
+        const now = Date.now()
+        const sql = `
+            INSERT INTO site_popup
+                (title, content_html, priority, is_active, repeat_show, start_at, end_at, created_by, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        `
+        const rows = await db.query(sql, [
+            String(title).trim(),
+            cleanHtml,
+            Number(priority) || 0,
+            Number(is_active) === 0 ? 0 : 1,
+            Number(repeat_show) === 1 ? 1 : 0,
+            normalizeDateTime(start_at),
+            normalizeDateTime(end_at),
+            uuid,
+            now,
+            now
+        ])
+        if (!rows || rows.affectedRows !== 1) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: { id: rows.insertId }
+        })
+    }
+}
+
+module.exports.AdminCreatePopup = AdminCreatePopup

+ 32 - 0
apis/Popup/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 AdminDeletePopup extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Popup')
+        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("server"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const rows = await db.query('DELETE FROM site_popup WHERE id = ?', [id])
+        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.AdminDeletePopup = AdminDeletePopup

+ 69 - 0
apis/Popup/Admin/GetReadList.js

@@ -0,0 +1,69 @@
+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 AdminPopupReadList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Popup/ReadList')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, popup_id, keyword, pagesize, current } = req.query
+        if ([uuid, session, popup_id, 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 = ['r.popup_id = ?']
+        const params = [popup_id]
+        const countParams = [popup_id]
+        if (keyword) {
+            where.push('(u.uuid COLLATE utf8mb4_general_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci OR u.username COLLATE utf8mb4_general_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${keyword}%`, `%${keyword}%`)
+            countParams.push(`%${keyword}%`, `%${keyword}%`)
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                r.popup_id,
+                r.user_uuid,
+                r.read_at,
+                u.username,
+                u.avatar
+            FROM site_popup_read r
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = r.user_uuid COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+            ORDER BY r.read_at DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM site_popup_read r LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = r.user_uuid 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.AdminPopupReadList = AdminPopupReadList

+ 85 - 0
apis/Popup/Admin/List.js

@@ -0,0 +1,85 @@
+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 AdminPopupList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Popup/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, title, is_active, 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"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+        if (title) {
+            where.push('p.title LIKE ?')
+            params.push(`%${title}%`)
+            countParams.push(`%${title}%`)
+        }
+        if (is_active !== undefined && is_active !== null && is_active !== '') {
+            where.push('p.is_active = ?')
+            params.push(Number(is_active) === 1 ? 1 : 0)
+            countParams.push(Number(is_active) === 1 ? 1 : 0)
+        }
+
+        const whereSql = where.join(' AND ')
+        const listSql = `
+            SELECT
+                p.id,
+                p.title,
+                p.content_html,
+                p.priority,
+                p.is_active,
+                p.repeat_show,
+                p.start_at,
+                p.end_at,
+                p.created_by,
+                p.created_at,
+                p.updated_at,
+                (
+                    SELECT COUNT(*)
+                    FROM site_popup_read r
+                    WHERE r.popup_id = p.id
+                ) AS read_count
+            FROM site_popup p
+            WHERE ${whereSql}
+            ORDER BY p.id DESC
+            LIMIT ? OFFSET ?
+        `
+        const countSql = `SELECT COUNT(*) AS total FROM site_popup p 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.AdminPopupList = AdminPopupList

+ 94 - 0
apis/Popup/Admin/Update.js

@@ -0,0 +1,94 @@
+const API = require("../../../lib/API.js")
+const db = require("../../../plugin/DataBase/db.js")
+const AccessControl = require("../../../lib/AccessControl.js")
+const { BaseStdResponse } = require("../../../BaseStdResponse.js")
+const { sanitizeHtml } = require("../../../lib/SanitizeHtml.js")
+
+function normalizeDateTime(value) {
+    if (value === undefined || value === null || value === '') return null
+    const n = Number(value)
+    if (!Number.isNaN(n) && Number.isFinite(n)) {
+        const d = new Date(n)
+        if (!Number.isNaN(d.getTime())) {
+            return d.toISOString().slice(0, 19).replace('T', ' ')
+        }
+    }
+    return String(value)
+}
+
+class AdminUpdatePopup extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Popup')
+        this.setMethod('PUT')
+    }
+
+    async onRequest(req, res) {
+        let {
+            uuid,
+            session,
+            id,
+            title,
+            content_html,
+            priority,
+            is_active,
+            repeat_show,
+            start_at,
+            end_at
+        } = 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("server"))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const sets = ['updated_at = ?']
+        const params = [Date.now()]
+        if (title !== undefined) {
+            sets.push('title = ?')
+            params.push(String(title).trim())
+        }
+        if (content_html !== undefined) {
+            sets.push('content_html = ?')
+            params.push(sanitizeHtml(content_html))
+        }
+        if (priority !== undefined) {
+            sets.push('priority = ?')
+            params.push(Number(priority) || 0)
+        }
+        if (is_active !== undefined) {
+            sets.push('is_active = ?')
+            params.push(Number(is_active) === 0 ? 0 : 1)
+        }
+        if (repeat_show !== undefined) {
+            sets.push('repeat_show = ?')
+            params.push(Number(repeat_show) === 1 ? 1 : 0)
+        }
+        if (start_at !== undefined) {
+            sets.push('start_at = ?')
+            params.push(normalizeDateTime(start_at))
+        }
+        if (end_at !== undefined) {
+            sets.push('end_at = ?')
+            params.push(normalizeDateTime(end_at))
+        }
+
+        if (sets.length === 1) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '缺少更新字段' })
+        }
+
+        const sql = `UPDATE site_popup SET ${sets.join(', ')} WHERE id = ?`
+        params.push(id)
+        const rows = await db.query(sql, params)
+        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.AdminUpdatePopup = AdminUpdatePopup

+ 53 - 0
apis/Popup/GetUnread.js

@@ -0,0 +1,53 @@
+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 GetUnreadPopup extends API {
+    constructor() {
+        super()
+        this.setPath('/Popup/Unread')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, limit } = 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 take = Number(limit) > 0 ? Number(limit) : 1
+        const sql = `
+            SELECT
+                p.id,
+                p.title,
+                p.content_html,
+                p.priority,
+                p.repeat_show,
+                p.start_at,
+                p.end_at,
+                p.created_at,
+                p.updated_at
+            FROM site_popup p
+            LEFT JOIN site_popup_read r ON r.popup_id = p.id AND r.user_uuid COLLATE utf8mb4_general_ci = CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci
+            WHERE
+                p.is_active = 1
+                AND (p.repeat_show = 1 OR r.popup_id IS NULL)
+                AND (p.start_at IS NULL OR p.start_at <= NOW())
+                AND (p.end_at IS NULL OR p.end_at >= NOW())
+            ORDER BY p.priority DESC, p.id DESC
+            LIMIT ?
+        `
+        const rows = await db.query(sql, [uuid, String(take)])
+        if (!rows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows
+        })
+    }
+}
+
+module.exports.GetUnreadPopup = GetUnreadPopup

+ 39 - 0
apis/Popup/MarkRead.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 MarkPopupRead extends API {
+    constructor() {
+        super()
+        this.setPath('/Popup/MarkRead')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, popup_id } = req.body
+        if ([uuid, session, popup_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 popupRows = await db.query('SELECT id FROM site_popup WHERE id = ?', [popup_id])
+        if (!popupRows || popupRows.length === 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '公告不存在' })
+        }
+
+        const sql = `
+            INSERT INTO site_popup_read (popup_id, user_uuid, read_at)
+            VALUES (?, ?, ?)
+            ON DUPLICATE KEY UPDATE read_at = VALUES(read_at)
+        `
+        const now = Date.now()
+        const rows = await db.query(sql, [popup_id, uuid, now])
+        if (!rows) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+
+        return res.json({ ...BaseStdResponse.OK })
+    }
+}
+
+module.exports.MarkPopupRead = MarkPopupRead

+ 16 - 0
lib/SanitizeHtml.js

@@ -0,0 +1,16 @@
+const BLOCKED_TAG_PATTERN = /<\/?(script|style|iframe|object|embed|link|meta|base|form|input|button|textarea|select)[^>]*>/gi
+const EVENT_HANDLER_PATTERN = /\son[a-z]+\s*=\s*(['"]).*?\1/gi
+const JS_PROTOCOL_PATTERN = /\s(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi
+
+function sanitizeHtml(input) {
+    if (input === null || input === undefined) return ''
+    let html = String(input)
+
+    html = html.replace(BLOCKED_TAG_PATTERN, '')
+    html = html.replace(EVENT_HANDLER_PATTERN, '')
+    html = html.replace(JS_PROTOCOL_PATTERN, ' $1="#"')
+
+    return html.trim()
+}
+
+module.exports = { sanitizeHtml }