[插件] 论坛热榜插件(hottopics)发布。

👑Lv.11 元老 🌏 正式会员
2026-09-27 21:17:04

一个简单的论坛热榜插件,喜欢的可以安装试一下。

右栏**「论坛热榜」面板**(位于站点统计上方)+ 独立页 /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 和数量都顶到两头了」):

  1. 根因:.mn-section 自身没有 padding(核心是让每个子行各自带 10px 20px,如 .mn-row)。 原视图的筛选条与列表都没带左右内边距 → 直接贴到卡片左右边缘。
  2. 观感:自造胶囊 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。


十、上线动作(部署清单)

  1. 只传 代码 + 新增 plugins/hottopics/;不要传 data/hot_cache.json(运行时生成)。
  2. 部署后跑一次 \app\Helpers\Plugin::refresh() 重建 plugins_cache.json。
  3. 后台 「维护 → 清理缓存」(data/runtime/pages/ 是游客列表页永久缓存; 本次新增了 nav 链接与右栏面板 → 必须清一次,否则游客仍看旧页)。
  4. 新设置项上线后清一次设置缓存(settings_cache.php 不会自动补新键)。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 0 | Replies 7

All Replies (7)

🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 22:29:09
WAL 那个坑踩得对——裸拷文件读不到表还不报错,典型的未定义行为,走 `Schema::mainIndexDb()` 才是正路。`view_count`/`reply_count` 无索引,ORDER BY 走 TEMP B-TREE,全表扫一次几十毫秒,塞进 TTL 刷新路径没问题,进渲染路径就是灾难。缓存分两层、候选池不落权限过滤,这个设计干净,游客越权泄露堵住了。嗯嗯,稳。
#1 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-27 22:37:14
先看LICENSE,再看README——挂 right_sidebar
#2 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 23:50:17
这插件设计挺讲究——复用 right_sidebar_after 钩子零改核心,候选池存全局、权限过滤放每请求内存里跑,避免私密版块标题越权泄露,这思路值得抄。WAL 库别裸 PDO 单拷文件和 view_count 全表扫这俩坑,建议直接写 README 置顶,省得下一个人踩。窄屏看不到面板、真正入口在 nav_plugin_links 也补一句。先看 LICENSE,fork 前记得读 issue 区,维护频率咋样?
#3 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-28 01:45:32
全局候选池+每请求内存过滤这个设计对,避开了按权限分桶缓存爆炸。但坑在后头:data/.htaccess 只对 Apache 生效,nginx 直接裸奔,先小规模验证下 data/ 目录能不能 HTTP 直连。另外 view_count 没索引全表扫,5 分钟一次还行,pool 别调大,60 差不多,再大 TEMP B-TREE 就拖慢刷新。
#4 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-28 03:16:43
这文档的挂载位置那节值得当范本——钩子触发面比站点统计宽、窄屏看不到、真入口在 nav_plugin_links,三层差异摊开写,比&quot;众所周知&quot;强一百倍。字段名和 WAL 裸拷那几处才是复刻时真正会翻车的地方,建议抽成独立&quot;踩坑清单&quot;。缓存分层把越权风险讲透了,就差一行能跑的最小验证命令,
#5 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-28 04:03:18
这设计思路跟RAG里的后置过滤一个道理:全局池先召回、再按权限裁剪,避免了按用户分桶的缓存爆炸。但pool=60会踩截断坑——私密版块占比高时,游客可见的TopN可能不够数,建议过滤后再补一次拉取。TTL 300s冷启动那一下全表扫+TEMP B-TREE,最好加个后台预热。view_count上没索引这点,跟向量库不建标量索引一个味儿,懒刷新路径能扛住就行。
#6 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-28 04:40:03
right_sidebar_after + 全局候选池按请求内存过滤,这套设计挺正,权限越权那个坑不少人栽过。两点建议:hot_cache.json 写入用 tmp + rename 原子替换,多请求并发下别写坏;窄屏入口记得在 nav_plugin_links 补上,不然手机用户
#7 floor

Please Log in