本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/回帖奖励池插件开发方案_2026-09-25.md。
回帖奖励池插件 —— 开发方案(v2)
v2 变更说明:原「回帖领红包」模型(回帖后可领一个随机红包)已废弃。
现模型 = 楼主设一个积分奖池,用户回帖即自动获得固定积分,池子发完为止。
本文只是方案,未写任何代码。所有「事实依据」标注
文件:行号,可直接核对。依据规范:
docs/插件开发规范.md(v1.3 / 2026-09-20)
一、需求定义(已与老搭档确认)
1.1 一句话
楼主挂一个积分奖池:设「总积分 X」+「每层回帖发 N 分」,X 分从楼主账户一次性预扣;用户在该主题回帖成功后自动获得 N 分,池子发完即止,剩余退回。
1.2 已确认的 6 条决策
| # | 决策点 | 结论 |
|---|---|---|
| 1 | 领奖方式 | A. 回帖即时到账(post_create_after 钩子直接发,无手动领取动作) |
| 2 | 发放上限 | 按总积分封顶 |
| 3 | 扣款时机 | 挂奖池时一次性预扣(Points::deduct(),池子余额存插件库) |
| 4 | 尾数处理 | ① 最后一层给余数(100 分 / 每层 3 分 → 33 层各 3 分,第 34 层得 1 分,池子清空) |
| 5 | 限领规则 | 每人限领 K 次(K 可配,默认 1;同主题同一用户最多触发 K 次发放) |
| 6 | 过期处理 | 有有效期,到期未发完的余数自动退楼主 |
1.3 完整闭环
楼主侧:
发帖时勾「设置回帖奖励」→ 填「总积分 X」+「每层 N 分」+「有效期 D 天」
→ 界面实时提示「将发放约 ⌊X/N⌋ 层(最后一层得 X mod N 分)」
→ 提交时 Points::deduct(楼主, X) 预扣 → 池子入库(thread_id 关联)
└─ 也支持:已发主题的楼主在主题页奖池卡片上补挂(只有楼主可见入口)
用户侧(回帖):
主题页回帖提交成功
→ 核心事务提交(帖子行 / 回复计数 / 回帖积分 / viewed_replies)
→ 事务外触发 post_create_after(thred_id, user_id, post_id)
→ 插件钩子:
① 查本主题有效池子(status=进行中 and 未过期 and remaining > 0)
② 查本人在本池已领次数 < K
③ 算本次应发 = min(N, remaining)
④ 原子扣池子:UPDATE ... SET remaining = remaining - got WHERE id=? AND remaining >= got AND status=0
(rowCount() == 0 则放弃,静默不报错)
⑤ INSERT 领取计数(UNIQUE(thread_id, user_id) 冲突则 cnt+1)
⑥ Points::award(回帖人, got, '回帖奖励', threadId, 'reply_reward')
⑦ got == 0(池子刚好发完)→ 关池;若池子发完时 remaining == 0 → status=1
→ 用户无感,积分直接到账 + 收到积分通知
节点:
池子发完 → 卡片降级「奖励已发完」
过期 → route_after_dispatch 触发退余:Points::award(楼主, remaining, '回帖奖励过期退回')
用户达 K 次 → 卡片显示「你已领取 K 次」1.4 与现有 red_packet(积分红包)的区别
| `red_packet`(已有 v1.8.0) | `reply_reward`(本方案) | |
|---|---|---|
| 核心动作 | 用户点「抢红包」 | 用户回帖即自动到账 |
| 金额 | 随机(二倍均值法) | 固定每层 N 分 |
| 上限口径 | 按份数 | 按总积分 |
| 触发钩子 | 自己的路由 /redpacket/{id}/grab | post_create_after |
| 关系 | 完全独立,不通信、不共享表 |
两者都挂
thread_redpacket_area(app/Views/thread/show.php:208),同一主题若两种都挂会上下叠两张卡。本方案不做互斥(互斥要改red_packet,本次不碰),靠运营自律。
二、命名与目录结构
- 插件目录名:
reply_reward(改用reward而非red_packet,避免与已有插件名混淆) - 命名空间:
Plugin\ReplyReward(首字母大写驼峰) - ⚠️ 目录名含下划线 →
Plugin.php末尾必须class_alias(规范 §4.1 / §11.12):
// plugins/reply_reward/Plugin.php 末尾
\class_alias(Plugin::class, 'Plugin\reply_reward\Plugin', false);依据:核心
app/Helpers/Plugin.php:699/750/846用目录名硬拼\Plugin\{目录名}\Plugin。不加 →
activate/deactivate/uninstall静默跳过(不报错、不写日志、后台提示成功),典型症状 = 卸载后表残留。全站 29 个插件里 16 个还没修。
plugins/reply_reward/
├── plugin.json
├── Plugin.php # 库连接 / 建表 / 生命周期 / 核心发奖方法
├── hook/
│ ├── init_after.php # 惰性建表兜底(try/catch)
│ ├── route_after_dispatch.php # 过期退余(响应已产出后跑,不拖首字节)
│ ├── admin_route_register.php # 后台路由(需 route:admin)
│ ├── layout_head_end.php # 仅 thread/show 加载 CSS
│ ├── thread_redpacket_area.php # 主题页奖池卡片
│ └── post_create_extra.php # 发帖表单「回帖奖励」折叠区
├── views/
│ └── admin/
│ └── settings.php # 后台配置 + 奖池列表
├── assets/
│ ├── style.css
│ └── script.js
├── lang/
│ ├── zh.php
│ ├── en.php
│ └── zh_tw.php
└── data/ # 运行时生成(Plugin::db() 自动建 + 写 web.config)不做的部分(与 v1 方案的差异):
- ❌ 不需要
FrontController.php(没有前台 AJAX 结算,发放全在钩子里完成) - ❌ 不需要
route_register(没有前台自定义路由) - ❌ 不需要
nav_plugin_links(无前台独立页面,奖池只活在主题页) - ❌ 不需要
post_create_after里的「二次校验是否回帖」——回帖就是发奖事件本身,天然自洽
三、plugin.json
{
"name": "回帖奖励",
"version": "1.0.0",
"author": "SoSite",
"description": "楼主设置积分奖池,用户回帖即自动获得固定积分,池子发完即止,过期余数退还",
"activated": false,
"category": "community",
"icon": "gift",
"admin_url": "/admin/reply-reward",
"hooks": {
"init_after": "hook/init_after.php",
"route_after_dispatch": "hook/route_after_dispatch.php",
"admin_route_register": "hook/admin_route_register.php",
"layout_head_end": "hook/layout_head_end.php",
"thread_redpacket_area": "hook/thread_redpacket_area.php",
"post_create_extra": "hook/post_create_extra.php"
},
"permissions": ["route:admin"]
}| 字段 | 说明 |
|---|---|
permissions | 只需 "route:admin"(注册了后台路由,permission_enforce 默认已置 1,缺声明会被 DENY)。不需要 system:settings(配置存插件独立库)、不需要 net:fetch(不发外部请求) |
category | community(社区互动类) |
activated | 初始 false,装好后手动启用 |
| hooks 路径 | 必须 / 分隔。改完必须重建 plugins/plugins_cache.json(删文件或 Plugin::refresh()) |
⚠️ 注意:本插件注册了
post_create_after吗? —— 没有! 这是本方案最反直觉的一点,见 §五。
四、数据表设计(独立库 plugins/reply_reward/data/reply_reward.sqlite)
4.1 DDL
-- 奖池主表
CREATE TABLE IF NOT EXISTS rr_pools (
id INTEGER PRIMARY KEY, -- 非 AUTO_INCREMENT
creator_id INTEGER NOT NULL,
thread_id INTEGER, -- 挂载主题(NULL = 未绑定,暂不用)
thread_title TEXT NOT NULL DEFAULT '',
total_points INTEGER NOT NULL DEFAULT 0, -- 楼主设定的总积分(预扣额)
per_reward INTEGER NOT NULL DEFAULT 0, -- 每层发多少分
daily_limit INTEGER NOT NULL DEFAULT 1, -- 每人限领次数 K
remaining INTEGER NOT NULL DEFAULT 0, -- 池子余额
claimed_count INTEGER NOT NULL DEFAULT 0, -- 已发放层数(统计用)
refunded INTEGER NOT NULL DEFAULT 0, -- 已退还积分(过期退余)
expire_at TEXT NOT NULL, -- 'Y-m-d H:i:s',SQLite 无 DATETIME
status INTEGER NOT NULL DEFAULT 0, -- 0=进行中 1=已发完 2=已过期(已退款) 3=已取消
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rr_pools_thread ON rr_pools(thread_id, status);
CREATE INDEX IF NOT EXISTS idx_rr_pools_expire ON rr_pools(status, expire_at);
-- 领取计数表(UNIQUE 防超领的第一道硬约束)
CREATE TABLE IF NOT EXISTS rr_claims (
id INTEGER PRIMARY KEY,
pool_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
cnt INTEGER NOT NULL DEFAULT 0, -- 该用户在本池已领次数(累加)
points INTEGER NOT NULL DEFAULT 0, -- 累计已得积分
updated_at TEXT NOT NULL,
UNIQUE(pool_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_rr_claims_user ON rr_claims(user_id, updated_at);SQLite DDL 红线(规范 §10.3):INTEGER PRIMARY KEY(不是 AUTO_INCREMENT)、TEXT(不是 VARCHAR/DATETIME)、CREATE TABLE IF NOT EXISTS、UNIQUE(a,b)。
索引 DDL 必须写在 CREATE TABLE 之后(本仓库习惯,避免规划器误选)。
为什么
rr_claims是累加行而不是每领一次插一行?见 §6.2。
五、⚠️ 本方案最关键的设计:怎么发、在哪发
5.1 问题:post_create_after 在外层事务内
规范 §9.1 表格第 32 行原文:
| 钩子 | 触发点 | 说明 |
|---|---|---|
post_create_after | app/Controllers/ThreadController.php:163 | 回帖成功(⚠️ 此时仍在外层事务内,随外层一起提交/回滚) |
但实测代码 ThreadController.php:130-179:
$replyOk = false;
try {
$db->begin();
$postId = (new Post())->insert([...]);
$threadModel->incrementReplyCount($threadId, $now);
Points::award(...); // 回帖 +2 分
Points::markReplied($threadId, ...);
$db->commit();
$replyOk = true;
} catch (\Exception $e) {
$db->rollback();
\error_log(...);
}
// 事务外!
if ($replyOk) {
try {
... 附件落盘 ...
\app\Helpers\Plugin::hook('post_create_after', [...]); // :175
Notification::notifyReplyAggregated(...);
} catch (\Throwable $e) { ... }
}结论:当前代码里 post_create_after 在事务外触发(ThreadController.php:175,注释也明说「事务外:附件落盘登记 + 插件钩子 + 通知」)。
规范 §9.1 的「在外层事务内」是过时描述(核心已重构过:注释写明「原实现会」把附件/钩子压在事务里)。
🚩 这条差异要在动手前先记一笔:我会顺手把规范 §9.1 第 32 行改对(规范自己要求「改核心后请同步更新本表」)。但这属于改
docs/,需要你点头。
5.2 那为什么不注册 post_create_after?
因为要判「本次该发多少、池子还有没有、这人领够没」必须查插件库,而 post_create_after 是每次回帖都触发的路径。如果在这里查库:
post_create_after每次回帖触发 1 次 → 查池子 1 次 + 查计数 1 次 = 2 次插件库查询- 看起来没问题?但规范 §11.4「每页 Hook 查询红线 ≤ 5 次」是按页面算的,回帖是 POST 请求,不叠页面查询。
所以其实可以注册。 我上一版担心的是「楼中楼也触发」——已核实不触发(post_create_after 全站唯一触发点 ThreadController.php:175,楼中楼不走这条路径,Points::markReplied() 也只在 ThreadController.php:142 一处调用)。
修正结论:注册 post_create_after,这是唯一正确的发奖点。 上面的目录结构里漏列了,实际 hooks 应为:
"post_create_after": "hook/post_create_after.php"理由:这是「回帖成功」的唯一权威事件,语义完全对应。用别的钩子(如 points_award_after 里反查是不是回帖)是扭曲语义,会误命中发帖、投票等场景。
5.3 钩子里必须做的 6 件事(顺序不能乱)
// hook/post_create_after.php(示意,非最终代码)
// 参数:post_id / thread_id / user_id
// ① 命中判定:本主题有没有进行中的池子(1 次查询)
// SELECT * FROM rr_pools
// WHERE thread_id = :tid AND status = 0 AND remaining > 0 AND expire_at > :now
// ORDER BY id ASC LIMIT 1
// ② 领次判定:本人已领次数(1 次查询,可用同一连接)
// SELECT cnt FROM rr_claims WHERE pool_id = :pid AND user_id = :uid
// ③ 若 cnt >= daily_limit → 直接 return(静默,不打扰用户)
// ④ 算本次应发:$got = min($pool['per_reward'], $pool['remaining']);
// 若 $got <= 0 → return
// ⑤ 原子扣池(红线 5:必须原子 + rowCount 校验)
// UPDATE rr_pools SET remaining = remaining - :got, claimed_count = claimed_count + 1
// WHERE id = :pid AND status = 0 AND remaining >= :got
// → rowCount() === 0 → 放弃(并发被别人抢先 / 池子空了),return
// ⑥ 计数 + 发积分(同一事务内)
// UPSERT rr_claims(UNIQUE(pool_id,user_id))
// Points::award($userId, $got, '回帖奖励', $threadId, 'reply_reward');
// 若 remaining - got == 0 → UPDATE rr_pools SET status = 15.4 ⚠️ 事务边界:两个库,不能真原子
这是本方案唯一的硬伤,必须明说。
- 插件库(
Plugin::db('reply_reward'),原生 PDO)——rr_pools/rr_claims - 核心库(
Database::getInstance(),框架类)——users.points/points_log
SQLite 跨库无法同一事务。 所以 §5.3 的 ⑤⑥ 只能:
| 方案 | 顺序 | 崩在中间会怎样 |
|---|---|---|
| ① 先扣池提交,再发积分 | 扣池 commit → Points::award() | 用户没拿到分,池子的钱丢了(既没发给用户,也不在池子里)→ ⚠️ 丢钱 |
| ② 先发积分,再扣池提交 | Points::award() → 扣池 commit | 用户白拿分,池子没减 → ⚠️ 超发 |
我推荐 ①,理由:
- ① 的失败模式是钱少发给用户(平台不亏),且用户如果发现没到账会再回一帖——但此时池子已扣,用户会重复被扣……不行。
修正推荐:③ 两阶段(写 rr_claims 用状态列)
-- rr_claims 加一列
state INTEGER NOT NULL DEFAULT 0 -- 0=pending 1=done流程:
beginTransaction
⑤ 原子扣池(rowCount 校验)
INSERT rr_claims (pool_id, user_id, cnt=1, points=got, state=0) -- 占位
commit
Points::award(user, got, ...) -- 核心库,独立事务
beginTransaction
UPDATE rr_claims SET state = 1 WHERE pool_id=? AND user_id=?
若 remaining == 0 → UPDATE rr_pools SET status = 1
commit兜底:route_after_dispatch 里的清理任务扫 state = 0 AND updated_at < now - 5分钟 的 rr_claims → 说明积分没发成功 → 补发(Points::award 后再置 state=1)。
🚩 这条要你拍板:做到 ③ 两阶段 + 补偿扫描,代码量比 ① 多约 40 行,但不会丢钱也不会超发。
我的建议:上 ③。理由是你维护的是生产站,用户积分是敏感数据,宁多写 40 行。
但如果你觉得「回帖奖励这种活动积分丢了无所谓」,① 也能用——代价是极端情况下用户回帖不发分且不可恢复。
六、防超领 / 防刷
6.1 三层防线
| 层 | 机制 | 防护对象 |
|---|---|---|
| 1 | UPDATE ... WHERE remaining >= :got + rowCount() | 并发抢发导致池子变负 |
| 2 | UNIQUE(pool_id, user_id) + cnt < K 判定 | 同一用户超领 |
| 3 | state 列 + 补偿扫描 | 跨库失败导致丢钱/超发 |
6.2 为什么 rr_claims 是「累加行」而不是「一次一行」
- 若一次一行(
UNIQUE(pool_id, user_id, seq)):K 次就要写 K 行,且判「领够没」要COUNT(*)。 - 累加行:
UNIQUE(pool_id, user_id),UPDATE ... SET cnt = cnt + 1 WHERE ... AND cnt < :k——一次 UPDATE + rowCount 就能原子判限,与红线 5 同构。 - ⚠️ 若用 UPSERT 且 SQLite 版本 < 3.24 不支持
ON CONFLICT DO UPDATE→ 退化为「先 SELECT 再 INSERT/UPDATE」,并发下有窗口。本机/生产 SQLite 版本需实测确认。
6.3 回帖本身的门槛(沿用核心)
不需要插件额外做——核心回帖已有:
- 登录校验、权限校验、限流(
RateLimiter,IP + Session 双维度) - 发帖/回帖积分(
points_post_create)
插件不重复做限流,避免叠加造成「正常用户被误拦」。
七、钩子清单(逐个:职责 / 参数 / 坑)
| 钩子 | 文件 | 职责 | 关键注意 |
|---|---|---|---|
init_after | hook/init_after.php | 惰性建表兜底 | 必须 try/catch(try { $plugin->ensureSchema(); } catch (\Throwable $e) { error_log(...) }),否则建表失败拖垮整个 init |
post_create_after | hook/post_create_after.php | 核心发奖逻辑(§5.3) | 参数 post_id/thread_id/user_id。实测在事务外(§5.1)。这里绝不能做外部 I/O(网络/上传),只碰数据库 |
route_after_dispatch | hook/route_after_dispatch.php | ① 过期退余 ② 补偿扫描(state=0) | 无参数、单触发、响应已产出(index.php:321)。⚠️ 别挂 init_after(拖慢首字节)。⚠️ 要 session 门控 60 秒,别每请求扫全表 |
admin_route_register | hook/admin_route_register.php | 注册后台路由 | 开头 if (!isset($router)) return;。必须声明 "route:admin" |
layout_head_end | hook/layout_head_end.php | 仅 thread/show 加载 CSS | 按 $template 判断;filemtime 版本号 |
thread_redpacket_area | hook/thread_redpacket_area.php | 主题页奖池卡片 | 参数 thread。⚠️ 渲染钩子禁 die/exit/header(红线 3) |
post_create_extra | hook/post_create_extra.php | 发帖表单「回帖奖励」折叠区 | 字段用 rr_* 前缀(**别和 red_packet 的 rp_* 混**)。零内联 |
7.1 thread_redpacket_area 卡片状态机
[本主题无池子] → 楼主看到「设置回帖奖励」入口;其他人不渲染
[进行中 · 我未领过] → 「回帖可得 N 积分 · 剩余 X 分」+「去回帖」锚链
[进行中 · 我已领 cnt 次] → 「你已领取 cnt/K 次」
[进行中 · 我达上限] → 「你已领满 K 次」
[已发完] → 「奖励已发完」
[已过期] → 「奖励已过期」
[未登录] → 「登录后可参与」+ 带 return 的登录链接⚠️ 判定「本人已领次数」查插件库 1 次;不要在这里调 Points::canViewContent()(那是 v1 方案用的,本方案不需要——回帖即发放,无「是否回帖」判定)。
八、过期退余与补偿
8.1 挂在哪、多久跑一次
挂 route_after_dispatch(index.php:321),session 门控 60 秒:
if (($_SESSION['_rr_maintain_at'] ?? 0) > time() - 60) return;
$_SESSION['_rr_maintain_at'] = time();
// 然后做两件事绝不放 init_after(规范 §11.20:那是每请求路径,全表扫描 + 退款会拖慢首字节)。
8.2 两件维护工作
① 过期退余
SELECT * FROM rr_pools WHERE status = 0 AND expire_at < :now AND remaining > 0逐条 → Points::award($creator_id, $remaining, '回帖奖励过期退回', $poolId, 'reply_reward') → UPDATE status = 2, refunded = remaining, remaining = 0。
⚠️ 单条失败要 error_log 并跳过,不要整批回滚(否则一个坏数据卡死所有退款)。
② 补偿扫描(仅当采用 §5.4 方案 ③)
SELECT * FROM rr_claims WHERE state = 0 AND updated_at < datetime('now', '-5 minutes')→ 说明积分没发成 → 补 Points::award() → 置 state = 1。
九、视图 / assets / 语言包
9.1 视图
- 前台外层用
.mn-section(规范 §6.4) - 用
$this->section()/$this->e()/$this->t()/$this->url()/$this->icon('名称', N) - ⚠️
icon()返回<i class="fa">→ 点击判定必须closest()(e.target白名单一律失效) - 后台 Tab 复用核心
.admin-tabs/.admin-tab-btn(规范 §11.10,勿自造) - 后台配置页必须渲染「有效默认值」(规范 §11.18)
9.2 发帖表单的实时提示(post_create_extra)
用户填「总积分 X」+「每层 N 分」时,前端实时算并显示:
将发放约 33 层(每层 3 分),最后一层得 1 分算法:$layers = intdiv(X, N),余数 X % N。
- 若
X < N→ 提示「总积分不足,至少 N 分」 - ⚠️ X 是 0/N 的整数倍时不说「最后一层得 0 分」,直接显示「共 33 层」
零内联:用 data-* + assets/script.js 的 input 事件监听。
9.3 assets 零内联(P0,规范 §7.3)
禁止:内联 <script> 块、on* 事件属性、style="" 属性。
⚠️ 两个已知 CSS 坑:
- **只能用既有
--mn-*变量**(新造 → 夜间「浅底浅字」)。核心 130 个--mn-里只有 37 个 9 套主题全有,用前脚本化核对。⚠️--mn-danger/--mn-spectrum-不可用。 - 核心
.mn-btn会静默压掉插件单类样式(同 (0,1,0),核心后胜)→ 必须提权(.rr-form .mn-btn)。
9.4 语言包(规范 §八)
- 前缀
plugin.reply_reward. - zh / en / zh_tw 三包键集合零差异(⚠️ 验收标准是键集合零差异,不是「值非空」)
- ⚠️
I18n::get()缺键return $key;(永不白屏)→ 只把键名当文案吐出,页面正常、不报错、无日志,最阴的一类缺陷 - 因此:改视图后必须跑「引用键 ↔ 三语字典」全量比对
预估键量:约 35~45 个。
十、后台(AdminController.php + admin_route_register)
| 路由 | 功能 |
|---|---|
GET /admin/reply-reward | 配置 + 奖池列表 |
POST /admin/reply-reward/save | 保存全局配置(PRG) |
GET /admin/reply-reward/list | 奖池分页列表(状态筛选、手动退款、强制关闭) |
全局配置项(存插件独立库 rr_settings 键值表,不写核心 settings):
- 开关(总闸)
- 默认有效期天数
- 默认每人限领次数 K
- 单池总积分上限 / 下限
- 单层积分上限 / 下限
- 是否允许普通用户挂池子(还是仅管理员/特定等级)
必守:
- 控制器内自己补管理员门禁 —— 继承
app\Core\Controller不会触发Admin\BaseController构造函数里的门禁。
正确口径只有 \app\Helpers\Auth::isAdmin()(app/Helpers/Auth.php:20,role==='admin' 且 group_id===4,AND 非 OR)。
⚠️ 历史上已复发 2 次越权(site_stats、nav_manager):写成 group_id===1 等于「所有登录用户都是管理员」——user_groups 里 id=1 是「普通会员」。
范式照 vn_block:requireAdmin() + requireAdminVerified()。
- CSRF 必须传参:
Csrf::verifyOrDie($_POST['csrf'] ?? '')。⚠️verifyOrDie($token)是必填参数,缺参 =ArgumentCountError= 500(site_stats踩过)。 - ⚠️ 规范 §11.11:配置合并数字键数组必须
array_replace,禁array_merge。
十一、与核心/其他插件的交互边界
| 事项 | 结论 |
|---|---|
| 预扣楼主积分 | 必须走 Points::deduct()(app/Helpers/Points.php:211)——原子条件 UPDATE + rowCount(),嵌套事务感知 |
| 发放回帖人积分 | 必须走 Points::award()(Points.php:161)——自动带 points_log、等级联动、points_award_after、notifyPointsGain() 通知。⚠️ award() 在 $points <= 0 时直接 return(Points.php:162),所以 $got 必须 > 0 才调 |
| 查主题 | ⚠️ 禁止直查核心 threads 表(SplitDB 后已退役,0 行)→ 走 \app\Models\Thread(实例方法 find(),禁静态调用) |
| 批量补用户名 | 必须走 \app\Models\User::batchGetNames($ids)(规范 §10.1),禁止直查 users 表 |
| 通知 | Notification::add($userId, \app\Helpers\Notification::TYPE_POINTS, ...)(Notification.php:28)。⚠️ 不要用 TYPE_RED_PACKET(那是给 red_packet 预留的,语义不同) |
| 插件间 | 禁止直耦(规范 §11.6)。与 red_packet 不通信、不共享表、不互相调用 |
| 每页 Hook 查询 | 红线 ≤ 5 次(规范 §11.4)。卡片查 1 次,回帖钩子查 2 次(不同请求,不叠加) |
十二、开发与验证步骤
12.1 开发顺序
- 建骨架 +
plugin.json+Plugin.php(含class_alias) ensureSchema()幂等 DDL +activate()/deactivate()/uninstall()init_after兜底 +admin_route_registerPlugin::createPool()(预扣 + 入库,事务)hook/post_create_after.php(核心发奖) ← 重点hook/thread_redpacket_area.php(卡片)+hook/post_create_extra.php(表单)hook/route_after_dispatch.php(退余 + 补偿)- assets(零内联)+ 三语语言包 + 后台
- 删
plugins/plugins_cache.json重建 → 后台启用 → 建表验证
12.2 验证清单(每条都要跑)
| # | 项 | 方法 | 通过标准 |
|---|---|---|---|
| 1 | 语法 | php -l 逐文件 | 无 syntax error |
| 2 | 激活建表 | 后台启用 → 读 data/reply_reward.sqlite | 2 张表 + 4 个索引 + rr_settings 都在 |
| 3 | 卸载无残留 | 后台卸载 → 查库 | 表全 DROP(专验 class_alias) |
| 4 | 语言键 | 脚本比对三包 | 键集合零差异 |
| 5 | 零内联 | grep 插件产出(原文 + 剥注释双口径) | 无 <script> 块、无 on*、无 style="" |
| 6 | 预扣正确 | 楼主 100 分挂池,总积分 100 | 楼主余额 -100,池子 remaining=100 |
| 7 | 单次发放 | 另一账号回帖 1 次 | 该用户 +N,池子 remaining -= N,claimed_count +1 |
| 8 | 尾数 | 设 100 分 / 每层 3 分,回帖到第 34 次 | 前 33 次各 3 分,第 34 次 1 分,remaining=0,status=1 |
| 9 | 限领 K 次 | K=2,同一账号回帖 3 次 | 只发 2 次,第 3 次 cnt 不变、积分不变 |
| 10 | 并发 | 池子剩 5 分,3 人同时回帖 | 发放总额 ≤ 5,remaining >= 0 |
| 11 | 过期退余 | 改 expire_at 到过去 → 访问触发 | 楼主收到余数,status=2,refunded=余数 |
| 12 | 补偿(若上 §5.4-③) | 手动造一条 state=0 的 claim | 维护后补发,state=1 |
| 13 | 普通用户挂池 | 权限不足账号 POST 挂池 | 被拒 |
| 14 | 权限 | 普通会员 POST 后台路由 | 被拒(403 或重定向) |
| 15 | CSRF | 不带 csrf POST | 被拒 |
| 16 | 部署兼容 | 检查所有 URL | 全部拼 BASE_PATH |
| 17 | 编码卫生 | 文件头 | UTF-8 无 BOM + LF(规范 §11.19) |
| 18 | 积分为 0 的边界 | 池子只剩 0 | 不发奖、award() 不被调用(否则会因 <=0 静默 return 也算安全) |
⚠️ 验证时注意本机红线:
- 冒烟前必清页面缓存(
data/runtime/pages/*.html永久缓存、不按 TTL 过期,否则一直在验旧 HTML)- 会翻转全局状态(插件激活态 / 页面缓存 / DB 行)的探针绝不可并行跑
- ★ 判据:断言总数骤降 = 探针中途异常,不是产品回归
- 会话态用「手写
sessions行 +PHPSESSID」,测完 DELETE- 探针数据用完即清(会污染
points_log),删测试用户后按精确 id 补清(User::deleteWithCascade()不清points_log)
12.3 上线前门禁
- 跑
pre_deploy_data_check.php(CLI 版,不能上服务器) - 部署只传「代码 + 新增
md_*」,绝不覆盖data/与assets/uploads/ - ⚠️
plugins/reply_reward/data/绝不能传(含本地测试数据 + 会覆盖线上奖池记录) - 部署后必走后台「维护→清理缓存」
十三、风险与红线自查
| 风险 | 应对 |
|---|---|
| 目录名含下划线 → 生命周期静默失效 | Plugin.php 末尾 class_alias + 验证 #3 |
| 跨库事务不原子 → 丢钱 / 超发 | §5.4 方案 ③ 两阶段 + 补偿(🚩 待你拍板) |
| 并发导致池子变负 | 原子 UPDATE ... WHERE remaining >= :got + rowCount() + 验证 #10 |
| 同一用户超领 | UNIQUE(pool_id,user_id) + cnt < K 原子判限 + 验证 #9 |
| 尾数算错 | $got = min(N, remaining);界面实时提示分层数 + 验证 #8 |
在 init_after 里跑退款 → 拖慢首字节 | 挂 route_after_dispatch + session 门控 60 秒 |
| 后台越权 | 只用 Auth::isAdmin()(AND 非 OR)+ 验证 #14 |
Csrf::verifyOrDie() 缺参 = 500 | verifyOrDie($_POST['csrf'] ?? '') + 验证 #15 |
| 表单走 PRG 而非 JSON | 挂池子用 PRG;本插件没有 AJAX 接口(无 grab()),不存混用风险 |
| 改 hooks 后钩子不生效 | 删 plugins/plugins_cache.json 重建 |
| 新造 CSS 变量 → 夜间浅底浅字 | 只用既有 --mn-*,用前脚本核对 |
核心 .mn-btn 压掉插件样式 | 提权 .rr-xxx .mn-btn |
points_log 被测试数据污染 | 探针用完即清 + 验证后核对 points_log |
十四、需要老搭档拍板的 2 件事
| # | 问题 | 我的建议 |
|---|---|---|
| 1 | 跨库一致性做到哪一档? ① 简单顺序(极端情况丢钱/超发,不可恢复) / ③ 两阶段 + 补偿扫描(多约 40 行,不丢不超) | ③ —— 生产站用户积分敏感,宁多写 40 行 |
| 2 | 顺手把规范 §9.1 第 32 行「⚠️ 此时仍在外层事务内」改对(实测 ThreadController.php:175 在事务外) | 改 —— 规范自己要求「改核心后请同步更新本表」,留着会误导后续开发 |
另:插件名从 v1 的 reply_red_packet 改为 reply_reward(「回帖领红包」→「回帖奖励」,语义更准,且避开与 red_packet 的命名混淆)。如无异议我就按这个走。
方案完成。等你确认上面 2 条 + 插件名,我再动手写代码。