[开发]回帖奖励池插件 —— 开发方案(v2)

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

本文由 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}/grabpost_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(不发外部请求)
categorycommunity(社区互动类)
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_afterapp/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 = 1

5.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 三层防线

层机制防护对象
1UPDATE ... WHERE remaining >= :got + rowCount()并发抢发导致池子变负
2UNIQUE(pool_id, user_id) + cnt < K 判定同一用户超领
3state 列 + 补偿扫描跨库失败导致丢钱/超发

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_afterhook/init_after.php惰性建表兜底必须 try/catch(try { $plugin->ensureSchema(); } catch (\Throwable $e) { error_log(...) }),否则建表失败拖垮整个 init
post_create_afterhook/post_create_after.php核心发奖逻辑(§5.3)参数 post_id/thread_id/user_id。实测在事务外(§5.1)。这里绝不能做外部 I/O(网络/上传),只碰数据库
route_after_dispatchhook/route_after_dispatch.php① 过期退余 ② 补偿扫描(state=0)无参数、单触发、响应已产出(index.php:321)。⚠️ 别挂 init_after(拖慢首字节)。⚠️ 要 session 门控 60 秒,别每请求扫全表
admin_route_registerhook/admin_route_register.php注册后台路由开头 if (!isset($router)) return;。必须声明 "route:admin"
layout_head_endhook/layout_head_end.php仅 thread/show 加载 CSS按 $template 判断;filemtime 版本号
thread_redpacket_areahook/thread_redpacket_area.php主题页奖池卡片参数 thread。⚠️ 渲染钩子禁 die/exit/header(红线 3)
post_create_extrahook/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 开发顺序

  1. 建骨架 + plugin.json + Plugin.php(含 class_alias)
  2. ensureSchema() 幂等 DDL + activate() / deactivate() / uninstall()
  3. init_after 兜底 + admin_route_register
  4. Plugin::createPool()(预扣 + 入库,事务)
  5. hook/post_create_after.php(核心发奖) ← 重点
  6. hook/thread_redpacket_area.php(卡片)+ hook/post_create_extra.php(表单)
  7. hook/route_after_dispatch.php(退余 + 补偿)
  8. assets(零内联)+ 三语语言包 + 后台
  9. 删 plugins/plugins_cache.json 重建 → 后台启用 → 建表验证

12.2 验证清单(每条都要跑)

#项方法通过标准
1语法php -l 逐文件无 syntax error
2激活建表后台启用 → 读 data/reply_reward.sqlite2 张表 + 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 或重定向)
15CSRF不带 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() 缺参 = 500verifyOrDie($_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 条 + 插件名,我再动手写代码。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 4 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入