一个简单的论坛热榜插件,喜欢的可以安装试一下。
右栏**「论坛热榜」面板**(位于站点统计上方)+ 独立页 /hot(24h / 3d / 7d × 回复榜 / 浏览榜,共 6 组)。
零核心改动 · 只读核心索引库 · 文件缓存 + TTL 懒刷新 · 不建表、不上 cron。
一、挂载位置(★ 本插件最关键的一点)
复用核心现成钩子 right_sidebar_after(规范 §9.1 #14),零核心改动。
app/Views/_components/right_sidebar.php 实测渲染顺序:
| 序 | 区块 | 行号 |
|---|---|---|
| 1 | 发帖人信息栏 + 主题卡片 或 用户面板 | 41–207 |
| 2 | 🔌 right_sidebar_after ← 本插件挂这里 |
211 |
| 3 | 站点统计面板 | 214–241 |
| 4 | 热门标签面板 | 244–259 |
| 5 | 🔌 sidebar_above_friend_links |
263 |
| 6 | 友情链接面板 | 265–274 |
→ 面板天然落在站点统计正上方。
- ⚠️ 触发面比站点统计更宽:站点统计只在
$showSiteStats(首页 //forum//forum/category/*//blog*)显示, 而right_sidebar_after在所有带右栏的页面都触发(含帖子/博客详情页——那里渲染的是「发帖人卡」而非用户面板)。 - ⚠️
right_sidebar_after只在 PC 右栏触发一次(mobile_sidebar.php里没有这个钩子) → 窄屏(≤1024px 右栏整块display:none)看不到本面板 → 真正的入口是nav_plugin_links(PC 左栏「应用」+ 顶栏「应用」下拉,双触发各一份)。 - ⚠️ 别与
sidebar_above_friend_links(#15)搞混:那个在站点统计下方。
二、数据口径
唯一数据源:data/meta/main_index.sqlite → topic_index(只读)。
SELECT id, uid, title, category_id, view_count, reply_count, last_reply_time
FROM topic_index
WHERE status = 0 AND deleted_at IS NULL AND last_reply_time >= :cutoff
ORDER BY {reply_count|view_count} DESC, last_reply_time DESC, id DESC
LIMIT :pool
:cutoff=time() - {24h:86400 | 3d:259200 | 7d:604800}(窗口字段是last_reply_time= 「近 N 天有人讨论过」)。- 排序是纯指标(无加权、无时间衰减)。
- ⚠️ 字段名:真实列是
create_time/last_reply_time(Unix 整数秒)、category_id; 没有created_at/last_reply_at/forum_id。 - ⚠️
view_count/reply_count上没有任何索引 → 该查询会全表扫 + TEMP B-TREE。 故它只在缓存过期的 TTL 刷新路径执行(默认 5 分钟一次),不进每页渲染路径。 - ⚠️ 语义提醒:「回复数」是帖子累计回复数,不是窗口内回复数(窗口只决定「谁有资格进榜」)。
- 读取一律走
\app\SplitDB\Schema::mainIndexDb(): ⚠️ 该库是 WAL,裸 PDO 单拷文件读不到表且不报错;⚠️app\Core\Database没有prepare()/fetchColumn()。
三、缓存设计(★ 全局候选池 + 每请求内存过滤)
为什么不能直接缓存「过滤后的榜单」:榜单排序依赖「当前用户可见版块」。 若把过滤后的结果写进全局缓存文件,游客就会看到私密版块的帖子标题(越权泄露); 若按用户权限分桶缓存,份数爆炸且命中率低。
两层结构:
① 全局候选池(缓存文件 data/hot_cache.json,**不含版块过滤**)
└─ 每 TTL(默认 300s)刷新一次,6 组 × pool(默认 60)条
② 每请求:读池 → 按 Permission::getAuthorizedCategoryIds() 内存过滤 → 取前 N
└─ 0 次额外数据库查询
- ⚠️ 缓存文件含未过滤的标题 → 必须由
data/.htaccess+data/web.config禁止 HTTP 访问(已配)。 - 缓存只存
category_id,不存category_name→ 分类改名即时生效(名称在渲染时由Category::allOrdered()映射)。
请求路径(范式同 leaderboard):
右栏钩子 / `/hot` 控制器
└─ 读 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 次文件写
- 正常请求 0 次 DB 查询(+ 权限表的请求级缓存),远低于规范 §十三 的「≤5 次/请求」红线。
- 不上 cron:完全靠「有人访问 → 懒刷新」,生产无命令行也照常工作。
四、权限过滤(唯一口径)
\app\Helpers\Permission::getAuthorizedCategoryIds(?int $uid = null): array // int[]
- 管理员组(
group_id = 4)直接短路返回全部版块;游客传null(核心内部按group_id = 0处理)。 - ⚠️ 核心对游客的行为:只要
group = 1有任一可看版块,就无条件返回group = 1的列表, 完全不看group = 0的显式行(Permission.php里if ($groupId === 0) { … return $fallbackIds; })。 → 想让游客看不到某版块,必须改group = 1的那行,改group = 0无效。 - 过滤失败时返回空数组 = fail-closed(宁可不出榜,也不越权展示)。
- ⚠️ 插件不自造管理员判定(唯一口径
Auth::isAdmin()),本插件也不需要判定。
五、文件清单
plugins/hottopics/
├── plugin.json # 6 hooks + permissions[route:admin, system:settings]
├── Plugin.php # 配置 / 候选池聚合 / 缓存读写 / 权限过滤 / 生命周期
├── FrontController.php # GET /hot
├── AdminController.php # 后台设置(GET / POST PRG / 重建缓存)
├── hook/
│ ├── right_sidebar_after.php # 右栏面板(站点统计上方)
│ ├── route_register.php # /hot(路径写全)
│ ├── admin_route_register.php # /admin/hottopics/*(路径不写 /admin)
│ ├── layout_head_end.php # 注入 style.css(?v=md5)
│ ├── nav_plugin_links.php # 「应用」入口(**双触发**,禁顶层符号)
│ └── route_after_dispatch.php # TTL 懒刷新
├── views/{_panel.php, index.php, admin/settings.php}
├── assets/style.css # 全部样式(零内联)
├── lang/{zh,en,zh_tw}.php # 各 30 键,键集合零差异
└── data/{.htaccess, web.config, hot_cache.json} # hot_cache.json 运行时生成
路由:前台 /hot;后台 /admin/hottopics/settings(与 plugin.json 的 admin_url 一致)。
六、后台设置
| 设置项 | 键 | 默认 | 范围 |
|---|---|---|---|
| 右栏面板显示条数 | hottopics_top_n |
5 | 1–20 |
| 候选池容量 | hottopics_pool |
60 | 10–200(自动 ≥ 显示条数) |
| 缓存有效期(秒) | hottopics_ttl |
300 | 30–86400 |
| 默认时间范围 | hottopics_default_range |
24h |
24h / 3d / 7d |
| 默认榜单 | hottopics_default_metric |
replies |
replies / views |
| 在右栏显示热榜面板 | hottopics_panel_on |
1 | 勾选 |
- 存
Settings::update($key, $val, 'hottopics')(需system:settings权限,plugin.json已声明)。 - 后台提供 「立即重建缓存」 按钮(改完
pool后想立刻生效时用)。 - ⚠️ 保存实现用
array_key_exists()判「字段缺失 = 跳过」, 禁止(int)($_POST[$k] ?? 0)(字段名漂移会静默写最小值,且表现为「提示成功但 DB 与缓存都不变」)。
七、命名与类名解析(为什么目录名不带下划线)
目录名 hottopics(单单词、无下划线)+ 命名空间 Plugin\Hottopics。
- 核心按目录名硬拼类名:
\Plugin\{目录名}\Plugin→\Plugin\hottopics\Plugin; PHP 类名大小写不敏感 → 与声明的Plugin\Hottopics\Plugin仅大小写不同 → 直接命中,无需class_alias。 - ⚠️ 若目录名含下划线(如
hot_topics),拼出的\Plugin\hot_topics\Plugin与Plugin\HotTopics\Plugin差一个下划线字符(不是大小写差异)→class_exists恒false→ 生命周期静默跳过、卸载留孤儿。 - 自动加载器对
Plugin\前缀有 3 条候选路径,其中第 2 条camelToSnake('Hottopics') = 'hottopics'恰好命中目录名 → Linux 下同样可用(Windows 走第 1 条精确路径)。
八、红线落地(逐条)
| # | 红线 | 落地方式 |
|---|---|---|
| 1 | 渲染钩子里 $this 不可用 |
钩子只做赋值与 $view->include();模板内才用 $this->e() |
| 2 | 钩子文件禁声明顶层符号 | 6 个钩子文件全部只用变量赋值 / if / echo(nav_plugin_links 双触发必须如此) |
| 3 | 全局缓存泄露私密版块 | 池不过滤、过滤在内存;缓存文件由 .htaccess + web.config 禁访问 |
| 4 | 每请求查询 ≤5 | 正常请求 0 次 DB(文件缓存 + 权限表请求级缓存) |
| 5 | 插件 CSS 同权重被核心后胜 | 覆盖核心的规则一律带 .hottopics-panel / .hottopics-page 前缀提权 |
| 6 | 颜色硬编码 | 只用既有 --mn-*(--mn-danger / --mn-spectrum-* / --mn-bg-hover 均不存在,未使用) |
| 7 | 零内联 | 样式全在 assets/style.css;图标用字面量 <i class="fa mn-fs-N"> |
| 8 | 路由前缀两相反 | 前台 /hot 写全;后台 /hottopics/* 不写 /admin |
| 9 | 独立页/面板无数据 | 「一条都没有」整块 return(§8 无数据不渲染) |
| 10 | 改 hooks 清单后失效 | 上线后跑 Plugin::refresh() 重建 plugins_cache.json |
| 11 | .mn-section 自身无 padding |
列表条目自带 padding: 12px 20px(窄屏 11px 14px)→ 不顶卡片两头 |
九、UI 设计口径(2026-09-27 改版,对齐「个人信息页面」)
参照物:plugins/user_profile/(用户主页 /u/{id})。两者是同一套视觉语言,没有自造控件。
| 位置 | 用的东西 | 说明 |
|---|---|---|
| 整页容器 | 核心 .mn-section |
= 卡片(--mn-bg-card 底 + --mn-border 边 + border-radius:10px + --mn-shadow-sm),与 .up-card / .up-section 同源 |
| 页头 | 核心 .mn-section-header |
左 <h2> 标题、右 <nav> 装 tab —— 与 app/Views/forum/index.php、plugins/suiyu/views/stream.php 完全同款 |
| 切换控件 | 核心 .mn-section-tab(选中加 mn-active) |
与 forum / message / suiyu / search 页一致;不用自造胶囊 |
| 版块标签 | 核心 .mn-tag.mn-tag-cat |
全站统一的版块胶囊 |
| 空状态 | 核心 .mn-empty |
与 forum 列表空态一致 |
| 列表条目 | 插件 .hottopics-item 等 |
排名徽标 + 标题 / 元信息两行 + 右侧「数值 + 单位」竖排块 |
| 右栏面板 | 全量复用核心 .mn-stat-* |
与「站点统计」像素级一致(刻意不动) |
改版前的两个问题(用户反馈「tab 和数量都顶到两头了」):
- 根因:
.mn-section自身没有 padding(核心是让每个子行各自带10px 20px,如.mn-row)。 原视图的筛选条与列表都没带左右内边距 → 直接贴到卡片左右边缘。 - 观感:自造胶囊 chip 与全站
.mn-section-tab不是一套;右侧数值是「飘在右边缘的裸数字」。
改版后:
- tab 用
.mn-section-tab,两组(时间范围 / 榜单)之间加一根.hottopics-tabs-sep竖分隔; 时间范围标签由「近 24 小时」收紧为「24 小时」(en:24h),5 个 tab 才放得下一行(窄屏自动换行)。 - 列表条目自带左右内边距;排名改为方角小徽标(前三名主色底);右侧数值改成「数值 + 单位」竖排右对齐块。
- 元信息不再重复堆「N 回复 · N 浏览」——排名指标只在右侧数值块出现一次,元信息只留版块 / 作者 / 时间。
⚠️
plugins/user_profile/assets/style.css里用的--text-light在核心/主题中未定义(永远走兜底值), 本插件不使用它,一律用--mn-text-muted。同理--mn-card-bg/--mn-bg-hover不存在, 存在的是--mn-bg-card/--mn-bg-row-hover。
十、上线动作(部署清单)
- 只传 代码 + 新增
plugins/hottopics/;不要传data/hot_cache.json(运行时生成)。 - 部署后跑一次
\app\Helpers\Plugin::refresh()重建plugins_cache.json。 - 后台 「维护 → 清理缓存」(
data/runtime/pages/是游客列表页永久缓存; 本次新增了 nav 链接与右栏面板 → 必须清一次,否则游客仍看旧页)。 - 新设置项上线后清一次设置缓存(
settings_cache.php不会自动补新键)。