CR #272 » CR-049_跨服務事件線格式與回沖業務日之實質缺陷(二次編碼與欄位層級不一致).md
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 項真機驗收 |
- « Previous
- 1
- 2
- Next »