本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/home_members/说明.md。
首页会员区块插件(home_members)
首页中栏会员展示区块:推荐会员(可积分上墙)+ 最新 / 活跃 / 土豪会员 四榜,纯 CSS 切换 tab,榜单缓存 5 分钟。
由 xiunox 的
xnx_block_user(旧xnx_home_block会员侧拆分演进)借鉴思路重写,不是移植:xiunox 的三币种(credits/golds/rmbs)、
CacheHelper、ban_type、用户组头像框在 FlintHub 均不存在,全部映射到等价物(见 §三)。
版本:1.1.0 min_core:1.1.2 sort_order:2
一、挂载位置(方案 B:复用核心钩子,零核心改动)
复用核心通用钩子 announcements_display,零核心改动。
| 项 | 事实 |
|---|---|
| 触发点 | app/Views/home/index_default.php:17、app/Views/forum/index.php:22、app/Views/blog/index.php:20 —— 一次页面渲染共三处 |
| 因此 | 渲染钩子第一件事就是「仅首页」门控:$GLOBALS['__view']->currentPath() !== '/' 即 return(其余两页零查询返回) |
| 排序 | plugin.json 的 sort_order = 2;同钩子上 announcements 为 0 → 本插件恒排在公告块之后 |
| 页面位置 | 首页三栏布局的中栏:公告块之下、论坛社区区块之上 |
⚠️ 与 xiunox 原设计的唯一位置差异
xiunox 里 xnx_block_post 与 xnx_block_user 注入同一注入点、按 hooks_rank 排序 →
会员块在帖子区块之下。FlintHub 的首页视图只有 announcements_display 这一个中栏钩子
(位于论坛区块之前),全站没有任何「论坛区块之后」的中栏钩子。
方案 A(新增核心钩子 home_main_after_forum)需要改核心;站长拍板走方案 B →
会员块位于论坛区块之上。这是设计确认的结果,不是实现偏差。
若日后要改到论坛区块之下,只需核心在 index_default.php:104 之后补一行 hook('home_main_after_forum')
并把本插件的 plugin.json 钩子名换过去(其余代码不动)。
二、四个 Tab 的口径
| tab | 数据源 | 口径 |
|---|---|---|
recommend 推荐会员 | hm_wall(插件独立库)+ 后台手动 uid | 见 §三,实时计算、不走缓存 |
new 最新会员 | users | status='active',按 created_at DESC, id DESC |
active 活跃会员 | users | status='active' AND post_count > 0,按 post_count DESC, id ASC |
rich 土豪会员 | users | status='active' AND points > 0,按 points DESC, id ASC |
- 每榜人数后台可配(
top_n,默认 8,范围 1-50),作用于 最新 / 活跃 / 土豪 三榜(推荐位另有独立的slots)。 - 排除封禁用户:FlintHub 的
users表没有ban_type列,等价口径是status != 'active'
(白名单只有 active / banned)。
- 用户名 / 头像:拿到 uid 后统一走
User::batchGetUsers()(规范 §11.8.3 硬要求:
插件禁止直查 users 表取展示字段)。用户名缺失(已注销)的行直接丢弃,避免死链。
- 并列名次用
id ASC稳定排序。 - 土豪榜排序键白名单:
RICH_KEYS = ['points']。FlintHub 只有单一积分字段users.points
(不像 xiunox 有 credits/golds/rmbs 三币种),故白名单只有一项;
保留这个常量是为了「排序键必须先过白名单再拼进 SQL」这条安全红线不退化。
Tab 可配 + 可排序
后台可多选启用并给每榜一个序号(seq,小者在前)。渲染时:
- 启用的 tab 按
seq升序(seq相同则按TAB_KEYS声明顺序兜底)。 - 只启用 1 个 → 不渲染标签栏(点了没反应的控件不出现),改用核心
.mn-section-header显示该榜名称。 - 一个都不启用 →
panelEnabled()为 false,整个区块不输出。 - 序号经
Plugin::tabs()做白名单 + 类型收敛,非法行直接丢弃。
三、推荐位与「上墙」
坑位组成
推荐位(slots,默认 10,可配 1-50)
├─ ① 后台手动 uid(按填写顺序,去重)—— 永不过期、不显示剩余天数
└─ ② 购买记录(未过期)—— 补足到 slots,按 expires_at 倒序;显示「剩 N 天」- 手动 uid 解析:英文逗号 / 中文逗号 / 顿号 / 空格 / 换行均可分隔,非纯数字片段丢弃。
- 已注销用户不占坑:
batchGetUsers未命中则丢弃,并用LIMIT need+10多取若干条补位。
上墙购买
| 项 | 说明 |
|---|---|
| 入口 | 仅会员区块内的「我要上墙」卡片(登录 + 开放购买 + 坑位未满 + 本人不在墙上 时才渲染) |
| 卡片副标题 | 显示「{单价} 积分 · 上墙展示 {N} 天」,点之前就知道要花多少 |
| 提交前确认 | assets/script.js 读 form[data-hm-confirm] 弹 window.confirm,确认后置灰按钮 + 标记已提交(防双击重复扣分) |
| 扣分 | 复用核心 Points::deduct() —— 条件 UPDATE 原子防透支 + points_log 流水 + syncLevel() 等级同步 + points_deduct_after 钩子 + 扣分通知。插件不写自己的扣分逻辑 |
| 单价 0 | 免费上墙,完全不走 Points::deduct()(不产生流水、不发通知) |
| 时效 | 默认 7 天(1-365 可配),到期由查询侧过滤自动下架(无定时任务) |
| 重复上墙 | 已在墙上 → 拒绝,并提示剩余天数 |
| 坑位已满 | 拒绝,前台自动隐藏入口 |
顺序:先占位、后扣分、扣分失败回滚占位
$db->beginTransaction();
// ① 条件 INSERT 原子占位(SQLite 无 FOR UPDATE → 用 WHERE NOT EXISTS 等价防重复)
INSERT INTO hm_wall (...) SELECT ... WHERE NOT EXISTS (
SELECT 1 FROM hm_wall WHERE user_id = :uid AND expires_at > :now
)
if ($stmt->rowCount() === 0) { $db->rollBack(); return already; } // 并发时只有一方能插入
// ② 扣分(核心库独立事务);返回 false = 余额不足 → 回滚占位
if ($price > 0 && !Points::deduct(...)) { $db->rollBack(); return no_points; }
$db->commit();points字段记录成交价快照(price之后被后台改动不影响历史记录)。- 余额不足的文案带具体数字:
积分不足:上墙需 {need} 积分,你当前只有 {have} 积分。 - 后台「下架」是管理动作,不退款(
Plugin::removeWall())。 - 并发:两个独立进程同时
buy()→ 实测["ok","already"],hm_wall恰好 1 条。
结果提示(PRG + 一次性 flash)
首页视图没有 flash 渲染机制 → 采用:
POST /home-members/buy → 写 $_SESSION['hm_flash'] → 302 回首页(PRG)
GET / → announcements_display 钩子读 flash、渲染 .mn-alert、**立即 unset**- flash 渲染段刻意放在
panelEnabled()之前 → 即使管理员随后关掉了面板,用户也能看到自己刚操作的结果。 - 必须 unset:
Csrf::verify()不消费 token,不 unset 则 F5 会重复提示。 - ⚠️
Csrf::verifyOrDie在未登录时跳/login(302)、已登录但 token 过期时 403 ——
两条都是核心行为,不是本插件的分支。
四、缓存机制(性能)
首页每个访客请求都渲染,不能在渲染路径做全表聚合。
渲染钩子(仅首页)
└─ 读 hm_cache(1 次查询,全表 3 行)
├─ 数据新鲜 → 直接渲染
└─ 已过期 → 仍渲染旧数据(不阻塞),置 $GLOBALS['hm_need_refresh']
route_after_dispatch(index.php:441,响应产出之后)
└─ 标志为真 → 重算 3 榜 + 回写(3 次聚合 + 1 次 upsert)- 正常请求的 hook 查询数 = 1(hm_cache)+ 1(batchGetUsers)+ 1(推荐位实时 hm_wall)= 3 次
(规范 §11.4 第 24 条红线 ≤ 5)。
- 冷启动(首次启用 / 缓存被清):渲染钩子内同步算一次,保证首屏不留空面板。
TTL = 300秒。- 推荐位与上墙状态不走缓存,每次实时算(
hm_wall行数极少)。 - ⚠️ 游客走核心
PageCache(整页永久缓存) → 上墙成功 / 后台保存配置 / 下架,
三处都必须调 Plugin::invalidatePageCache(),否则游客看到的还是旧首页。
Windows 下 PageCache::invalidate() 可能因文件占用静默失败 → 插件补了一层 glob() + @unlink()。
登录用户不走 PageCache。
五、tab 实现:纯 CSS,零 JS
- N 个
<input type="radio" class="hm-radio">前置为.hm-tabs/.hm-panes的兄弟节点。 - 显隐 / 激活态由
:checked ~ …控制;对应关系靠 **:nth-of-type(N)(radio,按input计数)
/ :nth-child(N)(label、pane)建立,不依赖 id 语义**。
- ⚠️ radio 的
name/id带渲染序号(Plugin::nextSeq())→ 同一页面若出现多份面板,
两份不会串成一个 radio group。
- 视觉隐藏用
clip: rect(0 0 0 0)而不是display:none→ 保留键盘聚焦与方向键切换。 - 服务端一次渲染全部 pane,切换零请求。
六、文件结构
plugins/home_members/
├── plugin.json # hooks: init_after / announcements_display
│ # / route_after_dispatch / route_register
│ # / admin_route_register / layout_head_end
├── Plugin.php # 独立库 / 建表 / 生命周期 / 配置 / 4 榜聚合
│ # / 缓存读写 / 推荐位 / 上墙 / 旧配置迁移
│ # + 末尾 class_alias(目录名含下划线,规范 §4.1)
├── FrontController.php # GET /api/home-members · POST /home-members/buy
├── AdminController.php # 继承 app\Controllers\Admin\BaseController(鉴权)
├── views/admin.php # 后台三分区页(基础设置 / 推荐与上墙 / 上墙记录)
├── hook/
│ ├── init_after.php # 惰性建表兜底(戳文件门控)
│ ├── announcements_display.php # 面板渲染 + flash 渲染(三触发钩子,仅首页)
│ ├── route_after_dispatch.php # 榜单 TTL 懒刷新
│ ├── route_register.php # 前台路由
│ ├── admin_route_register.php # 后台路由(不写 /admin 前缀)
│ └── layout_head_end.php # 注入 style.css + script.js(defer)
├── assets/
│ ├── style.css # 前台面板 + 后台局部(颜色只用既有 --mn-* / --c-*)
│ └── script.js # 上墙确认 + 防重复提交 + 后台分区切换
├── lang/{zh,zh_tw,en}.php # 55 键 × 3 语,键集合与键序完全一致
└── data/
├── home_members.sqlite # 独立库(hm_wall / hm_cache)
├── schema.version # 数据层版本号戳文件(比对**内容**)
└── .htaccess / web.config # 自动生成,防 Web 直读可调常量(Plugin.php 顶部):TOP_N_DEFAULT/MIN/MAX = 8/1/50、TTL = 300、
SLOTS_MIN/MAX = 1/50、DAYS_MIN/MAX = 1/365、PRICE_MAX = 1000000。
改动 ddlStatements() 时必须同步递增 SCHEMA_VERSION,否则老站点戳文件命中、新表建不出来(规范 §11.15)。
七、数据表(插件独立库)
hm_wall id / user_id / points / started_at / expires_at / created_at
idx_hm_wall_uid(user_id, expires_at) · idx_hm_wall_exp(expires_at)
hm_cache ckey(PK) / cdata / updated_at -- ckey ∈ {new, active, rich}points= 成交价快照;expires_at为Y-m-d H:i:s文本,与核心时间列口径一致。- 建表走
Plugin::ensureSchema()(=Plugin::db($name)->exec($ddl)),
索引 DDL 必须排在 CREATE TABLE 之后。
- ⚠️ 手工删表后必须一并删掉
data/schema.version,否则戳文件命中不会重建。
八、路由
| 方法 | 路径 | 控制器 | 门禁 |
|---|---|---|---|
| GET | /api/home-members | FrontController::api | 游客可访问,输出 JSON |
| POST | /home-members/buy | FrontController::buy | requireLogin() + Csrf::verifyOrDie($_POST['_token']) + PRG |
| GET | /admin/home-members | AdminController::index | 继承 Admin\BaseController(构造函数鉴权 + 二次验证) |
| POST | /admin/home-members/save | AdminController::save | 同上 + CSRF |
| POST | /admin/home-members/remove | AdminController::remove | 同上 + CSRF |
- 后台路由不写
/admin前缀(核心挂载点自动拼接,规范 §11.22)。 plugin.json权限:route:admin+system:settings(缺后者Settings::update()会被拒绝 +error_log)。
九、设计约束(改动前必读)
- 零内联:面板输出无
style=""/on*=""/<script>(探针逐条断言)。
图标写字面量实体 + 核心字号类 mn-fs-12,不用 $view->icon()(其返回值恒带内联 style)。
- 颜色只能用既有变量:前台
--mn-(--mn-danger/--mn-spectrum-全站从未定义,已避开);
后台 --c-*。style.css 在 layout_head_end 注入 → 加载早于核心 admin.css,
故后台规则一律用更高特异性取胜,或改用 max-width 这类不与核心 width:100% 正面冲突的属性
(不打 !important 补丁)。
- 渲染钩子
$this不可用(闭包作用域,规范 §11.24):用htmlspecialchars()、Upload::url()、
I18n::get()、$GLOBALS['__view']->timeAgo()。
- 多触发钩子禁止声明顶层函数/类(规范 §9.0):
announcements_display三触发,
一旦声明会被降级为 include_once,第二/三处入口静默消失。
- 目录名含下划线必须
class_alias(规范 §4.1):核心按目录名拼\Plugin\home_members\Plugin,
与命名空间 Plugin\HomeMembers 不同标识符 → 生命周期方法会静默跳过、卸载后数据表残留。
- HTML 不允许表单嵌套 → 后台设置表单包住前两个 pane,「上墙记录」独立成第三个 pane
并放在设置表单之外(否则浏览器丢弃内层 form,表现为「点下架没反应」)。
- 两处配置写入点:
AdminController::save()与Plugin::activate();
新增配置键后必须清一次 protected/settings_cache.php(该文件不自动补新键)。
Settings是 string 语义:布尔开关保存时必须归一成'0'/'1',绝不能存空串(空串会回落默认值)。- 改
hooks清单后必须\app\Helpers\Plugin::refresh()重建plugins/plugins_cache.json。
旧配置幂等迁移
Plugin::migrateLegacy() 把 xnx_home_block_(7 个键)映射到 home_members_。
⚠️ FlintHub 上并不存在 xnx_home_block,因此本方法在本仓恒为 no-op;
保留它是为了「从 xiunox 迁移过来的站点」能自动带上原配置,且可重复执行:
① 旧键为空 → 跳过;② 新键已有值 → 不覆盖。
十、验证记录(2026-10-14)
| 套件 | 结果 |
|---|---|
语法:php -l(8.2.9nts + 8.0.2nts 双版本,13 个 PHP 文件) | 全绿 |
plugin_case_lint.py(Linux 大小写精确匹配) | 全绿 |
php_var_cjk_check.php(全角标点紧跟 $var) | 全绿(13 文件) |
fh_i18n_check.php | 三语一致 55 键;代码引用键缺 0;总 62 PASS / 0 FAIL |
plugin_form_action_audit.php | 两个后台 POST 表单均命中已注册 POST 路由 |
| 数据层对照(三榜 vs 手写 SQL 逐行 / 缓存行数与 TTL / 数据形状 / 封禁排除 / 配置读取 / 时间与余额工具) | 37 PASS / 0 FAIL |
上墙逻辑(负例零写入 / 免费上墙 / 付费走 Points::deduct / 重复拒绝 / 后台下架 / 过期自动下架 / 快照→改→还原→回读) | 61 PASS / 0 FAIL |
| 结构 + 渲染 + 降级 + 竞态(钩子无顶层符号 / plugin.json / 缓存收录 / class_alias / 三触发门控 / 零内联 / flash 只读一次 / 单 tab 与全关降级 / 序号排序 / 双进程并发只落 1 条) | 63 PASS / 0 FAIL |
HTTP 端到端(真实 php -S:首页 200 含面板 / 论坛页与博客页不含面板 / 静态资源 200 / API 游客 200 / 未登录 302 跳 /login / 缺或错 _token 403 / 余额不足带数字且零写入 / 免费上墙成功且首页出现本人 + 剩 N 天 / 重复上墙拒绝且不渲染入口 / 后台未登录 302 / 后台 200 且无未解析键名 / 非法值零写入 / 正例保存 / 下架成功) | 70 PASS / 0 FAIL |
| 守卫 | protected/error.log 零新增;测试后 users.points/level、hm_wall、points_log、notifications、7 个配置键、settings_cache.php(逐字节)全部还原并回读确认 |
未验证项(交给站长自测):双主题(默认浅色 + 暗夜星辰)下的面板观感、与中栏上下区块的视觉协调度、
移动端网格列数观感。
十一、已知取舍
- 无定时任务:到期靠查询侧过滤(
expires_at > now),hm_wall里会留历史行
(后台「上墙记录」页可见,带「已过期」徽章)。这是刻意的:便于管理员追溯,且避免在 2 核 2G 上跑定时清理。
- 下架不退款:后台「下架」是管理动作,不退积分、不删
points_log。 - 无独立榜单页:面板只出现在首页中栏,不提供「更多」链接。
/api/home-members只读:不做写入、不暴露用户隐私字段(只有 uid / 用户名 / 头像 / 榜单数值)。