Project

General

Profile

CR #272 » CR-049_跨服務事件線格式與回沖業務日之實質缺陷(二次編碼與欄位層級不一致).md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

CR-049 跨服務事件線格式與回沖業務日之實質缺陷(二次編碼/欄位層級不一致)

項目 內容
文件編號 CR-049
版本 1.0
狀態 實作完成(真機 E2E 32/32 通過;2026-10-17)
觸發 CR-046 全鏈路真機端到端驗證(ops/local/e2e/campaign_chain_e2e.sh)——單元/整合測試全綠,但實機零入點
需求依據 SCS-026 §7.6(指令下發)、§7.14(回沖)、SCS-010 §7(入點)
規格依據 SAD-004 §5.1(信封固定欄位)、§5.4(線格式與不無限重試)、SDD-006 §4.2(事件 payload)、SCS-026 §7.5①(不得以 occurredAt 反推業務日)
影響模組 campaign-service、rewards-service、posting-service、tools/gen_service_skeleton.py、tools/verify_architecture.py(新關卡 A9)
嚴重性 高——症狀為「靜默略過」:無異常、無重試、無告警,只是永遠不入點

1. 問題陳述(實機症狀)

以真實 Oracle + 真實 Kafka 起 posting/campaign/rewards 三服務,對同一帳戶以兩個不同載體各刷 12,345 分:

端 觀察
campaign 端 campaign_reward_cmds 2 筆,狀態皆 SENT(已派發至事件流)
rewards 端 points_ledger 0 筆、points_accounts 0 筆、GET /api/v1/rewards/accounts/{acc} → 404 CC-RWD-4040
rewards 日誌 略過來自訂閱主題之未知事件型別:eventType=null eventId=null

★ 訊息確實送達(消費端有收到並印出 WARN),但解析後 eventType 為 null → 被當「未知型別」略過。


2. 根因 A:producer value-serializer 造成二次編碼

本專案之生產端一律以 KafkaTemplate<String,String> 發送已序列化之信封 JSON 字串。若 producer 之
value-serializer 為 JsonSerializer,字串會被再序列化一次(JSON 字串被當物件,外層多包一層引號):

實際線格式:  "{\"eventId\":\"…\",\"eventType\":\"campaign.reward.commanded.v1\",…}"
                 ↑ 整段變成一個 JSON「字串字面值」

消費端以 StringDeserializer 取得原文 → readTree() 得到 TextNode(不是 ObjectNode)→
root.path("eventType") 恆為 MissingNode → asText(null) = null → 略過。

2.1 為何只有部分服務受害

authorization/card/limit/payment/rewards/posting/statement/clearing/
merchant/dispute/notification/product 等 12 個服務先前已改為 StringSerializer
(檔內留有警語);本次新增/未推廣到的 6 個服務仍為 JsonSerializer:

服務 現況
campaign-service 本次實際踩雷(指令下發)
pricing-service 同型缺陷(尚未有跨服務消費端,故未爆)
migration-service 同型缺陷
party-service 同型缺陷
installment-service 同型缺陷
report-service 同型缺陷

★ 這是同一類缺陷的推廣缺口,不是單一服務的偶發錯誤——因此修的不只是 campaign。


3. 根因 B:回沖指令之欄位層級不一致 + 沖正事件缺業務日

3.1 欄位層級不一致

campaign 之 RewardCommandRelay 一律把 command_json 包進 payload.recognition;
惟 rewards 之消費端對「回沖」欄位讀的是 payload 層:

欄位 生產端實際位置 消費端原讀取位置 後果
refund payload.recognition.refund payload.refund 恆為 false → 把退款當一般入點(方向錯誤)
originalPostingId payload.recognition.originalPostingId payload.originalPostingId 恆為 null → 來源交易無法追溯

3.2 沖正事件缺業務日

posting.transaction.reversed.v1 原 payload 恰 4 欄(postingId/reversalOf/amount/reason),
無任何業務日。消費端只能拼 null + "T00:00:00Z" → DateTimeParseException → 略過。
而 SCS-026 §7.5① 明令不得以 occurredAt 反推業務日——所以這不是「取捨」,是契約缺欄。

★ 兩者疊加:回沖支線在真機上從未成功過,而單元測試因為手工餵入「正確層級」的 JSON 而全綠。


4. 修正內容

4.1 線格式(根因 A)

檔案 修正
tools/gen_service_skeleton.py 產生器之 value-serializer 改為 StringSerializer(附警語)——杜絕新服務再犯
6 個服務之 application.yml 同上(campaign/pricing/migration/party/installment/report)
tools/verify_architecture.py 新增關卡 A9「Kafka 線格式(producer 不得二次編碼)」:量測全部 value-serializer,非 StringSerializer 即失敗;零量測亦失敗(fail-closed,防假綠)

4.2 回沖(根因 B)

檔案 修正
posting/PostingEventFactory.reversedEnvelope payload 增 可選 postingDate(沖正交易之入帳日;不升版,慣例同 CR-035 §2.4)
campaign/ReversalService apply(...) 增 postingDate;refundCommandJson 帶 "postingDate";未帶時以當日為錨點並記 WARN(不阻斷、不臆造)
campaign/ReversalListener 解析 payload.postingDate 傳入
rewards/CampaignRewardCommandListener ①回沖欄位改為**「recognition 優先、payload 次之」(與既有 txType/accountId/instrumentId 一致);②回沖判定以 txType=REFUND 為權威(refund 旗標為輔);③缺 postingDate → fail-closed 略過**(不再拼 "nullT00:00:00Z")

5. 為什麼「測試全綠」卻「實機全掛」(治理教訓)

關卡 為何抓不到
編譯 型別完全正確(String 進 KafkaTemplate<String,String>)
單元測試 以手工字串餵入 listener、以 mock 取代 KafkaTemplate → 序列化器根本沒被執行
整合測試 直呼 service/repository,不經 Kafka 線格式
契約比對 只比對欄位名稱,不比對線上編碼與巢狀層級

→ 唯一能抓到的層級是跨服務真機端到端。故本 CR 一併把 E2E 固化成腳本
(ops/local/e2e/campaign_chain_e2e.sh),並把「線格式」升為架構關卡 A9,
使同型缺陷在 CI 即被擋下,而非等到真機。

★ 這正是 MEMORY-RULES 之「反假綠」:綠燈必須證明「權威值改變 → 下游行為跟著變」,
而不是「有跑、有過」。


6. 驗證(皆為實測,非推論)

層級 項目 結果
架構關卡 verify_architecture.py(含新 A9) 9/9 通過(A9 量測 18 個 producer 設定,非 StringSerializer:無)
關卡回測 verify_architecture.py --selftest 9/9 通過(A9 正樣本 JsonSerializer 命中、負樣本 StringSerializer 不誤判)
單元 CampaignRewardCommandListenerTest 6/6(新增:回沖欄位在 recognition 仍判定為退款;缺 postingDate fail-closed)
單元 PostingEventFactoryTest 8/8(reversed payload 由 4 欄改為「4 契約欄 + postingDate」)
整合(真機 Oracle) TransactionRecognitionIntegrationTest 30/30(新增回沖指令須帶 postingDate 之斷言)
E2E(真機 Kafka + Oracle) campaign_chain_e2e.sh 32/32 通過(見 §7)

7. E2E 驗收內容(ops/local/e2e/campaign_chain_e2e.sh)

段 驗證命題 實測結果
P2 同戶、同額、兩個不同載體 → 回饋逐分一致(AC 11 載體無關) 兩載體各 123 點;合計 246;指令 2 筆且涵蓋 2 個載體
P3 比率路徑:方案比率 5% → floor(12345×0.05)=617 分 ÷ 點值 100 → 6 點 入點 6;rateReason=BASE
P3 上限精確截斷:PER_HOUSEHOLD 1,000 分;首筆回填 600 → 次筆 600+617 > 1,000 次筆不再下發指令(指令數維持 1);日誌 上限截斷:used=600 estimated=617 cap=1000
P4 回沖:clawback_rule=CLAW_BACK → 沖正 → refund 指令 → 反向扣回 refund=true 指令 1 筆;ADJUST -123;淨點數 0

★ 三段皆為跨服務、經真實 Kafka、以 Oracle 為權威;判定一律讀權威資料表,不讀日誌。


8. 相容性與影響

  • 事件 payload 增欄:reversed.v1 加 postingDate(可選)——既有消費端不受影響;不升版。
  • 消費端更寬容:rewards 對「欄位在 recognition 或 payload」皆可解析——舊生產端不需同步改。
  • 組態變更:6 個服務之 value-serializer;已在線上的訊息格式由「雙重編碼」回到契約規定之單層 JSON。
    ⚠️ 若有既有 topic 內殘留雙重編碼訊息,消費端仍會略過(本來就略過),不影響新訊息。
  • 無資料遷移:不動任何 DDL。

9. 遺留與後續

# 項目 狀態
1 其餘 6 個服務之跨服務消費端尚未實作,故其線格式缺陷僅止於「潛在」 已由 A9 關卡釘死,未來接消費端不會重犯
2 campaign 端 estimatedValueMinor 為事前推估(amount × rate),與 rewards 實際入點價值(points × 點值)存在分以下尾差 屬設計取捨(事前截斷 vs 事後記帳),已於 CR-047 §3.1 記載
3 部分退款之按比例回沖 待 SCS-010 定義(未實作,不臆造)

10. 修訂紀錄

版本 日期 內容
1.0 2026-10-17 初版:E2E 揭露之兩類實質缺陷、修正、關卡化與 32 項真機驗收
(2-2/2)