[开发] qa 插件 2.0 开发规划方案(主帖 + 悬赏层松耦合闭环)

👑Lv.11 元老 🌞 骨干会员
2026-10-30 15:50:00

版本:规划稿 v2(2026-10-29,4 项关键决策已拍板) 依据:《FlintHub 插件开发规范》v1.7(2026-10-21) 目标插件:plugins/qa 当前状态:qa 插件 activated: false,plugin.json 版本 1.1.0(说明文档仍写 1.0.0) 本文件只做规划,不含任何代码改动。


〇、一句话结论

方案可行。需求与核心有 5 处差异(见 §1),但均已在 2026-10-29 拍板收敛,4 项关键决策全部采用 A 方案:

  1. 发帖 Saga:route_before_dispatch 预扣 + intent 凭证 + shutdown 补偿,严格「先扣分 → 再写主帖 → 再插悬赏」;
  2. 采纳入口:post_operation_after 内由插件自建折叠 ⋯ 菜单,不裸露采纳按钮,零核心改动;
  3. 印章联动:qa 触发自定义钩子 qa_bounty_accepted,stamp 新增监听文件盖章,符合插件解耦规范;
  4. 标题徽章:layout_body_end 输出隐藏数据节点 + script.js 注入,零核心模板改动。

以下 3 个核心事实仍需在实施时持续遵守:核心无 post_ops_actions 钩子(实际是 post_operation_after);核心无发帖前钩子;列表/详情标题旁无插件徽章钩子。


一、需求 vs 核心实际(必须核对的差异)

#需求原文 / 设想核心实际(已核对源码)规划处理
1由 post_ops_actions 钩子注入楼层「更多」菜单无此钩子。楼层只有 post_operation_after(app/Views/thread/show.php:319),且该位置没有 data-mn-actions 容器,核心的「⋯ 更多」只存在于主帖操作条(show.php:255-261)已选 A:插件在 post_operation_after 内自建折叠 ⋯ 菜单(§4.3)
2普通发帖走原来的 Thread::create()核心没有 Thread::create(),实际是 PostController::create() 内 $threadModel->insert([...])(PostController.php:135),事务 begin()/commit()插件不重复实现发帖;只挂 thread_create_after(事务已提交、已建索引)
3主帖数据存 threads 核心表当前为 SplitDB:帖子经 \app\Models\Thread 落到 main_index.topic_index,核心 threads 是退役表(规范 §10.1)禁止直查/直写 threads,一律经 \app\Models\Thread / \app\Models\Post
4采纳后「更新帖子状态为已解决」核心 threads 无 status 字段,Thread::update() 只允许少量字段;不能也不应加列状态只存在 qa_bounties.status,前台由插件派生展示「已采纳 / 已解决」
5发帖时「先扣分,再写主帖」核心没有发帖前钩子;thread_create_after 在事务提交之后已选 A:route_before_dispatch 预扣 + intent + shutdown 补偿(严格符合,§4.2)
6列表/详情标题旁渲染悬赏标签列表标题只读核心字段,没有逐行插件钩子;ulListBadges 是按 user_id 索引、渲染在作者名旁,不适合按帖悬赏已选 A:stamp 同款「服务端 JSON + 前端 script.js 注入」(§4.2)
7启用 stamp 后自动盖章stamp 文档明确:「首版不碰 qa,要做采纳自动盖章必须先给 qa 加钩子」;且 stamp 记录是按 thread 的qa 触发自定义钩子 qa_bounty_accepted;stamp 插件新增一个监听文件(§4.6)

另外:需求中的 post.php 实际是 app/Views/post/create.php,「卡片超市」锚点是 post_create_extra(create.php:72),红包/投票插件都在这里注入,位置正确。


二、总体架构(松耦合)

                    ┌─────────────────────────────┐
                    │  核心 PostController        │
                    │  /post/new (GET/POST)       │
                    │  Thread::insert + commit    │
                    └──────────────┬──────────────┘
                                   │ thread_create_after(thread_id)
                                   ▼
┌──────────────────────────────────────────────────────────────┐
│ qa 插件(独立库 plugins/qa/data/qa.sqlite)                   │
│                                                              │
│  qa_bounty_intents   预扣凭证(token / 状态机 / 补偿)        │
│  qa_bounties         悬赏主记录(thread_id UNIQUE / 状态机)  │
│                                                              │
│  读主帖:一律 \app\Models\Thread / \app\Models\Post           │
│  写积分:一律 \app\Helpers\Points::deduct / award             │
└──────────────┬───────────────────────────────┬───────────────┘
               │ 自定义钩子                     │ 自定义钩子
               ▼                               ▼
        stamp 监听(盖章)              (可选)其他插件监听

硬约束(贯穿全程)

  • 不新增/修改核心 threads(或 topic_index)任何字段;
  • 不直接 SQL 读写核心帖子表,全部走 Model;
  • qa 表只存「悬赏层」,thread_id 作为唯一绑定键;
  • 积分只经 Points::deduct() / Points::award(),禁止直写 users.points;
  • 跨库(qa.sqlite ↔ 核心库)不使用「分布式事务」,用状态机 + 补偿保证最终一致。

三、数据模型

3.1 新增表(qa 独立库,SQLite 语法)

-- 悬赏主记录:一帖一悬赏
CREATE TABLE IF NOT EXISTS qa_bounties (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    thread_id     INTEGER NOT NULL,
    user_id       INTEGER NOT NULL,              -- 楼主
    bounty_points INTEGER NOT NULL,
    status        TEXT    NOT NULL DEFAULT 'pending', -- pending | accepted | refunded
    answer_id     INTEGER NOT NULL DEFAULT 0,     -- 被采纳的核心回复 posts.id
    accepted_at   TEXT    NOT NULL DEFAULT '',
    created_at    TEXT    NOT NULL,
    updated_at    TEXT    NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_qa_bounty_thread
    ON qa_bounties (thread_id);
CREATE INDEX IF NOT EXISTS idx_qa_bounty_status
    ON qa_bounties (status, created_at);
CREATE INDEX IF NOT EXISTS idx_qa_bounty_user
    ON qa_bounties (user_id, status);

-- 发帖预扣凭证:解决「核心无发帖前钩子」的补偿锚点
CREATE TABLE IF NOT EXISTS qa_bounty_intents (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    token         TEXT    NOT NULL,
    user_id       INTEGER NOT NULL,
    bounty_points INTEGER NOT NULL,
    status        TEXT    NOT NULL DEFAULT 'init', -- init|deducted|consumed|refunded|failed
    thread_id     INTEGER NOT NULL DEFAULT 0,
    created_at    TEXT    NOT NULL,
    updated_at    TEXT    NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_qa_intent_token
    ON qa_bounty_intents (token);
CREATE INDEX IF NOT EXISTS idx_qa_intent_status
    ON qa_bounty_intents (status, created_at);

存量 qa_questions / qa_answers / qa_votes / qa_tags / qa_question_tags 保持不动,2.0 只叠加悬赏层。独立问答页是否保留由站长决定(建议保留,避免破坏旧链接)。 uninstall() 需按依赖顺序追加 DROP 两张新表;activate() / init_after 的 ensureSchema 同步追加。

3.2 状态机

intent:  init ──deduct──▶ deducted ──attach──▶ consumed
                 │             │
                 │             └──shutdown/attach失败──▶ refunded
                 └──余额不足/参数非法──▶ failed

bounty:  pending ──采纳成功──▶ accepted
             └──(v1 不做过期/删帖退款,列为后续项)

关键点:bounty.status='pending' 是唯一的「可采纳」闸门;accepted 一旦写入不可回退(除积分发放异常时补偿回 pending)。


四、链路设计

4.1 发帖页 UI(post_create_extra)

  • 钩子文件:plugins/qa/hook/post_create_extra.php
  • 输出一个 <details class="qa-bounty-panel">,位于核心附件区之后、提交按钮之前(与红包/投票卡片同级)。
  • 字段:
字段类型说明
is_qacheckbox value=1开启悬赏
bounty_pointsnumbermin=qa_bounty_min(默认 10)、max=qa_bounty_max(默认 1000),步长 1
qa_intent_tokenhiddenbin2hex(random_bytes(16)),同时写入 $_SESSION['qa_bounty_tokens'][token],TTL 30 分钟
  • 展示当前积分余额(Auth::getCurrentUser()['points'],不要读 $_SESSION['user_points'])。
  • 未登录直接 return(发帖页本身要求登录,兜底)。
  • 样式全部进 plugins/qa/assets/style.css;交互 JS 进 plugins/qa/assets/script.js;禁止内联 <style> / style="" / onclick(规范 §7.3)。
  • 核心发帖表单已含 csrf,钩子不重复输出。

4.2 服务端分流与 Saga(核心难点)

前提:核心不提供发帖前钩子。已拍板采用 方案 A(严格预扣 + 补偿);方案 B 仅作风险对照,不采用。

方案 A(已采用):route_before_dispatch 预扣 + intent 补偿

文件:plugins/qa/hook/route_before_dispatch.php

  1. 早退条件:仅当 REQUEST_METHOD=POST、路径(去 BASE_PATH 后)= /post/new、$_POST['is_qa']==='1' 时继续;否则 return。
  2. 双触发防重:route_before_dispatch 一次请求触发两次(index.php + Router::dispatch),文件内用 $GLOBALS['__qa_prepaid_guard'] 守卫;同时所有 DB 状态迁移都用条件 UPDATE + rowCount(),天然幂等。
  3. 登录与 CSRF:先 Auth::isLoggedIn()(未登录交回控制器),再 Csrf::verifyOrDie($_POST['csrf']),避免「先扣分后撞 CSRF」。
  4. 参数校验:bounty_points 必须为整数且在 min/max 内。非法 → 写 session 一次性提示、$_POST['is_qa']='' 降级为普通帖(不扣分、不丢正文)。
  5. 建 intent(qa 库):INSERT INTO qa_bounty_intents(token,user_id,bounty_points,status='init',...);token 必须命中 $_SESSION['qa_bounty_tokens'],否则降级。
  6. 预扣:Points::deduct($uid, $points, I18n::get('plugin.qa.points_bounty_escrow'), $intentId, 'qa_bounty_intent')。
    • 返回 false(余额不足)→ intent 置 failed,降级为普通帖 + 提示「积分不足,已按普通帖发布」。
    • 成功 → intent 置 deducted,登记 register_shutdown_function,把 $GLOBALS['__qa_intent'] 传给后续钩子。
  7. 失败补偿(shutdown):请求结束时,若 intent 仍为 deducted(说明主帖没发出去,或 qa 记录没写成),条件更新 deducted → refunded(rowCount()==1 才执行),并 Points::award($uid, $points, I18n::get('plugin.qa.points_bounty_refund'), $intentId, 'qa_bounty_intent_refund')。失败只写 error_log,再由 §4.2 的惰性对账兜底。
  8. 控制器继续执行:核心 PostController::create() 原逻辑不变。

补偿覆盖的真实失败路径:

失败点结果
控制器标题/正文/分类/权限校验失败提前 return,无 thread_create_after → shutdown 退款
Thread::insert() 抛异常控制器 rollback + 渲染错误页 → shutdown 退款
主帖成功但 thread_create_after 内 qa INSERT 抛异常钩子内 immediate 补偿退款 + 删残留行
PHP fatal / 请求中断register_shutdown_function 仍执行 → 退款
进程被强杀 / 机器宕机init_after 惰性对账(每 60 秒最多一次)扫描 deducted 超过 10 分钟的 intent 退款

方案 B(未采用,仅风险对照):事务后置托管(不做预扣)

thread_create_after 内:插 qa_bounties(pending) → Points::deduct → 扣款失败则删悬赏行(帖子保留为普通帖)。 优点:无 route_before_dispatch 全局拦截、无 shutdown 依赖、实现风险最低。 缺点:不满足「先扣分再写主帖」的字面顺序;「核心写入异常退款」测试会退化为「未扣款」而非「退款」。

建议:若验收报告必须出现「预扣后核心写失败 → 断言退款」这一条,选 A;否则选 B。A 的实现复杂度与回归面明显更高。

公共部分:thread_create_after

文件:plugins/qa/hook/thread_create_after.php

  1. 从 $_POST['qa_intent_token'] 取 token;查 qa_bounty_intents,仅 status='deducted' 继续(否则静默跳过,不影响发帖)。
  2. 校验 thread_id > 0、楼主与 intent 用户一致。
  3. INSERT INTO qa_bounties(thread_id,user_id,bounty_points,status='pending',...);UNIQUE(thread_id) 兜底并发/重复。
  4. intent 条件更新 deducted → consumed,写入 thread_id。
  5. 任一步失败:删刚插入的 bounty 行(若已插)、intent deducted → refunded、Points::award 退款、session 提示「悬赏创建失败,积分已退回」。
  6. 成功后 PageCache::invalidate()(游客列表页缓存含悬赏徽章数据,必须失效)。

4.3 采纳入口与采纳逻辑

入口(post_operation_after)

文件:plugins/qa/hook/post_operation_after.php

  • 由 controller_view_before 预载的 $GLOBALS['__qa_bounty_ctx'] 判定,每楼零查库。
  • 仅当:已登录、当前用户是楼主、status='pending'、该楼作者 ≠ 楼主 时输出。
  • 输出结构(折叠菜单,默认收起,绝不裸露采纳按钮):
<span class="qa-floor-ops" data-qa-ops>
    <button type="button" class="qa-ops-toggle" data-qa-ops-toggle
            aria-expanded="false" title="更多">⋯</button>
    <span class="qa-ops-menu" hidden>
        <button type="button" class="qa-accept-btn"
                data-qa-thread="123" data-qa-post="456"
                data-qa-csrf="...">采纳此回答</button>
    </span>
</span>
  • 已采纳时:在被采纳楼层输出绿色「已采纳」标记(.qa-accepted-mark),不再输出采纳按钮。
  • 菜单开合、请求发送全部在 assets/script.js,无内联事件。

服务端采纳(新增 BountyController.php + 路由)

路由(追加到 hook/route_register.php):POST /qa/bounty/accept

处理顺序(红线顺序):

$this->requireLogin();                       // ① 先登录
Csrf::verifyOrDie($_POST['csrf'] ?? '');     // ② 再 CSRF
RateLimiter::hit('qa_bounty_accept', 10, 60, true);
  1. IDOR 校验
    • (new \app\Models\Thread())->find($threadId);不存在 → 404/403;
    • (int)$thread['user_id'] !== 当前用户 → 403;
    • (new \app\Models\Post())->find($answerId);不存在或 post['thread_id'] !== threadId → 400;
    • post['user_id'] === 当前用户(自问自答)→ 403(防刷分)。
  2. 乐观锁抢占(qa 库):
UPDATE qa_bounties
   SET status='accepted', answer_id=:aid, accepted_at=:now, updated_at=:now
 WHERE thread_id=:tid AND status='pending'
  • rowCount() === 0 → 回查状态:已 accepted 返回 409 already_accepted;不存在返回 404;绝不二次发奖。
  1. 发奖:Points::award((int)$answer['user_id'], $points, I18n::get('plugin.qa.points_bounty_award'), $bountyId, 'qa_bounty')。
  2. 发奖异常补偿:条件更新 accepted → pending(带 answer_id 匹配),返回 err_system;绝不让「已采纳但没到账」静默存在。
  3. 成功后:
    • PageCache::invalidate();
    • Plugin::hook('qa_bounty_accepted', ['thread_id'=>..., 'answer_id'=>..., 'user_id'=>...])(供 stamp 监听);
    • 返回 JSON {success:true, points:N, answer_id:X};前端 location.reload() 展示印章与绿色标记。

防并发/幂等结论

  • 只有一个请求能把 pending 改成 accepted;
  • 其余 9 个请求 rowCount()==0,直接 409;
  • points_log 只出现 1 条 qa_bounty 发奖流水;
  • 重复点击采纳 = 第二次 409,不重复发奖。

4.4 前台展示

controller_view_before(批量预载,禁止 N+1)

文件:plugins/qa/hook/controller_view_before.php

  • 列表页模板:forum/index、forum/category、home/index_default
    • 从 $data['latestThreads'] / normalThreads / pinnedThreads / threads 收集 thread_id;
    • 一次 IN (...) 查询 qa_bounties(thread_id 唯一索引命中);
    • 结果写 $GLOBALS['__qa_bounty_map'](thread_id => [points,status]),供 layout_body_end 输出。
  • 详情页模板 thread/show
    • 查当前 thread_id 的悬赏;
    • $GLOBALS['__qa_bounty_ctx'] 给 post_operation_after / thread_redpacket_area 使用;
    • 同时在 $data['qaBounty'] 注入,供视图/钩子读取。

标题旁金色徽章(layout_body_end + script.js)

  • 文件:plugins/qa/hook/layout_body_end.php
  • 只在 $GLOBALS['__qa_bounty_map'] 非空时输出一个隐藏数据节点(遵守零内联,不用 <script> 块):
<div id="qa-bounty-data" hidden
     data-map="<?php echo htmlspecialchars(json_encode($map, JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT), ENT_QUOTES, 'UTF-8'); ?>"
     data-i18n="<?php echo htmlspecialchars(json_encode($i18n, ...), ENT_QUOTES, 'UTF-8'); ?>"></div>
  • assets/script.js 读取后:
    • 在 .mn-row-title 内匹配 /thread/{id} 的标题链接后插入 <span class="qa-bounty-badge">悬赏 N 积分</span>;
    • 详情页在 h1.thread-title 标题文本后插入同一徽章;
    • status='accepted' 渲染绿色「已采纳」;
    • 用 data-qa-badge 标记幂等,避免 htmx 局部刷新后重复插入;
    • 只在 forum/*、home、thread/show 页面执行。
  • 金色样式全部在 assets/style.css,用 var(--mn-warning, #d4a017) 等主题变量兜底,禁止硬编码白底/深字(规范 §11.7)。

详情页兜底卡片(thread_redpacket_area)

  • 即使 JS 被禁用/脚本未加载,详情页主帖内也能看到悬赏状态卡:
    • pending:悬赏 N 积分 · 待解决;
    • accepted:已采纳 · 悬赏 N 积分已发放;
    • 楼主且 pending 时显示引导「请在回答楼层的 ⋯ 菜单中采纳」。
  • 位置:主帖正文/附件之后、签名之前(show.php:213),与红包卡同锚点,天然融入主帖。

4.5 语言包

新增键统一前缀 plugin.qa.,zh / en / zh_tw 三份文件的键与键序必须完全一致(规范 §8 / §12.7)。

建议新增键(实施时按此表落地,顺序固定):

键中文示例
plugin.qa.bounty_panel_title开启悬赏
plugin.qa.bounty_panel_hint设置悬赏积分,采纳后自动转给回答者
plugin.qa.bounty_points_label悬赏积分
plugin.qa.bounty_points_range单次 {min}–{max} 积分
plugin.qa.bounty_balance当前积分:{points}
plugin.qa.bounty_badge悬赏 {points} 积分
plugin.qa.bounty_status_pending待解决
plugin.qa.bounty_status_accepted已采纳
plugin.qa.bounty_card_title悬赏问答
plugin.qa.bounty_card_owner_hint在回答楼层的 ⋯ 菜单中采纳
plugin.qa.bounty_more更多
plugin.qa.bounty_accept_this采纳此回答
plugin.qa.bounty_accepted_mark已采纳
plugin.qa.bounty_accept_success采纳成功,悬赏积分已发放
plugin.qa.bounty_accept_confirm确认采纳该回答并发放 {points} 积分?
plugin.qa.err_bounty_range悬赏积分需在 {min}–{max} 之间
plugin.qa.err_bounty_balance积分不足,无法发布悬赏
plugin.qa.err_bounty_not_found该帖没有可采纳的悬赏
plugin.qa.err_bounty_already该悬赏已被采纳
plugin.qa.err_bounty_self不能采纳自己的回答
plugin.qa.msg_bounty_degraded悬赏条件不满足,已按普通帖发布
plugin.qa.msg_bounty_refunded悬赏创建失败,积分已退回
plugin.qa.points_bounty_escrow发布悬赏(预扣)
plugin.qa.points_bounty_refund悬赏退款
plugin.qa.points_bounty_award回答被采纳(悬赏)

points_* 键会被写入核心 points_log.reason,沿用现有 qa 的 i18n 口径。

4.6 印章联动(stamp)

推荐路径(符合规范 §11.6 插件解耦)

  1. qa 采纳成功后触发自定义钩子 qa_bounty_accepted(qa 自己的 Plugin.php 内 Plugin::hook(...))。
  2. stamp 插件新增 hook/qa_bounty_accepted.php 并在其 plugin.json 注册:
    • getDefByCode('qa_accepted'),不存在则 saveDef([... color 绿色, enabled=1])(stamp 自己的数据,自己管);
    • codesOfThread($threadId) 判断是否已有;没有才 toggleRecord($threadId, 'qa_accepted', $userId),依靠 UNIQUE(thread_id, stamp_id) 幂等;
    • PageCache::invalidate()。
  3. 未启用 stamp 时:钩子不触发,qa 自己在详情页渲染绿色「已采纳」标记(thread_redpacket_area / 被采纳楼层),即需求中的回退方案。

备选:qa 直接 class_exists('\Plugin\Stamp\Plugin') && Plugin::isActivated('stamp') 后调用 stamp 公共方法。零改 stamp,但属于「插件直连」,审计会记违规(规范 §11.6)。需站长明确授权。


五、文件级改造清单

文件动作说明
plugins/qa/plugin.json改version: 2.0.0;新增 hooks:post_create_extra、thread_create_after、post_operation_after、controller_view_before、layout_body_end、thread_redpacket_area、thread_show_top、route_before_dispatch;min_core: 1.2.17
plugins/qa/Plugin.php改DDL 增加两表;新增 createIntent/consumeIntent/refundIntent/getBounty/getBountyMap/attachBounty/acceptBounty/reconcile;uninstall 增删表;版本头 2.0.0
plugins/qa/BountyController.php新增accept():requireLogin → CSRF → RateLimiter → IDOR → 乐观锁 → 发奖 → JSON
plugins/qa/hook/post_create_extra.php新增发帖页折叠面板
plugins/qa/hook/route_before_dispatch.php新增预扣 + intent + shutdown 补偿;双触发守卫
plugins/qa/hook/thread_create_after.php新增插入悬赏记录 / 失败退款
plugins/qa/hook/controller_view_before.php新增列表批量 map + 详情 ctx
plugins/qa/hook/post_operation_after.php新增楼层折叠「⋯」采纳菜单 / 已采纳标记
plugins/qa/hook/thread_redpacket_area.php新增详情页悬赏状态卡
plugins/qa/hook/thread_show_top.php新增一次性提示(降级/退款),读后即焚
plugins/qa/hook/layout_body_end.php新增输出 #qa-bounty-data 隐藏数据节点
plugins/qa/hook/layout_head_end.php改原为空实现;按模板白名单加载 style.css / script.js(?v=filemtime)
plugins/qa/hook/route_register.php改追加 POST /qa/bounty/accept
plugins/qa/assets/style.css改面板、徽章、折叠菜单、已采纳标记;主题变量化
plugins/qa/assets/script.js改面板开合、楼层菜单、采纳 fetch、标题徽章注入、提示
plugins/qa/lang/{zh,en,zh_tw}.php改追加 §4.5 键,三语键集/键序一致
plugins/qa/说明.md改2.0 架构、表、钩子、路由、验收口径
plugins/plugins_cache.json重建删除或 Plugin::refresh();改 hooks 后必须做
plugins/stamp/hook/qa_bounty_accepted.php + stamp/plugin.json新增/改(推荐路径)盖章适配器;如选直连方案则不需要

不改任何 app/ 核心文件、不改核心模板、不加 threads 字段。


六、安全与一致性红线落地表

红线落地方式
先登录后 CSRF采纳:requireLogin() 在 Csrf::verifyOrDie() 之前;预扣钩子同样先 Auth::isLoggedIn() 再 CSRF
参数化查询所有 SQL prepare()+execute([...]);ID 全部 (int) 强转;IN (...) 用占位符
IDOR采纳前校验 thread.user_id == 当前用户;answer.thread_id == thread_id;答案作者 ≠ 楼主
防超扣扣分只经 Points::deduct()(内部 UPDATE ... WHERE points >= :p + rowCount())
防重复发奖UPDATE ... WHERE status='pending' 乐观锁 + rowCount();409 幂等
积分流水预扣 / 退款 / 发奖三条全部由 Points 写 points_log,related_type 带 qa_bounty_*
跨库一致性intent 状态机 + shutdown 补偿 + init_after 惰性对账;任何补偿都条件 UPDATE 幂等
双触发钩子route_before_dispatch 用 $GLOBALS 守卫 + 条件 UPDATE;按规范 §9.4 做双 include 回归
零内联无 <style>、无 <script> 块、无 style=""、无 on*;数据走 data-*;样式/JS 全在 assets
多语言zh/en/zh_tw 键集与键序完全一致,fh_i18n_check.php 零缺失
输出转义所有输出 $this->e() / htmlspecialchars(..., ENT_QUOTES, 'UTF-8');JSON 用 JSON_HEX_*
主题适配颜色用 --mn-* 变量兜底,暗夜星辰下可读
游客缓存发帖/采纳后 PageCache::invalidate(),避免列表徽章不更新
路径安全本插件不落盘上传;如未来有导出,文件名必须 basename()

七、实施阶段(按依赖顺序,不含工期)

阶段内容出口条件
P0拍板 §9 的 4 个决策已完成(2026-10-29,全部 A 方案)
P1数据层:DDL、Plugin 业务方法、uninstall、默认设置CLI 建表通过;单测 attach/accept/refund
P2发帖链路:post_create_extra + 预扣/intent + thread_create_after发悬赏帖扣分正确;失败退款正确
P3展示:controller_view_before + layout_body_end + thread_redpacket_area + CSS/JS列表/详情出现金色徽章;详情卡正确
P4采纳:post_operation_after 折叠菜单 + BountyController + 乐观锁403/409/成功三态正确
P5印章:qa 自定义钩子 + stamp 适配器 / 回退标记启用 stamp 出现绿章;停用出现绿标签
P6门禁与验收:php -l、i18n、零内联、双 include、并发、SQL EXPLAIN、截图全绿报告

八、验收测试矩阵(交付报告必须逐条给证据)

ID场景步骤断言证据
T1发悬赏帖账号 A 积分 ≥ N,发帖勾选悬赏 NA 余额 -N;points_log 1 条 qa_bounty_escrow;qa_bounties 1 行 pending;帖正常出现在论坛流截图 + SQL 查询结果
T2回答账号 B 回帖回帖成功;post.thread_id 与悬赏帖一致截图
T3楼主采纳A 打开楼层 ⋯ → 采纳 BB 余额 +N;points_log 1 条 qa_bounty;status=accepted、answer_id=B 的 post_id;页面出现绿色已采纳/印章截图 + SQL
T4非楼主采纳B(非楼主)直接 POST 采纳接口HTTP 403;悬赏仍 pending;无积分变动HTTP 响应截图
T5重复采纳幂等A 连续提交 2 次第 1 次 200;第 2 次 409;B 只到账 1 次;points_log 仅 1 条截图 + SQL
T6并发探针10 个并发请求采纳同一帖成功 1、409 共 9;B 只加 1 次 N;qa_bounties 仅 1 行 accepted并发脚本输出
T7退款链路(核心写失败)预扣后让核心发帖失败(超长标题/非法分类触发控制器提前返回)A 余额回到原值;points_log 有 escrow + refund 各 1 条;intent refunded;无 bounty 行SQL + 截图
T8退款链路(qa 表写失败)测试库临时重命名 qa_bounties 后发悬赏帖发帖成功但无悬赏;积分退回;intent refunded;恢复表脚本输出
T9印章回退停用 stamp 后采纳仍显示绿色文本「已采纳」;不报错截图
T10多语言三语文件 array_keys 全等键数一致、顺序一致、无缺失脚本输出
T11零内联grep -nE '<style|<script>|on(click|change|submit|load)='零命中(<script src> 除外)命令输出
T12SQL 执行计划EXPLAIN QUERY PLAN 三条核心 SQLuk_qa_bounty_thread / idx_qa_intent_status 命中,无 SCAN qa_bounties截图
T13双 include 回归php -r 'include $f; include $f;'(route_before_dispatch.php)第二次不重复扣分/不重复 INSERT命令输出
T14卸载后台卸载 qa两张新表 + 五张旧表全部 DROP,无残留SQL .tables 截图

SQL 执行计划采集命令(示例)

php -r "$p=new PDO('sqlite:d:/phpstudy_pro/WWW/plugins/qa/data/qa.sqlite'); foreach($p->query(\"EXPLAIN QUERY PLAN SELECT thread_id,bounty_points,status FROM qa_bounties WHERE thread_id IN (1,2,3)\") as $r){echo $r['detail'],PHP_EOL;} "
php -r "$p=new PDO('sqlite:d:/phpstudy_pro/WWW/plugins/qa/data/qa.sqlite'); foreach($p->query(\"EXPLAIN QUERY PLAN UPDATE qa_bounties SET status='accepted' WHERE thread_id=1 AND status='pending'\") as $r){echo $r['detail'],PHP_EOL;} "
php -r "$p=new PDO('sqlite:d:/phpstudy_pro/WWW/plugins/qa/data/qa.sqlite'); foreach($p->query(\"EXPLAIN QUERY PLAN SELECT id FROM qa_bounty_intents WHERE status='deducted' AND created_at<'2026-10-29 00:00:00'\") as $r){echo $r['detail'],PHP_EOL;} "

预期输出:

SEARCH qa_bounties USING INDEX uk_qa_bounty_thread (thread_id=?)
SEARCH qa_bounties USING INDEX uk_qa_bounty_thread (thread_id=?)
SEARCH qa_bounty_intents USING INDEX idx_qa_intent_status (status=?)

九、已拍板决策(2026-10-29)

#决策选定落地要点
1发帖 Saga 顺序A 严格预扣route_before_dispatch 预扣 + qa_bounty_intents + register_shutdown_function 补偿 + init_after 惰性对账
2楼层采纳入口A 插件折叠菜单post_operation_after 输出 ⋯ 触发 + 默认收起的菜单,不裸露采纳按钮
3stamp 集成A 自定义钩子适配qa 触发 qa_bounty_accepted;stamp 新增 hook/qa_bounty_accepted.php 并注册;未启用时 qa 渲染绿色回退标记
4列表标题徽章A 前端注入layout_body_end 输出 #qa-bounty-data[data-map];script.js 在标题后插入金色徽章

方案 B/C(后置托管、主帖更多菜单、核心新增钩子/模板、qa 直连 stamp)均不采用,保留在本文件历史讨论中仅作风险对照。实施时不得回退到 B/C,除非重新走变更评审。

决策 1 的已知风险与必须落实的守卫:

  • route_before_dispatch 一次请求触发两次:$GLOBALS['__qa_prepaid_guard'] + intent 条件 UPDATE 双保险;
  • 只能在 POST /post/new 且 is_qa=1 时生效,其他请求零副作用;
  • 预扣前必须先 Auth::isLoggedIn() + Csrf::verifyOrDie();
  • 参数非法 / 余额不足:不扣分,降级普通帖 + 一次性提示,绝不丢正文;
  • 所有退款必须条件 UPDATE(deducted → refunded 且 rowCount()==1)才执行 Points::award,保证幂等;
  • 上线前必须跑 T7(核心写失败退款)、T8(qa 表写失败退款)、T13(双 include 不重复扣分)。

十、范围外(本版不做)

  • 悬赏加价 / 追加悬赏;
  • 多答案同时采纳;
  • 悬赏过期自动退款、删帖自动退款(v1 只保证「发帖失败退款」;后续可加惰性对账);
  • 独立问答页(/qa/*)与主论坛悬赏层的数据迁移/合并;
  • 悬赏排行榜、通知模板扩展。

十一、交付物清单

  1. 代码:上述 §5 文件清单;
  2. docs/qa插件2.0验证报告_2026-10-29.md(实施后生成,含 T1–T14 全绿结论);
  3. SQL 执行计划截图(至少 3 张,放 docs/,命名 qa2.0_explain_*.png);
  4. 并发探针输出(10 请求 1 成功);
  5. 双主题截图(浅色 + 暗夜星辰);
  6. 语言包键比对脚本输出、零内联扫描输出、php -l 输出。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 8 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录