本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/首页会员区块插件开发方案_2026-10-14.md。
首页会员区块插件开发方案(home_members)
日期:2026-10-14
来源:借鉴 xiunox 的
xnx_block_user(由旧插件xnx_home_block会员侧拆分演进)状态:方案待拍板,未动手实现(本文档只做设计,不含代码)
参考实现:
plugins/leaderboard(榜单 + 纯 CSS tab + 独立库 TTL 懒刷新)、plugins/pledge_checkin(后台配置页 + 表单 PRG + 前台路由)
一、需求逐条转译:xiunox → FlintHub
xiunox 的这套设计里,有一半的 API 在 FlintHub 不存在,必须换成等价物。先把这个对照表定下来,后面所有设计都基于它。
| xiunox 概念 | FlintHub 对应物 | 结论 |
|---|---|---|
hooks_rank 同注入点排序 | plugin.json 的 sort_order(数值小者先,Plugin::hookIndex() 按此枚举) | ✅ 有等价物,直接用 |
xnx_block_post 帖子区块 / 同一注入点 | 不存在中栏钩子(见 §二,最接近的是 announcements_display,位置在中栏顶部) | ⚠️ 必须决策 |
CreditsService(credits / golds / rmbs 三币种) | app\Helpers\Points(只有单一 users.points) | ✅ 按你的要求「只用积分」→ 三币种降为一币种 |
CacheHelper | 无统一类。app\Helpers\Cache 只是「清缓存入口」,不含 get/set | ✅ 走 leaderboard 范式:插件独立库 + TTL 懒刷新 |
avatar_component_from_data | User::defaultAvatarPath()(系统默认 SVG 头像)+ Upload::url() | ✅ 有等价物(但无「用户组框色」,见 §七.6) |
user_find_by_uids 批量取用户 | app\Models\User::batchGetUsers(array $ids) | ✅ 有等价物(规范硬要求:插件禁止直查 users 表) |
ban_type = 2 排除封禁 | users.status != 'active'(白名单只有 active / banned) | ✅ 口径降级为「非 active 即排除」 |
xnx_block_user_api | 插件前台路由 $router->get('/api/...', [...]) 返回 JSON | ✅ 有等价物(游客可访问由控制器自己控制) |
| 独立库 | Plugin::db($name) → plugins/<name>/data/<name>.sqlite + Plugin::ensureSchema() | ✅ 有等价物 |
| 安装时迁移旧配置 | Plugin::activate() 生命周期回调 | ⚠️ FlintHub 上没有 xnx_home_block 可迁移,见 §七.7 |
一句话总结:功能形态 100% 可做,但 ① 落点需要改核心、② 三币种降为一币种、③ 迁移逻辑无源可迁 —— 这三点是本方案与 xiunox 原设计的全部实质差异。
二、★ 落点决策(必须先拍板,其余设计都依赖它)
你的描述是「首页中栏,位于帖子区块 之下,同一注入点按 hooks_rank 排序」。
先看 FlintHub 首页的真实结构(实测):
site_mode = portal+default_home_mode = community→ 默认访客看到的是app/Views/home/index_default.php(三栏:左分类 + 中栏内容 + 右信息),中栏依次是:公告→论坛社区区块(= 帖子区块) →博客区块。- 该文件里目前只有 1 个钩子:
announcements_display(第 17 行),位置在「论坛社区」区块之前。 - 全站视图层钩子清单里,没有任何「中栏内容区」钩子。
所以「帖子区块之下」这个位置,FlintHub 目前没有现成挂点。三个选项:
| 选项 | 做法 | 位置 | 核心改动 | 风险 |
|---|---|---|---|---|
| A(推荐) | 新增核心钩子 home_main_after_forum,插在 index_default.php 论坛区块之后 | ✅ 完全符合「帖子区块之下」 | 2 行(首页 + 可选论坛页)+ 核心版本 bump | 其他站需升级核心;插件须声明 min_core |
| B | 复用 announcements_display | ❌ 在帖子区块之上 | 0 行 | 与公告插件共享钩子;位置不符 |
| C | layout_body_end + 外链 JS 插 DOM | ✅ 位置可精确 | 0 行 | SEO 抓不到、首屏闪烁、依赖主题 DOM(主题一换就废) |
我推荐 A,理由:
- 语义最干净 —— 「中栏区块」本来就应该是一个正式钩子,而不是靠 JS 挪 DOM;
- 正好复刻 xiunox 的「同一注入点按 rank 排序」 —— 以后再有「首页中栏」类插件(比如你说的
xnx_block_post那种),共用home_main_after_forum、各自写sort_order即可,与 xiunox 心智完全一致; - 改动极小(视图文件是进包的,老站升级核心即可拿到)。
⚠️ 若选 A,本插件
plugin.json必须写min_core(指向含该钩子的核心版本),否则老站装了插件会静默无输出。
三、功能设计
3.1 四个 Tab(后台多选启用 + 序号排序)
| tab key | 名称 | 数据源 | 排序口径 |
|---|---|---|---|
recommend | 推荐会员 | 管理员 uid + 上墙购买记录 | 手动 uid 按配置顺序 → 购买记录按到期时间倒序补足 |
new | 最新会员 | users.created_at | created_at DESC |
active | 活跃会员 | users.post_count | post_count DESC |
rich | 土豪会员 | users.points | points DESC |
- 每榜固定 10 人(产品定死,写常量
TOP_N = 10,不进后台配置)。 - 后台配置:每个 tab 一个「启用」勾选 + 一个「序号」数字输入,按序号升序排列 tab;全部不勾选 → 隐藏整个会员区块。
- 存储形态:
settings.home_members_tabs存 JSON,如
[{"key":"recommend","on":1,"seq":1},{"key":"new","on":1,"seq":2},...]。
排除规则(所有榜统一):
users.status = 'active'(即排除封禁);- 用户名缺失(已注销)的行直接丢弃,避免死链(与
leaderboard一致); - 并列名次用
uid ASC稳定排序。
3.2 土豪榜的「排序键可选」
xiunox 支持 credits / golds / rmbs 三选一。FlintHub 只有 points → 后台那个「积分类型」单选降级为固定 points(保留 UI 位置但只有一项,或直接去掉)。建议去掉,符合你「没有实际用途的控件就不要出现」的偏好。
实现上仍保留一个
RICH_KEYS白名单常量(只含points),排序键经白名单校验后才拼进 SQL —— 这是安全红线,即使现在只有一个选项也不能省。
3.3 推荐会员位(坑位 1–50,默认 10)
渲染顺序(总长度 = home_members_slots):
① 管理员手动 uid(按配置里书写的顺序,不过期,不显示剩余天数)
↓ 若未填满
② 上墙购买记录(expires_at > now,按到期时间倒序 / 或按购买时间倒序)
↓ 补足到 slots
③ 仍未满 → 渲染「我要上墙」虚线卡片占 1 格(未满时才出现)- 手动 uid 与购买 uid 去重:同一人同时被手动配置又在墙上 → 只显示一次,按手动位算(不显示剩余天数)。
- 手动 uid 里若包含已封禁/已注销用户 → 跳过该 uid(不占坑)。
- 坑位全满 → 隐藏「我要上墙」入口(与 xiunox 行为一致)。
3.4 上墙购买
| 项 | 设计 |
|---|---|
| 入口 | 会员区块内「我要上墙」卡片 / 按钮(仅登录用户可见;游客点则跳登录) |
| 支付 | Points::deduct($uid, $price, '推荐位上墙', $recordId, 'home_members') |
| 价格 | home_members_price(0 = 免费,此时跳过扣分但仍写记录) |
| 时效 | home_members_days(默认 7 天),expires_at = now + days |
| 已在墙上 | 提示「你已在推荐位上」并显示剩余天数(不重复扣分) |
| 免费额度/坑位满 | 坑位满 → 提示「推荐位已满」;不扣分 |
并发与一致性(关键,参考 pledge_checkin 的教训):
- 先写记录后扣分:在事务内
INSERT购买记录 → 拿到id→Points::deduct(...)传related_id = id;
deduct() 内部已做「条件 UPDATE(WHERE points >= :p)原子校验余额」+ 事务嵌套感知 + 写 points_log 流水 + 等级同步;
余额不足返回 false → 回滚整个事务(记录与流水一起撤)。
- 防重复上墙:事务内先
SELECT该用户未过期记录;存在则直接返回「已在墙上」。
⚠️ SQLite 无 FOR UPDATE,用 INSERT ... WHERE NOT EXISTS 或「事务 + 唯一索引」兜底(详见 §七.4)。
- 失败文案必须带数字(
pledge_checkin那轮的血泪教训):积分不足:上墙需 {need} 积分,你当前只有 {have} 积分。 - 成功 →
PageCache::invalidate()(清游客整页缓存,否则游客看到的还是旧首页,见 §七.3)→ PRG 重定向回首页并带一次性提示。
3.5 数据接口(游客可访问)
GET /api/home-members → JSON:
{
"tabs": [{"key":"recommend","label":"推荐会员"}, ...],
"boards": { "recommend":[...], "new":[...], "active":[...], "rich":[...] },
"wall": { "slots":10, "used":3, "can_buy":true, "price":100, "days":7,
"me": {"on_wall":false, "expires_at":null} },
"generated_at": 1760000000
}- 榜单部分:读独立库
hm_cache(TTL 300s),过期只置标志、渲染旧数据,重算放到响应之后(route_after_dispatch)—— 与leaderboard完全同构。 - 推荐会员 + 上墙状态:实时计算,不走缓存(按你的要求)。
- 游客可访问(不做登录校验),但
me字段在未登录时为null。
接口与「服务端首屏渲染」并存:首屏由钩子直出 HTML(SEO 友好、无闪烁),接口供前端做「上墙后局部刷新」及第三方调用。
四、数据结构
4.1 配置(复用核心 settings 表)
| key | 含义 | 默认 |
|---|---|---|
home_members_enabled | 总开关 | '1' |
home_members_tabs | tab 启用与序号(JSON) | 四榜全开,seq 1–4 |
home_members_recommend_uids | 管理员手动 uid(逗号/换行分隔,按书写顺序) | '' |
home_members_slots | 推荐位总坑位(1–50,夹紧) | '10' |
home_members_buy_enabled | 是否开放购买 | '1' |
home_members_price | 上墙积分(0 = 免费) | '100' |
home_members_days | 上墙时效天数 | '7' |
⚠️ 写
settings需plugin.json声明system:settings权限,否则Settings::update()会被requirePermission拒绝并记日志。⚠️ 上线新设置项后必须清一次
settings_cache.php(项目既有红线:该缓存不自动补新键)。
4.2 独立库 plugins/home_members/data/home_members.sqlite
-- 上墙购买记录
CREATE TABLE IF NOT EXISTS hm_wall (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
points INTEGER NOT NULL DEFAULT 0, -- 实付(0=免费)
started_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_hm_wall_uid ON hm_wall(user_id, expires_at);
CREATE INDEX IF NOT EXISTS idx_hm_wall_exp ON hm_wall(expires_at);
-- 榜单缓存(与 lb_cache 同构)
CREATE TABLE IF NOT EXISTS hm_cache (
ckey TEXT PRIMARY KEY,
cdata TEXT NOT NULL DEFAULT '[]',
updated_at INTEGER NOT NULL DEFAULT 0
);- 戳文件
data/schema.version(内容比对,非判存在),常量SCHEMA_VERSION = 'hm-schema-1';改 DDL 必须同步 +1。 data/.htaccess/web.config由Plugin::db()自动补写。
五、钩子与路由清单
5.1 钩子(plugin.json 的 hooks)
| 钩子 | 文件 | 作用 | 触发次数 |
|---|---|---|---|
init_after | hook/init_after.php | 惰性建表兜底(戳文件门控) | 每请求 1 |
home_main_after_forum | hook/home_main_after_forum.php | 主渲染(首页中栏,帖子区块之下) | 1 |
route_after_dispatch | hook/route_after_dispatch.php | TTL 懒刷新(榜单重算,响应后) | 1 |
route_register | hook/route_register.php | 注册前台路由(含 API) | 1 |
admin_route_register | hook/admin_route_register.php | 注册后台路由(需 route:admin) | 1 |
layout_head_end | hook/layout_head_end.php | 注入 style.css(带 ?v=filemtime) | 1 |
sort_order:设为20(announcements等既有插件多在 0–10 区间,20 保证排在它们之后,与「帖子区块之下」的语义一致)。- ⚠️ 主渲染钩子若只在首页触发一次,则不需要
nextSeq()那套 radio 分组隔离(那是sidebar_above_friend_links双触发才需要的)。若后续也挂到论坛列表页,再补。
5.2 路由
| 方法 | 路径 | 控制器 | 权限 |
|---|---|---|---|
| GET | /api/home-members | FrontController::api | 游客可访问 |
| POST | /home-members/buy | FrontController::buy | 需登录 + CSRF |
| GET | /home-members(可选) | FrontController::index | 独立榜单页(可选,不做则省) |
| GET/POST | /home-members(后台) | AdminController::index / save | route:admin + CSRF |
⚠️ 路由路径不得与站点根真实文件/目录同名;后台路由不要再写
/admin前缀(核心自动拼接)。
六、文件清单
plugins/home_members/
├── plugin.json # sort_order=20 / min_core / permissions[route:admin, system:settings]
├── Plugin.php # 独立库 / 建表 / 生命周期 / 配置读写 / 榜单聚合 / 缓存
├── FrontController.php # API + 上墙购买
├── AdminController.php # 后台设置(Tab 页:基础设置 / 推荐会员与上墙)
├── hook/
│ ├── init_after.php # 惰性建表兜底
│ ├── home_main_after_forum.php # ★ 主渲染(面板 HTML,纯 CSS tab)
│ ├── route_after_dispatch.php # TTL 懒刷新
│ ├── route_register.php # 前台路由
│ ├── admin_route_register.php # 后台路由
│ └── layout_head_end.php # 注入 CSS
├── views/
│ ├── admin.php # 后台设置页(复用 .admin-tabs)
│ └── panel.php # 前台面板片段(可选,抽出来便于复用)
├── assets/
│ ├── style.css # 仅补面板样式,颜色全走 --mn-*
│ └── script.js # 仅「上墙」按钮的确认/局部刷新(可选)
├── lang/{zh,zh_tw,en}.php # 三语键集合与键序必须一致
├── data/ # 独立库 + 戳文件 + .htaccess/web.config
└── 说明.md # 插件体例文档核心改动(若选方案 A):
app/Views/home/index_default.php:论坛社区区块</div>(第 104 行)之后插入 1 行
<?php \app\Helpers\Plugin::hook('home_main_after_forum'); ?>
version.php:核心版本 bump(+ 出核心增量包)
七、关键坑与红线(★ 实施前必读)
- 渲染钩子里
$this不可用(闭包作用域)→ 用htmlspecialchars()/Upload::url()/I18n::get();
图标用核心字号类 <i class="fa mn-fs-12">...;</i>,不要用 $view->icon()(返回值带内联 style,违反零内联)。
- 钩子文件禁止声明顶层函数/类 → 否则被降级为
include_once,多入口场景第二处静默消失(无报错、无日志)。 - 首页
PageCache:游客首页走整页永久缓存(PageCache::render,只在发帖/回帖时失效)。
→ 上墙成功后必须主动 PageCache::invalidate(),否则游客看到的还是旧首页(登录用户不走 PageCache,会立刻看到)。
这也是「榜单缓存 5 分钟」在游客侧的实际表现会比 5 分钟更久的原因,属核心机制,需在 说明.md 写明。
- SQLite 无
FOR UPDATE:防重复上墙不能靠行锁。方案:事务内INSERT INTO hm_wall (...) SELECT ... WHERE NOT EXISTS (SELECT 1 FROM hm_wall WHERE user_id=? AND expires_at>?),或对「同用户未过期记录」建部分唯一索引兜底。
(项目既有教训:探针/并发写必须证明「零残留 + 不重复扣分」。)
settings_cache.php不自动补新键 → 后台保存后 / 上线新键后必须清缓存,否则读到的还是旧值。- 「用户组框色」FlintHub 没有:
user_groups表有color字段,但头像框组件不存在。
→ 方案里只做头像(Upload::url(avatar),空则 User::defaultAvatarPath()),不做框色。若你要框色,需另行评估(属于新增视觉能力,不是本次范围)。
- 「迁移旧配置」无源可迁:FlintHub 上没有
xnx_home_block。
→ 建议在 Plugin::activate() 里写一段幂等兼容读取:若 settings 表存在 xnx_home_block_ 系列键则映射到 home_members_,否则直接跳过(无害、可重复执行)。
这样既保留「借鉴思路」的完整性,又不做无用功。若你认为没必要,可以直接去掉这段。
- **零内联 / 颜色只用既有
--mn-*:--mn-danger全站 0 处定义**(曾导致按钮隐形);--mn-bg-hover不存在。
危险色用 --mn-alert-error-{bg,text}(成对、9 套主题全有)。
- CSS 注入早于核心
modern.css→ 同权重会被核心压掉,插件选择器一律带前缀提权(如.hm-panel .hm-tab)。 - 插件目录名含下划线 → 核心按目录名硬拼类名会落空,需在
Plugin.php末尾class_alias(pledge_checkin同款处理)。 - 性能红线:渲染钩子内查询数 ≤ 5。本设计正常请求 = 1(
hm_cache)+ 1(batchGetUsers)+ 1(推荐位,实时)= 3 次。 - 改
hooks清单后必须Plugin::refresh()重建plugins_cache.json;部署后走后台「维护 → 清理缓存」。
八、分期实施(建议)
| 阶段 | 内容 | 产出 |
|---|---|---|
| P0 | 拍板 §二 落点 + §十 五个决策点 | — |
| P1 | 核心新增 home_main_after_forum 钩子(2 行)+ 核心版本 bump | 核心增量包 |
| P2 | 插件骨架:plugin.json / 独立库 / 建表 / 生命周期 / 配置读写 | 可激活 |
| P3 | 四榜聚合 + TTL 懒刷新 + API 接口 | 数据层可测 |
| P4 | 前台面板渲染(纯 CSS tab,参考 leaderboard) | 首屏可见 |
| P5 | 上墙购买(事务 + 扣分 + 防重复 + PageCache 失效) | 可购买 |
| P6 | 后台设置页(Tab:基础设置 / 推荐会员与上墙 + 购买记录表) | 可配置 |
| P7 | 三语 / 说明.md / 验证 / 出包发布 | 发布包 |
九、验收计划(沿用项目既有手法)
- 数据层:四榜 vs 手写 SQL 逐行对照;缓存落盘 / TTL 判定;排除封禁;稳定排序。
- 并发/一致性:并发上墙不重复扣分、不重复记录(零残留);余额不足 → 记录与流水一起回滚。
- HTTP 端到端:
/api/home-members游客 200 + JSON 形状;POST /home-members/buy未登录 302、CSRF 负例、余额不足文案带数字、成功后 302 + 首页出现自己。 - 渲染:真实 HTTP 抓首页,断言面板 DOM / tab 数 / 坑位数 / 「我要上墙」显隐条件。
- 静态:
php -l(8.2 + 8.0 双版本)、php_var_cjk_check.php、fh_i18n_check.php(三语键集合与键序)。 - 界面观感:交你实测(按既有分工)。
十、待你拍板的 5 件事
- 落点:新增核心钩子(方案 A,推荐)/ 复用公告钩子(B)/ JS 注入(C)?
- 插件命名:
home_members(推荐,FlintHub 风格)/ 沿用xnx_block_user/ 其他? - 「我要上墙」入口:只做会员区块内(推荐)/ 还要在帖子区块右上角再加一个按钮(那需要另一个核心钩子)?
- 土豪榜排序键:确认只保留积分(去掉「积分类型」单选)?
- 迁移逻辑:保留「兼容读取
xnx_home_block_*」的幂等迁移(推荐,无害)/ 直接去掉?