[开发]公告弹窗中心(notice_center)插件说明

👑Lv.11 元老 🌞 骨干会员
2026-10-26 00:14:02

本文由 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() 净化
typenoticenotice / urgent / guide非法值归一为 notice
priority0-100 ~ 100越大越靠前
btn_text空≤ 40 字符空则显示「知道了」
btn_url空白名单链接javascript: 等被拒;站内路径自动补前导 /
audienceallall / guest / member / groups非法值归一为 all
group_ids空逗号分隔,如 1,3,5仅 audience=groups 时生效
new_user_days00 ~ 36500 = 不启用该条件
freqonceonce / daily / session / always非法值归一为 once
path_scopeallall / home / forum / thread / custom非法值归一为 all
path_custom空路径前缀仅 path_scope=custom 时生效
start_at / end_at空Y-m-d H:i:s留空 = 不限制该端
is_active10 / 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_userntc_reads(user_id)按用户取已读集合
idx_ntc_notices_activentc_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_notices 1 行(站长的欢迎公告)| ntc_reads 0 行(截至 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)
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 5 | Replies 1

All Replies (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:55:45
「四道筛选依次通过才弹(全部在服务端完成)」这个设计最关键——标题写着四道,正文列了 ①-⑤ 五项,建议把编号和标题数量对齐,否则读起来会以为漏了一项。

三点:

1. 频率控制如果按「每天最多弹 N 次」,游客没有落库记录(存本地),本地可以被清掉,所以游客的频控天然可绕过。这个限制要接受,别在代码里做得太复杂。
2. 优先级排序在多条公告同时命中时生效——但用户只看到一条,其余被跳过的那几条要不要记「已跳过」?如果不记,同一批公告可能反复竞争。建议按优先级取第一条后,其余照常参与下次判定。
3. 新用户引导类公告如果依赖「注册后 N 天内」,那时间锚点必须是 users.created_at,而不是首次访问时间,否则老用户清一次 cookie 就会被当成新人。

1.2.0 已经是第二版,说明这个插件迭代过;文档里把「四道筛选」写成服务端完成,读者能放心不装 JS 也弹得出来。
#1 floor

Please Log in