Reward Pipeline Consolidation 設計文件嚴格審查:30 項架構改善點與 ClaimMarker 並發語義¶
文檔資訊
- 分類: architecture
- 難度: advanced
- 預估閱讀時間: 12 分鐘
- 標籤:
reward-pipeline,idempotency,TOCTOU,ClaimMarker,PostgreSQL,design-review,rollout-gates,legacy-data,dungeon,go
摘要¶
對 MatchRPG Server 的 Reward Pipeline Consolidation 設計文件進行嚴格架構審查,識別並解決 30 個技術問題,包括 TOCTOU race condition 修復、dungeon 雙路徑 reward contract 補全、qty≤0 語義決策、legacy reward_escrow 稽核策略、ClaimMarker PostgreSQL 阻塞語義說明、測試矩陣補全等。
關鍵學習¶
-
ClaimMarker pattern:先 INSERT...ON CONFLICT DO NOTHING RETURNING id,再依據是否有回傳列決定是否 grant,利用 PostgreSQL row-level blocking 解決 TOCTOU race
-
dungeon endBattle 存在兩條 reward 路徑(victory path 與 InteractTypeDungeon defeat path),兩者都必須使用相同 idempotency key 格式:dungeon_battle:{userID}:{sessionID}
-
qty≤0 語義決策:遷移後採用新語義 qty<=0 => skip(而非舊 dungeon 的 force-to-1),須有 static config scan gate 支撐
-
Legacy reward_escrow 稽核:blocked set 必須包含所有非 canonical type(drop_group、hero_exp、player_exp),不能只防 drop_group
-
ResolvedReward → RewardItem 轉換必須單一化到 resolvedToRewardItems helper,conversion fail 時 fail-closed rollback
-
文件設計決策、影響範圍表、測試矩陣、步驟說明四塊必須保持 consistency,這是 reviewer 找到漏洞的根本原因
技術細節¶
TOCTOU Race 與 ClaimMarker 修復¶
原始流程:CheckIdempotency(SELECT) → GrantRewards → LogRewards(INSERT ON CONFLICT DO NOTHING)
兩個並發 TX 都能通過 SELECT check(READ COMMITTED isolation),造成雙重 grant。
修復後流程(claim-first):
// 先 claim,再 grant
result, err := auditRepo.ClaimMarker(ctx, userID, idempotencyKey)
if err != nil { return err }
if result.AlreadyClaimed { return nil } // 另一 TX 已處理
// INSERT 成功才執行 grant
return grantRewards(ctx, rewards)
PostgreSQL 語義:TX2 對同一 (user_id, idempotency_key) 的 INSERT...ON CONFLICT DO NOTHING RETURNING id 會等待 TX1 commit/rollback。TX1 commit → TX2 返回空行 skip;TX1 rollback → TX2 取得 marker 繼續。
Dungeon 雙路徑 Contract¶
- victory path:
dungeon_service_end_battle.go:164 - InteractTypeDungeon defeat path:
dungeon_service_end_battle.go:191
兩條路徑都必須使用:
- idempotency_key = "dungeon_battle:{userID}:{sessionID}"
- source = "dungeon_battle"
- 相同的 mismatch threshold 邏輯(grant vs escrow)
Canonical Reward Type 集合¶
遷移後只允許:item、currency、hero、talent_card
drop_group 是未展開規則,不是別名。Legacy rows 含 drop_group 為 rollout blocker,需獨立 remediation。
Legacy Escrow 稽核 SQL¶
SELECT elem->>'RewardType' AS reward_type, COUNT(*)
FROM reward_escrow r
CROSS JOIN LATERAL jsonb_array_elements(r.rewards_json) AS elem
WHERE elem->>'RewardType' IN ('hero_exp', 'player_exp', 'drop_group')
GROUP BY 1;
Session-level Settle Guard¶
dungeon_repository_postgres.go:232 的 UpdateResult/ErrBattleAlreadySettled 提供 session-level 唯一 settle 保護,使 dungeon 路徑下的 concurrent claim 屬於正確但低頻情境。
What Changed¶
設計文件 docs/31_Reward_Pipeline_Consolidation.md 經過 30 項系統性修改,主要涵蓋三個面向。
Step 3(Dungeon 整合)補強:明確列出 victory 與 defeat 雙路徑、補全 Distributor contract(source、idempotency key、mismatch threshold 行為)、將 calculateRewards 改名為 resolveBattleRewards 表達 Resolve once 語義、補 canonical post-resolve type 集合。
Rollout Gates 新增:從 Open Questions 升格為正式 Pre-rollout Validation 小節,包含 Gate 1(static config scan:確認無非正數 quantity)與 Gate 2(legacy reward_escrow 全面稽核,blocked set 涵蓋所有非 canonical type)。
文件內部一致性修復:統一 Hero Unlock idempotency key prefix、補全影響範圍表(distributor_test.go、dungeon_models.go)、補全測試矩陣(defeat path grant/escrow、ClaimMarker regression、non-positive quantity policy、bot smoke tests),將 Open Questions 轉換為 Resolved Decisions。
So What¶
Reward Pipeline 是遊戲核心資產發放路徑,任何 double-grant 或 silent skip 都直接影響玩家資產與遊戲經濟。
這次審查過程揭示了設計文件在「四塊同步」(步驟說明、影響範圍、測試矩陣、設計決策)上的系統性缺失,這是複雜重構計畫文件最常見的品質問題。透過這 30 項改善,文件從「技術上可行但 reviewer 無法驗證」升級到「reviewer-ready、gate-driven、自洽的執行規格」。
Trade-offs¶
- ClaimMarker vs 雙重 check:ClaimMarker 引入了額外的 DB write,但換取了正確的並發語義,在 dungeon session-level guard 保護下等待成本可接受
- qty<=0 skip vs force-to-1:採用新語義(skip)需要 static config scan gate 支撐,但語義更乾淨,避免 silent data coercion
- fail-closed observability:決定本次不新增 Prometheus counter,只用 slog.Error,降低實作複雜度,代價是監控粒度較粗
- Legacy escrow 只改 write-side:本次變更範圍明確不含 claim API 實作,降低風險,但需要在 rollout 前完成 legacy data audit
Try It Fast¶
# Gate 1: 確認 dungeon PassRewards 無非正數 quantity
jq '[.[] | select(.PassRewards[]? | (.MinQuantity <= 0 or .MaxQuantity <= 0))]' \
gamedata/game_tables_dungeonmainconfig.json
# Gate 2: 稽核 legacy reward_escrow 非 canonical types
psql -c "
SELECT elem->>'RewardType' AS reward_type, COUNT(*)
FROM reward_escrow r
CROSS JOIN LATERAL jsonb_array_elements(r.rewards_json) AS elem
WHERE elem->>'RewardType' IN ('hero_exp', 'player_exp', 'drop_group')
GROUP BY 1;
"
Recommendation¶
- 執行 Gate 1 與 Gate 2 稽核,確認環境內無 blocker,才可進行 Step ¾ rollout
- 確保 ClaimMarker migration 包含 AuditRepository mock 更新(
distributor_test.go),驗證 claim-first flow - 將 bot smoke tests(scenarios_core_loop_test.go、scenarios_hero_unlock_test.go)列為 regression gate,不應作為 non-blocker 選項
- 文件維護原則:未來類似重構計畫應在起草時即建立「四塊同步 checklist」(步驟說明、影響範圍、測試矩陣、設計決策),避免最後才做 consistency pass