跳轉到

Grant Pipeline 重建:MutationPlan 架構設計與 Pool Resource Routing 修正

文檔資訊

  • 分類: architecture
  • 難度: advanced
  • 預估閱讀時間: 12 分鐘
  • 標籤: grant-pipeline, mutation-plan, plan-context, reward-routing, stamina, pool-resource, go, game-backend, production-grade, architecture-debt

摘要

針對 Pool Resource Routing 的 4 個 Findings 展開完整架構重建討論,最終定案 14 點 production-grade spec,核心是引入 PlanContext、MutationPlan(純函數 planner)、ExecuteMutationPlanTx(唯一寫入入口)、executor-authored MutationSummary、DisplayReward(從 plan 投影)的五層分離架構,同時修正 CAS retry non-determinism、data_version ownership、public API pool bypass 等問題。

關鍵學習

  • MutationSummary 必須由 executor 根據「實際提交的 mutation」產生,不能從 plan 推導——前者是 DB 實際改了什麼,後者是玩家看到什麼,兩者不能混用

  • PlanContext 要同時凍結 Tables + NowMs + RNGSeed,光凍結 gamedata 還會讓 time-limited item 的 ExpireAt 在 retry 間漂移

  • CAS retry 不能重 roll random:隨機流程必須在 retry 外先 plan 完整 reward graph,planner 應是純函數(P0)

  • Pool Resource 目前只支援 Stamina,schema 也只有 stamina 欄位,不要假裝 generic pool 已存在,文件/命名/註解要全部收斂到這個事實

  • auto-open 必須在 planning phase 展開完,executor 只負責寫 DB,不做遞迴——這同時解掉 nested reward 丟失、recursion 無 guard、response 組裝不一致

  • data_version/changelog 只能由最外層 use case bump 一次,executor 不 bump version,只回 MutationSummary

  • AffectedDomains 要用 typed enum/bitset,不要自由字串,否則順序、重複、拼字都會變成新債

  • ProjectDisplayRewards(plan) 合理,BuildMutationSummary(plan) 不合理——這是整份 spec 最關鍵的分界

  • 容量模型要算「淨新增槽位」:useItem 的 source item 被消耗可能釋放槽位,要一起計入 ConsumeSpec

  • 不要做雙軌過渡:舊的 handleRewardResources 等只能變成薄 wrapper 呼叫新 planner/executor,不能留半套舊邏輯

技術細節

核心問題(原始 Findings)

高優先級:RewardEntry.ID contract 不一致

  • proto 明寫 RewardEntry.Id 對 currency 應是 currency_typeplayer_types.pb.go:1939
  • RewardResources 路徑回的是 ResourcesTypeitem_effects.go:225
  • DropGroup Currency 路徑回的是 ResourcesDefinitionConfig.IDitem_effects.go:389
  • 同一種 Gold/Stamina 在不同 path 對 client 呈現不同 ID,mergeRewards 也不會合併(item_effects.go:665

中:Pool Resource Routing 實際只對 Stamina 成立

  • 所有分派都硬寫成 AddPoolStaminaitem_effects.go:220, adapters_reward.go:60
  • 底層 storage 只有 stamina 欄位(player_repository.go:90, player_repository_postgres.go:609

中:GrantItems auto-open 路徑漏測

  • GrantItems 會先把 BooleanAutoOpen=true 的 item 直接 dispatch(player_inventory_service.go:1213
  • autoApplyItem 進到同一套 RewardResources/DropGroup 邏輯(item_effects.go:522
  • 這條路徑會被 reward pipeline 和 dungeon item grant 間接打到

定案架構(14 點 Spec)

PlanContext → MutationPlan → ExecuteMutationPlanTx → MutationSummary
         DisplayReward (投影)

1. PlanContext

type PlanContext struct {
    Tables          GameDataTables
    NowMs           int64
    RNGSeed         int64
    MaxAutoOpenDepth int
}

2. MutationPlan(原 GrantPlan,改名因同時含 consume + grant) - 只有 canonical GrantSpec,全部用 config ClassID - ConsumeSpec 也在此,含 InstanceID/ItemID/Quantity

3. Pure Planner - PlanItemEffects(ctx PlanContext, ...) -> MutationPlan - 展開 RewardResources、DropGroup、RandomBox、auto-open - 加 maxAutoOpenDepth + cycle detection(A→B→A 型配置錯誤 fail-closed) - auto-open 禁止 interactive effect(遇到就 fail-closed,不默默選 index 0)

4. ExecuteMutationPlanTx(唯一寫入入口) - 集中做:ValidateGrantSpec、ResourcesDefinitionConfig 批次 lookup、IsKnownResourcesType、IsPoolResource、pool/simple routing、item batch upsert - 回傳 MutationSummary(executor 根據「實際提交的 mutation」產生)

5. MutationSummary(executor-authored)

type MutationSummary struct {
    DeletedItemInstanceIDs []string
    GrantedItems           []GrantedItemResult  // 含 instanceID/isNew/merged
    SimpleResourceDeltas   map[ResourceType]int64
    PoolDeltas             map[ResourceType]int64
    AffectedDomains        DomainBitset  // typed enum,不是 []string
}

6. DisplayReward:從 MutationPlan 投影,不從 MutationSummary 推導

7–8. Distributor/RewardGranter 改回 MutationSummary;最外層 use case 負責單次 data_version/changelog

9. 封死 public API 旁路 - AddCurrencyAmountDeductCurrencyAmountGetCurrencyBalanceAdminSetResource 對 pool type 直接報錯 - 同時 honest rename:AddCurrencyAmount 改名,明確只適用 simple-balance

10. Stamina-only honest labeling - GrantPoolResourceTx 保留 dispatcher 形狀,但文件明寫目前只實作 stamina - 若未來要真 generic pool:新增 player_pool_resources(user_id, resource_type, amount, max_amount, recover_at, ...) table,schema migration 必須做

11. 淨槽位容量模型 - 容量檢查要算「淨新增槽位」:freed = consumed non-stackable items - ConsumeSpec 需攜帶「是否會釋放槽位」資訊給 executor/capacity checker

12. 禁止雙軌過渡 - 舊的 handleRewardResourcesgrantCurrencyRewardsgrantItemRewardsplayerRewardGranter 只能成為薄 wrapper

13. Deterministic 測試矩陣 - version conflict retry 不改掉落 - auto-open nested reward 不丟失 - all-auto-open 仍會 bump version - pool bypass API 會 loud failure

14. Repo contracts 要回傳實際 mutation 結果 - 目前 item_effects.go:516 只回 error,正式版要回 GrantedItemResult

文檔審查發現的 P0/P1 硬傷

  • P0:MutationPlan 定義與 planner 產生邏輯互相打架(誰組最終 plan 不明確)
  • P0ProjectDisplayRewards 把 Stamina 投影成 "resource",與現有測試/client contract 不一致
  • P0planItemMetadata 用了不存在的 EffectiveTimeLimitInMinutes 欄位,與現有 ItemLimitedTime + CalcExpireAtMs(...) 不相容
  • P1validateNetCapacitylen(summary.DeletedItemInstanceIDs) 扣槽位,但 stackable consume 不釋放槽位,高估可用空間
  • P1GrantItems data-version bump 範例未使用 BumpDataVersionAtomic(),在 contention 下引入可避免的 version conflict

What Changed

這段對話從「4 個 Findings」出發,經過多輪架構迭代,最終定案一套完整的 Grant Pipeline 重建設計。

核心架構轉變:從 RewardResult 兼「grant 輸入」和「client 輸出」雙重職責(split-brain 根因),轉變為 MutationPlan(canonical grant spec)+ MutationSummary(executor 實際產生)+ DisplayReward(從 plan 投影)三層明確分離。

關鍵設計決策:引入 PlanContext 凍結 Tables + NowMs + RNGSeed,讓 planner 成為純函數,解決 CAS retry 重 roll random 的 P0 問題;auto-open 展開移到 planning phase,executor 只負責寫 DB,徹底消滅 nested reward 丟失和 recursion 無 guard 問題。

誠實邊界劃定:明確宣告 Pool Resource 目前只支援 Stamina,schema 和 API 全部收斂到這個事實,不假裝 generic 已存在——若要真正 generic 必須做 schema migration。

So What

這個設計解決的不只是一個 bug,而是一整套架構債(item effect/reward pipeline 沒有統一 grant pipeline)和領域債(DB 和 public API 只支援 Stamina 卻宣稱 generic)。

如果只 patch 個別問題,任何修法都只是把 bug 換位置。這份設計確立了明確的邊界:planner 是純函數、executor 是唯一寫入入口、MutationSummary 只能由 executor 產生、data_version 只能在最外層 bump 一次。這些規則一旦落地,未來加新 reward 類型、新 pool resource、新 use case 都有清楚的 contract 可依循。

文檔審查發現的 P0/P1 硬傷也提醒:設計文檔在落地前必須對照實際 codebase 逐行驗證,尤其是不存在的 field、與現有 contract 不一致的型別轉換邏輯。

Trade-offs

  • 誠實版 vs 平台版:目前選擇「誠實版」(承認 stamina-only),避免假裝 generic 造成更多混淆;若產品 roadmap 真的有 PvpTicket/BossTicket,不做 schema migration 就不算正式版
  • planner 純函數 vs CAS retry 兼容:選擇在 retry 外先 plan 完整 reward graph(而非 retry 內共用 seed),犧牲一點彈性換取可測試的純函數設計
  • MutationSummary from executor vs from plan:執行後才能得知真實 instance-level delta,代價是 repo contract 必須一起改(目前 item_effects.go:516 只回 error)
  • 禁止雙軌過渡:舊 handler 只能成為薄 wrapper,不能留半套舊邏輯——犧牲過渡期的靈活度,換取不讓技術債原樣回來
  • AffectedDomains typed enum:比 []string 嚴格但改動範圍更大,長期可維護性明顯更好

Try It Fast

// PlanContext - 在 retry 外凍結,讓 planner 成為純函數
type PlanContext struct {
    Tables           GameDataTables
    NowMs            int64
    RNGSeed          int64
    MaxAutoOpenDepth int
}

// MutationPlan - canonical plan,全部用 config ClassID
type MutationPlan struct {
    Grants   []GrantSpec
    Consumes []ConsumeSpec
}

// GrantSpec - 統一 ID contract
type GrantSpec struct {
    Type     GrantType  // Item / Resource / Hero / ...
    ClassID  string     // 一律用 config ID,不用 ResourcesType
    Quantity int64
}

// 正確的流程分離
func UseItem(ctx context.Context, ...) (*Response, error) {
    // 1. 在 retry 外 plan(純函數,含 RNGSeed)
    planCtx := PlanContext{Tables: tables, NowMs: now, RNGSeed: seed}
    plan, err := planner.PlanItemEffects(planCtx, item, ...)

    // 2. retry 只負責執行,不重 plan
    var summary MutationSummary
    err = runWithVersionRetry(func(tx Tx) error {
        summary, err = executor.ExecuteMutationPlanTx(tx, userID, plan)
        return err
    })

    // 3. display 從 plan 投影(不從 summary)
    display := projector.ProjectDisplayRewards(plan)

    // 4. 最外層 bump data_version
    _ = repo.BumpDataVersionAtomic(ctx, userID, summary.AffectedDomains)
    return buildResponse(display, summary), nil
}

Recommendation

  1. 落地順序嚴格遵守:PlanContext/MutationPlan 型別 → pure planner → ExecuteMutationPlanTx → Distributor 回 MutationSummary → 舊 handler 改薄 wrapper → 移除 RewardResult 雙重職責 → public API guard + rename → schema migration(如需真 generic)
  2. 文檔落地前必須對照 codebase 驗證:重點檢查(a)新引入的 field/method 是否真實存在,(b)Stamina 的 client-facing type 在 DisplayReward 投影時是否保持 currency 而非誤投成 resource,(c)expiration 邏輯是否對齊現有 ItemLimitedTime + CalcExpireAtMs
  3. MutationSummary 的分界是最高優先守則:永遠從 executor 產生,不要從 plan 推導——這是防止整個系統再次 split-brain 的核心邊界
  4. ConsumeSpec 要帶 isNonStackable flag:讓 validateNetCapacity 能正確算淨槽位,避免 stackable consume 高估可用空間
  5. GrantItems 全 auto-open 不能 early return:即使全部都是 auto-open 也必須走 data_version/changelog,使用 BumpDataVersionAtomic() 而非手動 GetCurrentDataVersion + UpdateDataVersion
  6. AffectedDomains 在型別定義階段就要用 typed bitset,不要事後再改

本文檔由 Semi-Brain 自動生成

Session ID: da5e38be-36d2-40ac-8b78-e37cc8e0dec9

分析信心度: 95%