本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/热榜插件开发方案_2026-09-27.md。
论坛热榜插件开发方案(hot_topics · 右栏面板 + 独立页 /hot)
日期:2026-09-27 | 依据:
docs/插件开发规范.md(§9 钩子全表 / §11 红线 / §十三 查询预算)状态:待确认(本文只出方案,未创建任何
plugins/hot_topics/文件)参照实现:
plugins/leaderboard(同类:右栏面板 + 纯 CSS tab + TTL 懒刷新,已上线)⚠️ 插件目录名
hot_topics含下划线 → 触发规范 §4.1 硬坑(见 §六·风险 1),方案已内置处置
一、结论摘要
在右栏「站点统计」面板正上方新增一个 .mn-panel 面板(默认展示「近 24h · 回复榜」TOP 5 + 「查看完整热榜 →」),
并提供一个独立页 /hot(?range=24h|3d|7d × ?metric=replies|views,共 6 组)。
关键结论(全部经实测,非推断):
- 「放在站点统计上面」无需改核心 —— 核心已有现成钩子
right_sidebar_after(right_sidebar.php:211),
它的位置就在「站点统计」(:214–241)之前。→ 零核心改动即可精准落位。
- 规格里的数据源字段名几乎全错,必须纠正(见 §2.2):
created_at→create_time、last_reply_at→last_reply_time、forum_id→category_id,
且两个时间字段是 Unix 整数秒,不是 DATETIME 字符串。
- 权限过滤与全局缓存是天然冲突的 —— 榜单排序依赖「当前用户可见版块」,若把过滤后的榜存进全局缓存,
游客会看到私密版块标题。→ 采用「全局候选池(不按版块过滤)+ 每请求内存过滤」(见 §3.2,本方案最核心的设计点)。
view_count/reply_count上没有索引 → 纯按这两列排序会全表扫 + TEMP B-TREE。
本机 86 行量级实测 < 1ms,可接受;但方案按「候选池 + 缓存」把该扫描收敛到 TTL 刷新路径(5 分钟一次),不进每页路径。
- 不建表、不上 cron —— 文件缓存(
data/hot_cache.json)+route_after_dispatchTTL 懒刷新(照抄 leaderboard 范式)。
需先拍板的点:6 个(规格给的 4 个 + 我新增 2 个),见 §四。
二、现状核查(实测,非推断)
2.1 右栏结构 —— 「站点统计上面」的确切落位
app/Views/_components/right_sidebar.php(275 行)实测渲染顺序:
| 序 | 区块 | 行号 | 显示条件 |
|---|---|---|---|
| 1 | 发帖人信息栏 + 主题卡片 或 用户面板 | 41–207 | 详情页 / 其他页 |
| 2 | 🔌 钩子 right_sidebar_after | 211 | 无条件 |
| 3 | 站点统计面板 | 214–241 | $showSiteStats = 首页 / /forum / /forum/category/ / /blog |
| 4 | 热门标签面板 | 244–259 | 有 tags 且非 /tags 页 |
| 5 | 🔌 钩子 sidebar_above_friend_links | 263 | 无条件 |
| 6 | 友情链接面板 | 265–274 | 上述页面 + friend_links 已启用 |
→ 「站点统计正上方」= 钩子 right_sidebar_after(规范 §9.1 #14),现成、零核心改动。
- ✅ 权威依据:规范 §9.1 #14「右栏用户面板之后。所有渲染右栏的页面都触发(含帖子/博客详情页)」;
且 §11.26 明确分工:「想往右栏加整块面板(带 .mn-panel 标题栏)用 #14」。
- ⚠️ 触发范围比站点统计更宽:站点统计只在
$showSiteStats的 4 类页面显示,而本钩子在所有带右栏的页面触发
(含帖子/博客详情页)→ 需拍板「全站显示」还是「复刻站点统计的可见域」(§四·决策 5)。
- ⚠️
right_sidebar_after只在 PC 右栏触发一次(移动端侧栏mobile_sidebar.php里没有这个钩子,只有
sidebar_above_friend_links + home_sidebar_bottom)→ 无需「双触发」防护;但也意味着移动端看不到本面板
(移动端侧栏本来就没有站点统计,口径一致)→ 真正的入口靠 nav_plugin_links(§3.4)。
- ⚠️ 右栏在
@media (max-width:1024px)整块display:none。
2.2 数据源实测 —— topic_index 真实结构(纠正规格的错误假设)
app/SplitDB/Schema.php:780 的真实 DDL:
CREATE TABLE IF NOT EXISTS topic_index (
id INTEGER PRIMARY KEY,
uid INTEGER,
title TEXT,
create_time INTEGER, -- ⚠️ 不是 created_at;Unix 整数秒
last_reply_time INTEGER, -- ⚠️ 不是 last_reply_at;Unix 整数秒
view_count INTEGER DEFAULT 0,
reply_count INTEGER DEFAULT 0,
status INTEGER DEFAULT 0,
category_id INTEGER DEFAULT 0, -- ⚠️ 不是 forum_id
is_pinned INTEGER DEFAULT 0,
is_highlighted INTEGER DEFAULT 0,
color TEXT DEFAULT '',
reply_to_view INTEGER DEFAULT 0,
deleted_at INTEGER,
bucket_path TEXT,
is_archived INTEGER DEFAULT 0,
excerpt TEXT DEFAULT '',
excerpt_images TEXT DEFAULT ''
);规格假设 → 实际字段对照表:
| 规格里写的 | 实际字段 | 说明 |
|---|---|---|
created_at | create_time | Unix 整数秒 |
last_reply_at | last_reply_time | Unix 整数秒 |
forum_id | category_id | 版块 ID |
| (未提) | status | 0 = 正常;1 = 在回收站(Thread::STATUS_NORMAL = 0) |
| (未提) | deleted_at | IS NULL = 未软删 |
现有索引(Schema.php:812–822):
idx_topic_index_archive(is_archived, create_time)
idx_topic_index_list(is_pinned DESC, last_reply_time DESC, create_time DESC)
idx_topic_index_create_time(create_time)
idx_topic_index_category(category_id)
idx_topic_index_uid(uid)⚠️ view_count / reply_count 上没有任何索引 → ORDER BY reply_count DESC 会走全表扫 + TEMP B-TREE。
本机 topic_index 86 行(83 行有效),实测该排序约 < 1ms,可接受。
方案把该扫描收敛到「每 5 分钟一次的 TTL 刷新路径」,不进每页渲染路径(§3.2)。
读取入口(必须走这条,不能裸 PDO 直读文件):
\app\SplitDB\Schema::mainIndexDb()→ 返回\PDO(有prepare()),库文件data/meta/main_index.sqlite(WAL)。- ⚠️
app\Core\Database没有fetchColumn()/prepare()→ 误用会 fatal 且伪装成全 PASS(MEMORY §1)。 - ⚠️
main_index.sqlite是 WAL → 单拷不带-wal读不到表且不报错。
权限过滤接口(实测签名):
\app\Helpers\Permission::getAuthorizedCategoryIds(?int $userId = null): array
// 返回 int[] 版块 ID;管理员组(group_id=4)返回全部;
// 游客(userId=null → group_id=0)自动回退用 group=1 的权限;
// 内部走请求级缓存 permTable(),一请求最多 1 次查询。2.3 可复用范式:plugins/leaderboard(已上线的同类插件)
| 维度 | leaderboard 做法 | 本插件是否照抄 |
|---|---|---|
| 右栏挂载 | sidebar_above_friend_links(站点统计下方) | ❌ 改用 right_sidebar_after(上方) |
| 缓存 | 插件独立库 lb_cache 表 + TTL 300s | ⚠️ 改「文件缓存」(规格要求不建表) |
| 懒刷新 | route_after_dispatch + $GLOBALS['lb_need_refresh'] 收敛 | ✅ 完全照抄 |
| 冷启动 | 渲染路径同步算一次,避免首屏空面板 | ✅ 照抄 |
| CSS 注入 | layout_head_end(不做模板白名单) | ✅ 照抄(+ 独立页另用 section('css')) |
| tab | 纯 CSS radio + :checked ~(零 JS) | ✅ 照抄 |
| 图标 | 字面量 <i class="fa mn-fs-12"></i> | ✅ 照抄(不用 $view->icon(),其带内联 style) |
| 管理员判定 | 不涉及 | 本插件也不涉及(不做排除) |
2.4 设置存储机制(system:settings 权限对应什么)
\app\Helpers\Settings::get(string $key, string $default = ''): string
\app\Helpers\Settings::update(string $key, string $value, string $pluginName = ''): void
// 第三个参数传插件目录名做命名空间隔离;update() 需要 system:settings 权限→ 与规格要求的 permissions = ["route:admin", "system:settings"] 完全对应(参照 plugins/suiyu)。
2.5 路由注册机制(独立页 /hot 怎么加)
// plugins/hot_topics/hook/route_register.php
if (!isset($router)) { return; }
// ⚠️ route_register 在 $router->group('/admin') 之外触发 → 路径必须写全(/hot)
$router->get('/hot', ['\\Plugin\\HotTopics\\FrontController', 'index']);- 触发点:
index.php:295,$router由核心注入(照抄plugins/suiyu/hook/route_register.php)。 - ⚠️ 前缀口径两相反:
route_register(前台)路径写全;admin_route_register(后台,在 group 内)路径不写/admin。 - 导航入口:
nav_plugin_links(双触发:PC 左栏「应用」+ 顶栏「应用」下拉)→ 钩子文件禁声明顶层符号。
三、技术设计
3.1 数据口径
候选池 SQL(缓存构建时执行,不按版块过滤 → 全局池):
-- metric = replies → 排序列 reply_count;metric = views → view_count
-- cutoff = time() - {24h:86400 | 3d:259200 | 7d:604800}
SELECT id, uid, title, category_id, view_count, reply_count, last_reply_time, create_time
FROM topic_index
WHERE status = 0
AND deleted_at IS NULL
AND last_reply_time >= :cutoff
ORDER BY {col} DESC, last_reply_time DESC, id DESC
LIMIT :pool:pool= 候选池容量(默认 60,见 §四·决策 6)。- 6 组组合(3 range × 2 metric)→ 刷新时执行 6 次该查询。
- ⚠️
range的窗口字段语义(last_reply_timevscreate_time)是必须拍板的点 → §四·决策 4。 - ⚠️ 不排除
is_pinned(置顶帖回复数低,天然排不上去);不额外过滤is_archived
(核心 Thread::getLatestAllowed() 也没过滤,且时间窗口已天然排除旧帖)→ 与核心口径一致。
每请求过滤(内存,0 额外查询):
$allowed = \app\Helpers\Permission::getAuthorizedCategoryIds($uid ?: null); // int[]
$visible = array_filter($pool, fn($r) => in_array((int)$r['category_id'], $allowed, true));
$list = array_slice($visible, 0, $n);作者名水合(仅 /hot 页,1 次查询):\app\Models\User::batchGetUsers($uids)(规范 §11.8.3:禁止插件直查 users 表)。
右栏面板不需要作者名/头像(极简风)→ 面板 0 次用户查询。
3.2 缓存设计(★ 本方案最核心:全局池 + 请求内过滤)
为什么不能直接缓存「过滤后的榜单」:
榜单排序依赖当前用户可见版块。若把过滤后的结果写进全局缓存文件,则游客会看到私密版块的帖子标题(越权泄露)。
反之若按「用户权限签名」分桶缓存,则缓存份数爆炸、且游客/多用户组命中率低。
正解 = 两层:
① 全局候选池(缓存文件,不含版块过滤,权限无关)
└─ 每 5 分钟刷新一次,6 组组合 × 60 条
② 每请求:读池 → 按 getAuthorizedCategoryIds() 内存过滤 → 取前 N
└─ 0 次数据库查询(除权限表本身的请求级缓存)缓存文件结构 plugins/hot_topics/data/hot_cache.json:
{
"v": 1,
"updated_at": 1758960000,
"pools": {
"24h": {
"replies": [
{"id":123,"uid":1,"title":"…","category_id":2,"view_count":88,"reply_count":12,"last_reply_time":1758959000}
],
"views": [ … ]
},
"3d": { "replies": [ … ], "views": [ … ] },
"7d": { "replies": [ … ], "views": [ … ] }
}
}- ⚠️ 缓存文件含未过滤的标题 → 必须由
data/.htaccess+data/web.config禁止 HTTP 直接访问
(照抄 plugins/site_stats/data/ 的两个保护文件,实测内容已核对)。
- ⚠️ 缓存只存
category_id,不存category_name;分类名在渲染时由(new Category())->allOrdered()映射
(app/Models/Category.php:50,1 次查询,且该方法是列表页常规调用)。→ 分类改名即时生效。
请求路径(照抄 leaderboard 的收敛机制):
右栏钩子 right_sidebar_after(每页,PC 右栏一次)
└─ file_get_contents(data/hot_cache.json) ← 1 次文件读,0 次 DB
├─ 文件缺失 / 版本不符 → 同步算一次(冷启动,避免首屏空面板)
├─ 未过期 → 直接用
└─ 已过期 → 仍用旧数据(不阻塞),置 $GLOBALS['hot_need_refresh'] = true
route_after_dispatch(响应已产出,index.php:321)
└─ if (!empty($GLOBALS['hot_need_refresh'])) → refreshCache() ← 6 聚合 + 1 次文件写- 正常请求 DB 查询 = 0(文件缓存)+ 0~1(权限表请求级缓存) ✅ 远低于规范 §十三「≤ 5 次/请求」红线。
- 过期请求的 6 次查询发生在响应产出之后,不占用户等待。
- TTL 默认 300s(与生产云函数 5 分钟
cron_trigger.php节奏一致)。 - 不上 cron:完全靠「有人访问 → 懒刷新」,无命令行依赖(生产无命令行,MEMORY §1)。
3.3 UI 结构
右栏面板(固定 24h + replies,零 tab、零 JS):
/hot 页(6 组切换用普通链接,全页刷新,URL 可分享/可缓存):
视觉一致性要点:
- 外层 / 标题 / 列表 / 条目 / 数值全部复用核心类(
.mn-panel/.mn-panel-title/.mn-stat-list/.mn-stat-item/
.mn-stat-label / .mn-stat-value)→ 与站点统计面板像素级一致。
- 插件自建类一律带
.hot-topics-前缀并提权(如.hot-topics-panel .hot-topics-rank),
只写核心没定义的属性(规范 §5 / MEMORY §4:插件 CSS 在 main.php:40 注入、早于核心 editor.css,同权重核心后胜)。
- 颜色**只用既有
--mn-***(写前逐条 grep 核对;--mn-danger/--mn-spectrum-*已知不存在)。 - 图标字面量
<i class="fa mn-fs-12"></i>(fa-fire)——不用$view->icon()(其返回值带内联 style,违反零内联)。 - 空榜 → 渲染一行 muted 文案(i18n 键),不整块隐藏(面板已存在时隐藏会显得坏掉);但「一条数据都没有」时
按规范 §8「无数据不渲染」整块 return。
3.4 路由、钩子与文件清单
钩子清单(6 个):
| # | 钩子 | 触发点 | 作用 | 关键红线 |
|---|---|---|---|---|
| 1 | right_sidebar_after | right_sidebar.php:211 | 右栏面板(站点统计上方) | $this 不可用;$GLOBALS['__view'] 取模板 |
| 2 | route_register | index.php:295 | 注册 /hot | 路径写全;if (!isset($router)) return; |
| 3 | layout_head_end | main.php:40 | 注入 style.css | 带 ?v=filemtime;不做模板白名单(面板全站可见) |
| 4 | nav_plugin_links | left_sidebar.php:100 + main.php:218 | 「应用」入口 | 双触发 → 禁声明顶层符号;图标 16px;守卫 __nav_uri |
| 5 | route_after_dispatch | index.php:321 | TTL 懒刷新 | 必须有触发源收敛($GLOBALS 标志) |
| 6 | admin_route_register | index.php:276 | 后台设置路由 | 在 /admin group 内 → 路径不写 /admin;需 route:admin |
目录结构:
plugins/hot_topics/
├── plugin.json # hooks(6) + permissions["route:admin","system:settings"]
├── Plugin.php # 常量 / 缓存读写 / 6 组聚合 / refreshCache() / getList()
│ # ⚠️ 文件末尾 class_alias(目录名含下划线,见 §六·风险 1)
├── FrontController.php # GET /hot → 整页视图
├── AdminController.php # 后台设置页(GET/POST,PRG)
├── hook/
│ ├── right_sidebar_after.php # 右栏面板
│ ├── route_register.php # /hot
│ ├── layout_head_end.php # CSS
│ ├── nav_plugin_links.php # 「应用」入口(双触发)
│ ├── route_after_dispatch.php # TTL 懒刷新
│ └── admin_route_register.php # 后台路由
├── views/
│ ├── _panel.php # 右栏面板片段(viewRaw / 无 link+script)
│ ├── index.php # /hot 整页
│ └── admin/settings.php # 后台设置
├── assets/style.css # 全部样式(零内联)
├── lang/{zh,en,zh_tw}.php # 键前缀 plugin.hot_topics.*(三语键集合零差异)
└── data/
├── .htaccess # 禁 HTTP 访问(照抄 site_stats)
├── web.config # IIS 禁 data 段(照抄 site_stats)
└── hot_cache.json # 运行时生成(不进发行包)路由:
// 前台(route_register,路径写全)
$router->get('/hot', ['\\Plugin\\HotTopics\\FrontController', 'index']);
// 后台(admin_route_register,在 group 内,不写 /admin)
$router->get('/hot-topics/settings', ['\\Plugin\\HotTopics\\AdminController', 'settings']);
$router->post('/hot-topics/settings/save',['\\Plugin\\HotTopics\\AdminController', 'settingsSave']);3.5 i18n 键(初版,三语同步)
plugin.hot_topics.nav 热榜
plugin.hot_topics.title 论坛热榜
plugin.hot_topics.more 查看完整热榜
plugin.hot_topics.range_24h 近 24 小时
plugin.hot_topics.range_3d 近 3 天
plugin.hot_topics.range_7d 近 7 天
plugin.hot_topics.metric_replies 回复榜
plugin.hot_topics.metric_views 浏览榜
plugin.hot_topics.col_replies 回复
plugin.hot_topics.col_views 浏览
plugin.hot_topics.empty 暂无数据
plugin.hot_topics.admin_title 热榜设置
plugin.hot_topics.admin_top_n 面板显示条数
plugin.hot_topics.admin_pool 候选池容量
plugin.hot_topics.admin_ttl 缓存有效期(秒)
plugin.hot_topics.admin_default_range 默认时间范围
plugin.hot_topics.admin_default_metric默认榜单
plugin.hot_topics.admin_panel_on 显示右栏面板
plugin.hot_topics.admin_saved 已保存⚠️ 验收标准 = 三语键集合零差异(不是「值非空」),含
plugins/*/lang/全量比对(MEMORY §6)。⚠️
I18n::get()缺键return $key;(永不白屏)→ 改视图后必跑全量比对。
3.6 后台设置页
| 设置项 | 键 | 默认 | 说明 |
|---|---|---|---|
| 面板显示条数 | top_n | 5 | 右栏 TOP N |
| 候选池容量 | pool | 60 | 过滤前候选数(须 ≥ top_n 数倍,避免过滤后不足) |
| 缓存有效期 | ttl | 300 | 秒 |
| 默认时间范围 | default_range | 24h | /hot 无参时的默认 |
| 默认榜单 | default_metric | replies | /hot 无参时的默认 |
| 显示右栏面板 | panel_on | 1 | 关掉后右栏不渲染(/hot 仍在) |
- 存储走
Settings::update($key, $val, 'hot_topics')(需system:settings)。 - ⚠️⚠️ 保存时必须用
array_key_exists($key, $_POST)判缺失即跳过,
禁止 (int)($_POST[$k] ?? 0) —— 后者在字段名对不上时会静默写最小值,
且「302 + 页面提示成功、DB 与 settings_cache.php 都不变、error.log 无记录」(MEMORY §7,症状欺骗性极强)。
勾选框(panel_on)另算:缺失 = 显式关闭。
- ⚠️ 上线新设置项后必须清一次缓存(
settings_cache.php不会自动补新键,MEMORY §1)。
四、需拍板的 6 个点
决策 1:右栏面板固定「24h + 回复榜」,不做 tab
| 方案 A(**推荐**) | 方案 B | |
|---|---|---|
| 面板 | 固定 24h + replies TOP 5 + 「查看完整热榜 →」 | 面板内做 range/metric tab |
| 交互 | 零 tab、零 JS | 6 组 tab(纯 CSS radio 需 6 个 pane,面板高度翻倍) |
| 复杂度 | 极低 | 中(窄栏塞 6 个 chip 会挤) |
推荐 A。理由:右栏宽度有限(面板约 210–300px),6 组 tab 会把面板撑很高;「完整榜单」的浏览场景天然属于独立页 /hot;
且符合你「没有实际用途的控件就不要出现」的取向。
决策 2:/hot 页切换用普通链接(全页刷新),不做 AJAX
| 方案 A(**推荐**) | 方案 B | |
|---|---|---|
| 切换 | <a href="/hot?range=3d&metric=views"> 全页刷新 | XHR 局部替换(hx-swap 或 fetch) |
| 降级 | 不存在降级问题 | 必须写降级(无 JS 时回退全页) |
| 依赖 | 零 JS | 需 JSON 端点 + JS(本项目有「点按钮没反应 = JS 运行时 TypeError」的历史坑,且 node --check/静态扫一律漏) |
| URL | 可分享、可被页面缓存 | 状态在 JS 里,URL 与内容可能不一致 |
| SEO | 每种组合都可被抓取 | 单一 URL |
推荐 A。理由:6 组组合用链接切换没有任何功能损失,却把整块 JS 风险与降级负担归零;
仓库内已有 leaderboard 的「零 JS 优先」先例。AJAX 可作为后期可选增强(不在本方案范围)。
决策 3:缓存放插件自己的 data/
推荐:是 → plugins/hot_topics/data/hot_cache.json,并用 .htaccess + web.config 禁 HTTP 访问。
理由:与 site_stats / suiyu / leaderboard 的既有惯例一致;不污染核心 data/runtime/(那里是核心自管目录);
插件 data/ 天然不进发行包。
决策 4:range 的时间窗口按哪个字段?
| 方案 A(**推荐**) | 方案 B | |
|---|---|---|
| 条件 | last_reply_time >= cutoff | create_time >= cutoff |
| 语义 | 「近 N 天有人讨论过的帖子」按总回复数排 | 「近 N 天新发布的帖子」按总回复数排 |
| 现象 | 老帖今天被顶一下就可能进榜 | 今天新发但无人回复的帖会占位(0 回复也上榜) |
推荐 A(活跃度窗口)。理由:热榜的语义是「最近有人在讨论」;用 create_time 会让「刚发但没人理」的帖子霸榜,观感差。
⚠️ 但这带来一个必须你知情的语义限制:因为规格要求「纯指标、无时间衰减」,reply_count 是该帖的累计回复数(不是窗口内回复数)。
→ 即「近 24h 有动静的帖子里,累计回复最多的那个」。若要「窗口内回复数」,必须扫 reply_index 按时间聚合(查询更重,且与「零核心改动 + 只读 topic_index」的规格不符)。
请确认接受这个语义。
决策 5:右栏面板的可见页面范围(我新增)
| 方案 A(**推荐**) | 方案 B | |
|---|---|---|
| 做法 | right_sidebar_after 天然触发 → 所有带右栏页面都显示 | 在钩子内复刻 $showSiteStats 条件,仅首页/论坛/分类/博客显示 |
| 差异 | 帖子/博客详情页也会出现本面板 | 与站点统计同域,两面板始终成对出现 |
| 复杂度 | 0 判断 | 需在钩子里读 $GLOBALS['__view']->currentPath() 复刻 4 个条件 |
推荐 A。理由:零判断、与 suiyu/leaderboard 的既有行为一致;且详情页放「热榜」本身合理(引导继续浏览)。
若你希望「跟站点统计严格成对」,选 B。
决策 6:候选池容量 / TOP N / TTL(我新增)
top_n(面板条数)默认 5;/hot页展示 20 条(不分页)。pool(候选池)默认 60 —— 理由:池需不按版块过滤,而每请求过滤后可能只剩少数,
池太小会导致有权限的用户也看到不足 N 条。60 条 ≈ 6 组 × 60 = 360 行的 JSON(约 60–80 KB),文件读写开销可忽略。
ttl默认 300s。
五、实施阶段(每阶段末暂停确认)
| 阶段 | 内容 | 验收方式 |
|---|---|---|
| P1 | 骨架 + 后台:plugin.json / Plugin.php(常量 + class_alias)/ AdminController / hook/admin_route_register / views/admin/settings / 三语 lang/ / data/ 两个保护文件 | 插件可激活;后台设置页 200;保存后同时回读 DB 与 settings_cache.php 确认落库 |
| P2 | 数据层 + 缓存:6 组聚合 SQL + refreshCache() + getList() + 权限内存过滤 + 文件缓存读写 | CLI 探针:6 组结果与手写 SQL 一致;池内条目全部落在 getAuthorizedCategoryIds() 之外时游客取到空;TTL 内 0 次重算、过期后恰好 1 次 |
| P3 | 独立页 /hot:route_register + FrontController + views/index.php + 6 组链接切换 + CSS | HTTP 冒烟:6 组 URL 全 200;断言每组的排序键单调不增;/hot?range=bogus 回落默认 |
| P4 | 右栏面板:hook/right_sidebar_after.php + views/_panel.php + nav_plugin_links + layout_head_end | HTTP 冒烟:断言面板 DOM 出现在「站点统计」面板之前(这是「放在上面」的硬验收);窄屏面板随右栏隐藏;面板在游客/管理员下均正确过滤 |
| P5 | 验证与收尾:门禁全跑 + 说明.md + 清页面缓存 | 见 §七 |
六、风险与坑(逐条对应规范 / MEMORY)
| # | 风险 | 依据 | 处置 |
|---|---|---|---|
| 1 | ⚠️⚠️ 目录名含下划线 → 核心按目录名硬拼类名落空 → 生命周期静默跳过 → 卸载留孤儿数据 | 规范 §4.1 / MEMORY §7 | Plugin.php 末尾加 \class_alias(Plugin::class, 'Plugin\hot_topics\Plugin', false)(下划线 ≠ 大小写差异,class_exists 会恒 false) |
| 2 | ⚠️⚠️ 渲染钩子里 $this 不可用 → $this->e() 被吞成「HTML 渲染一半断掉、HTTP 200、无告警」 | 规范 §11.24 / MEMORY §7 | 钩子内一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8');表单用 Csrf::token();静态扫不出,只能发真实 HTTP + 查 protected/error.log |
| 3 | ⚠️ 全局缓存泄露私密版块标题 | 本方案 §3.2 | 缓存只存未过滤候选池,过滤在每请求内存做;缓存文件由 .htaccess+web.config 禁访问 |
| 4 | 每请求查询超红线 | 规范 §十三(≤5 次) | 文件缓存 → 正常请求 0 次 DB(+权限表请求级缓存) |
| 5 | 核心同特异性声明后胜 → 插件样式静默失效 | MEMORY §4 | 插件类一律提权,且只写核心没定义的属性 |
| 6 | 夜间主题浅底浅字 | 规范 §11.7 | 只用既有 --mn-*,写前 grep 核对;禁硬编码色 |
| 7 | 内联 <style>/<script>/on* | 规范 §7.3(P0 一票否决) | 全部收敛到 assets/;发布前跑运行期零内联扫描(只认「标签自身带插件标记」) |
| 8 | 钩子文件声明顶层函数/类 → 双触发钩子(nav_plugin_links)第二处静默消失 | 规范 §9.0/§9.3 | nav_plugin_links.php 禁声明任何顶层符号 |
| 9 | 后台保存「字段名对不上 → 静默写最小值」 | MEMORY §7 | 用 array_key_exists 判缺失即跳过 |
| 10 | 上线新设置项后 settings_cache.php 不自动补键 | MEMORY §1 | 上线后清一次缓存 |
| 11 | 改 nav_* / layout_head_end 后前台仍看旧页 | 规范 §9.5 / MEMORY §1 | 走后台「维护→清理缓存」(data/runtime/pages/ 永久缓存,仅游客列表页) |
| 12 | 视图模板 i18n 缺键 → 显示原始键名 | MEMORY §6 | 改视图后跑「引用键 ↔ 三语字典」全量比对 |
| 13 | main_index.sqlite 是 WAL,裸 PDO 直读拿到旧快照 | MEMORY §1/§10 | 一律走 Schema::mainIndexDb() |
| 14 | 若控制器内做 404 分支 → 只渲染不设码 = 软 404 | MEMORY §7 | \http_response_code(404) 必须在 view() 之前 |
| 15 | ⚠️ 全角标点紧跟 $var → PHP 变量名含 ≥0x80 字节 → 静默空值 | MEMORY §9 | 交付前跑 .workbuddy-ai/tools/php_var_cjk_check.php |
| 16 | 同文件多处改动并行 Edit 互相覆盖 → 「报成功但未落盘」 | MEMORY §9 | 同文件多处改动必须串行,改后回读校验 |
七、验证方案(P5)
| 项 | 手法 | 通过标准 |
|---|---|---|
| 语法 | php -l(本机 8.2 + 底线 8.0 双版本) | 0 error |
| 插件体检 | plugin_hygiene.py | FAIL = 0 |
| 变量/全角 | php_var_cjk_check.php | 通过 |
| i18n | 引用键 ↔ 三语字典全量比对 | 键集合零差异 |
| 零内联 | 运行期扫描(只认插件标记) | 0 命中 |
| 「在站点统计上面」 | HTTP 抓首页 → 断言 hot-topics-panel 的 DOM 位置 早于「站点统计」面板 | ✅(硬验收) |
| 权限过滤 | 造 1 条私密版块帖子(游客不可见)→ 游客抓 /hot 与首页 | 标题不出现;管理员抓则出现 |
| 6 组 URL | 抓 /hot?range={24h,3d,7d}&metric={replies,views} | 全 200;排序键单调不增 |
| 缓存 | CLI 探针(带 __construct(){} 空构造 + 增量守卫) | TTL 内 0 次重算、过期后恰好 1 次 |
| 错误日志 | protected/error.log | 零新增 |
| 收尾 | 清理页面缓存 | data/runtime/pages/ 清空 |
⚠️ 探针红线(MEMORY §8):CLI 探针首尾各取一次「行数 +
MAX(id)」做增量守卫;禁止按硬编码 id /user_id IN (...)删数据;
new class extends X {}是实例化,继承Admin\BaseController会跑构造函数 → 必须显式public function __construct(){}。⚠️ 用户正开着页面自测时:不清页面缓存、不按「数据眼熟」臆断删数据(先查
users确认 uid)。
八、待你确认
- 决策 1:右栏面板固定「24h + 回复榜」TOP 5、不做 tab?(推荐:是)
- 决策 2:
/hot页用普通链接切换、不做 AJAX?(推荐:是) - 决策 3:缓存放
plugins/hot_topics/data/hot_cache.json?(推荐:是) - 决策 4:
range窗口用last_reply_time(活跃度)还是create_time(新帖)?
—— 且请确认接受「回复数 = 帖子累计回复数,不是窗口内回复数」这个语义。
- 决策 5:面板显示范围 —— 所有带右栏页面(含详情页)还是仅站点统计可见的 4 类页面?(推荐:前者)
- 决策 6:
top_n=5/pool=60/ttl=300s,可否? - 目录名:保持
hot_topics(需class_alias兜底)还是改成无下划线名(如hottopics/hot_list,天然绕开该坑)?
确认后我按 P1 → P5 分阶段做,每阶段末停下来给你看。