版本:规划稿 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 方案:
- 发帖 Saga:
route_before_dispatch预扣 + intent 凭证 + shutdown 补偿,严格「先扣分 → 再写主帖 → 再插悬赏」; - 采纳入口:
post_operation_after内由插件自建折叠⋯菜单,不裸露采纳按钮,零核心改动; - 印章联动:qa 触发自定义钩子
qa_bounty_accepted,stamp 新增监听文件盖章,符合插件解耦规范; - 标题徽章:
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_qa | checkbox value=1 | 开启悬赏 |
bounty_points | number | min=qa_bounty_min(默认 10)、max=qa_bounty_max(默认 1000),步长 1 |
qa_intent_token | hidden | bin2hex(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
- 早退条件:仅当
REQUEST_METHOD=POST、路径(去BASE_PATH后)=/post/new、$_POST['is_qa']==='1'时继续;否则return。 - 双触发防重:
route_before_dispatch一次请求触发两次(index.php+Router::dispatch),文件内用$GLOBALS['__qa_prepaid_guard']守卫;同时所有 DB 状态迁移都用条件 UPDATE +rowCount(),天然幂等。 - 登录与 CSRF:先
Auth::isLoggedIn()(未登录交回控制器),再Csrf::verifyOrDie($_POST['csrf']),避免「先扣分后撞 CSRF」。 - 参数校验:
bounty_points必须为整数且在 min/max 内。非法 → 写 session 一次性提示、$_POST['is_qa']=''降级为普通帖(不扣分、不丢正文)。 - 建 intent(qa 库):
INSERT INTO qa_bounty_intents(token,user_id,bounty_points,status='init',...);token必须命中$_SESSION['qa_bounty_tokens'],否则降级。 - 预扣:
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']传给后续钩子。
- 返回
- 失败补偿(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 的惰性对账兜底。 - 控制器继续执行:核心
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
- 从
$_POST['qa_intent_token']取 token;查qa_bounty_intents,仅status='deducted'继续(否则静默跳过,不影响发帖)。 - 校验
thread_id > 0、楼主与 intent 用户一致。 INSERT INTO qa_bounties(thread_id,user_id,bounty_points,status='pending',...);UNIQUE(thread_id)兜底并发/重复。- intent 条件更新
deducted → consumed,写入thread_id。 - 任一步失败:删刚插入的 bounty 行(若已插)、intent
deducted → refunded、Points::award退款、session 提示「悬赏创建失败,积分已退回」。 - 成功后
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);
- 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(防刷分)。
- 乐观锁抢占(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;绝不二次发奖。
- 发奖:
Points::award((int)$answer['user_id'], $points, I18n::get('plugin.qa.points_bounty_award'), $bountyId, 'qa_bounty')。 - 发奖异常补偿:条件更新
accepted → pending(带answer_id匹配),返回err_system;绝不让「已采纳但没到账」静默存在。 - 成功后:
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 插件解耦)
- qa 采纳成功后触发自定义钩子
qa_bounty_accepted(qa 自己的Plugin.php内Plugin::hook(...))。 - 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()。
- 未启用 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 | 已完成(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,发帖勾选悬赏 N | A 余额 -N;points_log 1 条 qa_bounty_escrow;qa_bounties 1 行 pending;帖正常出现在论坛流 | 截图 + SQL 查询结果 |
| T2 | 回答 | 账号 B 回帖 | 回帖成功;post.thread_id 与悬赏帖一致 | 截图 |
| T3 | 楼主采纳 | A 打开楼层 ⋯ → 采纳 B | B 余额 +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> 除外) | 命令输出 |
| T12 | SQL 执行计划 | EXPLAIN QUERY PLAN 三条核心 SQL | uk_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 输出 ⋯ 触发 + 默认收起的菜单,不裸露采纳按钮 |
| 3 | stamp 集成 | 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/*)与主论坛悬赏层的数据迁移/合并; - 悬赏排行榜、通知模板扩展。
十一、交付物清单
- 代码:上述 §5 文件清单;
docs/qa插件2.0验证报告_2026-10-29.md(实施后生成,含 T1–T14 全绿结论);- SQL 执行计划截图(至少 3 张,放
docs/,命名qa2.0_explain_*.png); - 并发探针输出(10 请求 1 成功);
- 双主题截图(浅色 + 暗夜星辰);
- 语言包键比对脚本输出、零内联扫描输出、
php -l输出。