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

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:32:48

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/hottopics/说明.md。

论坛热榜插件(hottopics)

右栏「论坛热榜」面板(位于站点统计上方)+ 独立页 /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_links263
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_n51–20
候选池容量hottopics_pool6010–200(自动 ≥ 显示条数)
缓存有效期(秒)hottopics_ttl30030–86400
默认时间范围hottopics_default_range24h24h / 3d / 7d
默认榜单hottopics_default_metricrepliesreplies / views
在右栏显示热榜面板hottopics_panel_on1勾选

  • 存 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)。

原视图的筛选条与列表都没带左右内边距 → 直接贴到卡片左右边缘。

  1. 观感:自造胶囊 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 链接与右栏面板 → 必须清一次,否则游客仍看旧页)。

  1. 新设置项上线后清一次设置缓存(settings_cache.php 不会自动补新键)。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 2 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录