本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/notice_center/说明.md。
公告弹窗中心(notice_center)插件说明
版本:1.2.0 | 作者:FlintHub | 状态:✅ 已启用
功能:全站公告弹窗 —— 后台发一条公告,满足条件的访客进站时自动弹出。
支持受众定向(所有人 / 游客 / 登录用户 / 指定用户组)、生效时间窗、弹出频率控制、
页面范围限定、优先级排序、已读持久化(登录用户落库 / 游客落本地)。
一、功能简介
1.1 一条公告由什么决定「弹不弹」
四道筛选依次通过才弹(全部在服务端完成):
① 总开关开启(后台「启用」勾选)
② 公告本身 is_active = 1
③ 时间窗命中:start_at ≤ 现在 ≤ end_at(留空 = 不限制该端)
④ 受众命中:all / guest / member / groups(指定用户组)
⑤ 新用户条件:new_user_days > 0 时,仅注册未满 N 天的用户可见
⑥ 页面范围命中:all / home / forum / thread / custom(自定义路径前缀)再叠加频率控制(freq,客户端判定):
| freq | 语义 | 已读存哪 |
|---|---|---|
once | 只弹一次,之后永久不再弹 | 登录用户 → ntc_reads 表;游客 → localStorage |
daily | 每天弹一次 | localStorage 记日期 |
session | 每个会话弹一次 | sessionStorage |
always | 每次访问都弹 | 不记录 |
1.2 只弹一条
Plugin::MAX_POPUP = 5 —— 服务端最多渲染 5 条候选到 DOM,
但前端过滤后只显示优先级最高的一条(决策 5:避免弹窗轰炸)。
多渲染几条是给「已读过滤后仍有备选」留余地。
1.3 弹窗外观
三种类型,只影响标题颜色:
| type | 语义 | 视觉 |
|---|---|---|
notice | 普通公告 | 默认色 |
urgent | 紧急通知 | 标题 --mn-error(红) |
guide | 新用户引导 | 标题 --mn-primary(主色) |
弹窗结构:遮罩 + 对话框(标题 / 富文本正文 / 一个可选按钮)。
按钮文字留空则默认显示「知道了」,按钮链接留空则按钮不跳转、只关闭。
二、弹窗判定与渲染链路
访客请求页面
│
├─ layout_head_end ─→ Plugin::shouldInject($template)
│ ├─ 无候选(无 cache.json)→ return,连 CSS/JS 都不加载
│ └─ 有候选 → 输出 + 为什么 DOM 走服务端直出:弃用了「<template> + JS 克隆」方案 ——
DOM 直接进 HTML 更简单、无闪烁,且不需要额外接口取数(用户 2026-09-30 拍板)。
三、路由与接口
3.1 前台(hook/route_register.php,路径写全)
| 方法 | 路径 | 说明 | 登录 | CSRF |
|---|---|---|---|---|
| POST | /notice/read | 登录用户标记已读(幂等) | ✅ 必需 | ✅ 必需 |
- 游客调用 → 401;CSRF 失败 → 403;
id ≤ 0→ 400。 user_id只从 session 取,不接受客户端传参 → 越权面归零。- 必须 POST:Service Worker 对同源 GET 非导航请求一律 Cache-First,
GET 会被缓存吞掉,已读永远写不进库(规范 §11.5 第 35 条)。
3.2 后台(hook/admin_route_register.php,不写 /admin 前缀,实际路径自动带)
| 方法 | 实际路径 | 说明 |
|---|---|---|
| GET | /admin/notice-center | 公告列表 + 总开关 |
| GET | /admin/notice-center/edit | 新增表单 |
| GET | /admin/notice-center/edit/{id} | 编辑表单 |
| POST | /admin/notice-center/save | 保存(新增 / 编辑共用) |
| POST | /admin/notice-center/delete | 删除 |
| POST | /admin/notice-center/toggle | 启用 / 停用单条 |
| POST | /admin/notice-center/sort | 上移 / 下移(`dir=up\ |
| POST | /admin/notice-center/settings | 保存全局总开关 |
全部写操作均为 POST + 表单内 CSRF,走 PRG(302 → GET),提示文案存 $_SESSION 读后即焚。
四、后台可配项
| 设置项 | 键名 | 默认 | 范围 | 说明 |
|---|---|---|---|---|
| 总开关 | notice_center_enabled | '1'(开) | '0' / '1' | 关掉后前台连 CSS/JS 都不加载 |
公告级字段(每条公告单独设置):
| 字段 | 默认 | 范围 / 取值 | 说明 |
|---|---|---|---|
title | — | ≤ 200 字符,必填 | 空标题不落库 |
content | — | 富文本 | 存原文,渲染时走 Content::formatPostContent() 净化 |
type | notice | notice / urgent / guide | 非法值归一为 notice |
priority | 0 | -100 ~ 100 | 越大越靠前 |
btn_text | 空 | ≤ 40 字符 | 空则显示「知道了」 |
btn_url | 空 | 白名单链接 | javascript: 等被拒;站内路径自动补前导 / |
audience | all | all / guest / member / groups | 非法值归一为 all |
group_ids | 空 | 逗号分隔,如 1,3,5 | 仅 audience=groups 时生效 |
new_user_days | 0 | 0 ~ 3650 | 0 = 不启用该条件 |
freq | once | once / daily / session / always | 非法值归一为 once |
path_scope | all | all / home / forum / thread / custom | 非法值归一为 all |
path_custom | 空 | 路径前缀 | 仅 path_scope=custom 时生效 |
start_at / end_at | 空 | Y-m-d H:i:s | 留空 = 不限制该端 |
is_active | 1 | 0 / 1 | 单条启停 |
五、数据表
独立库:plugins/notice_center/data/notice_center.sqlite
| 表名 | 用途 | 关键结构 |
|---|---|---|
ntc_notices | 公告主表(18 列) | id 主键;is_active / priority / type / audience / freq / path_scope / 时间窗 / 按钮字段 |
ntc_reads | 已读记录 | 复合主键 (notice_id, user_id) → INSERT OR IGNORE 天然幂等;read_at 记时间 |
索引(一律排在 CREATE TABLE 之后,规范 §10.3):
| 索引 | 列 | 用途 |
|---|---|---|
idx_ntc_reads_user | ntc_reads(user_id) | 按用户取已读集合 |
idx_ntc_notices_active | ntc_notices(is_active, priority) | 列表 / 候选集排序 |
候选集文件缓存:data/cache.json
- 内容 = 「启用 + 时间窗内」的公告(与用户无关的公共数据),按
priority DESC, id DESC。 - 结果是空时删掉该文件 → 前台
hasCandidates()靠is_file()短路,整条链路零 SQLite 查询。 - 写入用
.tmp+LOCK_EX+rename原子替换,避免读到半截 JSON。 - 格式版本
CACHE_VERSION = 1:改缓存结构必须 +1,旧缓存自动视为失效。
六、目录结构
plugins/notice_center/
├── plugin.json 插件元信息(5 个 hooks / 2 个权限)
├── Plugin.php 主类(894 行):建表 / 生命周期 / 规则引擎 / 缓存 / 渲染
├── AdminController.php 后台控制器(413 行):列表 / 表单 / 保存 / 删除 / 启停 / 排序 / 设置
├── FrontController.php 前台控制器(55 行):只做 POST /notice/read
├── hook/
│ ├── init_after.php 惰性建表兜底(命中戳文件后零查询)
│ ├── route_register.php 前台路由(POST /notice/read)
│ ├── admin_route_register.php 后台路由(8 条)
│ ├── layout_head_end.php 只输出 + 七、注意事项
7.1 依赖一个本地新增的核心钩子
layout_body_end 定义在 app/Views/layouts/main.php:534(</body> 前),
不是上游原生钩子 —— 与上游核心合并 main.php 时必须保留该段
(源码内已有 [FLINTHUB-CUSTOM 2026-09-30] 标记,规范见 docs/插件开发规范.md §9.1 #43)。
该钩子无参数 → 判「是否后台页」只能靠请求路径(Plugin::isBackend('') 里的 /admin 前缀),
不能只靠模板名。
7.2 游客版本会被整页缓存(PageCache 仅游客)
views/popup.php 里 data-ntc-csrf / data-ntc-read 只对登录用户输出。
若对游客也输出,会把某个游客的已读状态烘焙进公共缓存 → 全站串号。
7.3 改前端资源不需要手动 bump 版本号
layout_head_end.php 的 ?v= 取的是 filemtime() —— 改文件即变,
自动破 asset() 的 immutable 缓存。不要改成手写常量。
7.4 改 DDL 必须 +1 SCHEMA_VERSION
建表走戳文件内容门控(data/schema.version 存 'notice_center-schema-1'):
改了 ddlStatements() 但版本号不变 → 戳命中、新表/新索引静默建不出来。
7.5 目录名含下划线 → 必须保留 class_alias
目录名 notice_center 与命名空间 Plugin\NoticeCenter 差一个下划线。
核心按目录名硬拼 \Plugin\notice_center\Plugin → class_exists() 恒为 false
→ activate()/deactivate()/uninstall() 会被静默跳过,卸载留孤儿表。
Plugin.php 末尾的 class_alias 是唯一解法(规范 §4.1),不可删。
正因如此,建表另有 init_after 钩子 + ensureTable() 双保险。
7.6 渲染钩子在闭包作用域,$this 不可用
hook/layout_*.php 与 views/popup.php 里禁止 $this->xxx()(规范 §11.24)。
症状极隐蔽:HTML 渲染半截 + HTTP 200 + 无告警。转义一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8'),
i18n 一律 I18n::get() 静态调用。
7.7 排序是「整表重排」,不是相邻互换
AdminController::sort() 换位后按新顺序重排整张表 priority = n … 1。
原因:相邻两条 priority 相等时「互换」等于没换。事务包裹,中途失败不留半新半旧。
已在首位再上移 / 末位再下移 → 静默返回(302,无副作用)。
7.8 零内联红线
视图与 JS 里不得出现 <style> 块 / <script> 块 / style="" 属性 / on* 事件属性。
所有行为绑定在 assets/script.js、assets/admin.js 里完成(规范 §7.3)。
7.9 卸载清理
uninstall() 按序执行:DROP ntc_reads / ntc_notices → 删核心库 settings 里的
notice_center_enabled → 清 Settings 缓存 → 删 data/schema.version → 清页面缓存。
顺序不可反(规范 §4.1)。
7.10 改布局级钩子后必须清页面缓存
layout_head_end / layout_body_end 的输出会进游客整页缓存。
后台任何写操作都调了 afterWrite()(重建候选集缓存 + PageCache::invalidate())——
新增写路径时别忘了挂它,否则表现为「改了没生效」(规范 §11.5 第 31 条)。
7.11 已知取舍
- 后台实时预览的正文不净化:
admin.js把编辑器 HTML 直接塞进预览容器。
仅管理员可见、且真实弹窗渲染时走 Content::formatPostContent() 净化,风险自伤级别,暂不处理。
ntc-notice-notice类无对应 CSS:notice类型无专属样式(走.ntc-notice基础样式),
该类名属冗余但无害。
7.12 编辑器提交的是 base64,入库前必须 Content::decode()
核心富文本编辑器提交的内容是 base64(HTML) 提交态,不是 HTML。
保存时必须先解码再入库(AdminController::save() 已按核心同口径处理):
$content = \app\Helpers\Content::decode(\trim((string)($_POST['content'] ?? '')));⚠️ 漏掉这一步极其隐蔽:库里存的是 base64 串 → 前台 Content::formatPostContent()
把它当正文原样渲染成一大段乱码;而后台编辑页因走 $this->decodeContent() 回显,
看起来完全正常 —— 只看后台根本发现不了。
2026-09-30 实际踩过:公告 id=1 前台显示 base64,后台编辑页一切正常。
修复 = 保存端补
Content::decode()+ 存量数据一次性解码(迁移脚本见
.workbuddy-ai/tmp/ntc_fix_base64.php,带 dry-run 与整表备份)。
Content::decode() 对「首字符 < 的 HTML」不匹配其 base64 正则 → 原样返回,
因此对已解码内容是幂等的,编辑 → 保存的往返不会破坏正文。
同口径参考:ThreadController:110、PostController:77、Admin\ThreadController:83。
八、当前状态
- 启用状态:✅ 已启用(
plugin.json的activated = true) - 数据量:
ntc_notices1 行(站长的欢迎公告)|ntc_reads0 行(截至 2026-09-30) - 候选集缓存:存在(1 条启用公告)
- 版本常量:
SCHEMA_VERSION = 'notice_center-schema-1'|CACHE_VERSION = 1|MAX_POPUP = 5 - 语言键:zh / en / zh_tw 各 88 键,键集合零差异、键序一致
- 验证记录(2026-09-30):
- P0 后台 CRUD 冒烟 33 PASS / 0 FAIL
- P0 逻辑探针 54 PASS / 0 FAIL
- P1/P2 前台弹窗冒烟 44 PASS / 0 FAIL
- P2 规则引擎探针 65 PASS / 0 FAIL
- P3 排序专项冒烟 25 PASS / 0 FAIL
- 保存链路 base64 专项探针 16 PASS / 0 FAIL(见 §7.12)
- 查询预算实测:无候选 0 次 | 有候选 + 游客 0 次 | 有候选 + 登录 1 次(红线 ≤5)