Project

General

Profile

CR #245 » CR-044_三項真機揭露(A-210-A-206)之診斷與待裁定.md

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

 

CR-044 三項真機揭露(#A-210/#A-206)——診斷與修法方向待裁定

項目 內容
文件編號 CR-044
版本 1.1
狀態 ★ ①/②/③ 已執行(BEAM PMO 2026-10-15 裁定「同意」)——見 §8
提出日期 2026-10-15
提出者 開發團隊(★ BEAM PMO 2026-10-15 裁定「請立 CR」;來源為 HANDOVER「★★★ 真機揭露」區)
影響文件 SCS-010(點數)、SDD-005 §10(點數帳戶狀態機)、SDD-005 §3(佔用狀態機)、SCS-001 §7.6(雙軌釋放)
影響模組 rewards-service(① ②)、limit-service + authorization-service(③)
優先級 P1(高)——★ ①/② 為可導致批次中止/開戶失敗之實缺陷;③ 為跨服務資料不一致
相關前例 CR-042(同屬「實測才現形」之揭露)、#A-206(批次 14 messaging)、#A-210(批次 17-b rewards)

1. 摘要

於 批次 14(messaging,#A-206) 與 批次 17-b(application,#A-210) 之
真機(真實 Oracle + 真實 JDBC 埠) 測試中,揭露三項與規格/跨服務一致性有關之現象。
★ BEAM PMO 2026-10-15 裁定「請立 CR」——故本文僅診斷並提出修法方向,
★ 未動手修,待核定後另案執行。★ 三項皆已於 2026-10-15 現況複查
(★ 非抄舊結論;逐項以現行原始碼重新核對)。


2. 三項揭露(逐項,附 as-found 證據)

2.1 ★★ ① rewards 到期批次:冪等鍵碰撞 → DuplicateKeyException 逃逸 → 整批中止

內容
現象 同日到期之同階段桶 ≥ 2 → 分錄冪等鍵碰撞 → DuplicateKeyException 未被 catch (DomainException) 攔住 → 整批中止(並如揭露所述留下孤兒桶)
證據 1 PointsExpiryService.processExpiry(@Transactional)第 83 行 僅 catch (DomainException e)——DuplicateKeyException(Spring DataAccessException)不在其列 → 逃逸
證據 2 冪等鍵(writeLedger 第 175 行):
"EXP:" + account.id() + ":" + bucket.code() + ":" + entryType.code() + ":" + today
★ 以「帳戶 + 桶階段 + 動作 + 當日」組成——★ 不含 bucketId
證據 3 ★★ 與其自身 Javadoc 矛盾:第 155–156 行明載「冪等鍵以 EXP:<bucketId>:<entryType> 組成…以桶為主體」——★ 實作卻用 account.id() + bucket.code()(階段),★ 非 bucketId
影響 同一帳戶之多個同階段桶於同一次執行中到期時 → 產生相同鍵 → 第二筆 saveLedger 觸發唯一鍵衝突 → 因例外逃逸,整個 @Transactional 回滾 → ★ 全部客戶之到期處理停擺(非僅該筆)

★ 為何是「整批」而非「該筆」:processExpiry 為單一 @Transactional,
例外逃逸即回滾整批。★ 此與該類別 Javadoc 第 48–50 行「逐桶隔離…不讓整批回滾」之
設計意圖直接衝突——★ 意圖正確,實作未落實。

2.2 ★★ ② rewards 首次累點(新帳戶)→ ORA-02291 外鍵失敗(先插桶、後插帳戶)

內容
現象 新帳戶首筆累點 → 開戶失敗(ORA-02291:parent key not found)
證據 AccrualService.accrue:
★ 第 119–121 行 upsertBucket(account.id(), expiryDate, points) →(新帳戶時)saveBucket(...) INSERT 桶列;
★ 第 125–126 行 saveAccount(updated) 才 INSERT 帳戶列。★ 桶先於帳戶
影響 ★ 新客戶永遠無法累積第一筆點數(與該方法 Javadoc 第 44–46 行「首筆回饋自動開戶」之設計意圖直接衝突)

2.3 ★★ ③ 跨服務不一致:部分釋放時 limit 維持 HELD、auth 寫 PARTIALLY_RELEASED

內容
現象 佔用 > 請款之部分釋放情境:★ limit-service 之權威佔用列維持 HELD;★ authorization-service 之讀模型寫 PARTIALLY_RELEASED
證據 1(limit) JdbcLimitRepository 之 UPDATE_RELEASE_SQL CASE:
WHEN ? >= hold_amount THEN 'RELEASED' ELSE status END——★ 部分釋放不改狀態(維持 HELD)→ ★ DDL 之 PARTIALLY_RELEASED 在 limit-service 不可達
證據 2(auth) JdbcLimitHoldProjectionRepository.applyRelease 第 77 行:
THEN 'RELEASED' ELSE 'PARTIALLY_RELEASED' END——★ 同一情境寫 PARTIALLY_RELEASED
既有測試 limit-service 之 MessagingListenerIntegrationTest.partialReleaseKeepsHoldStatus(第 226–253 行)★ 已如實記錄並註明「是否統一屬另案裁定」

★ 規格依據:SDD-005 §3 佔用狀態機為 HELD →(PARTIALLY_RELEASED)→ RELEASED/EXPIRED;
SCS-001 §7.6 定有「雙軌釋放」。★ 即規格承認 PARTIALLY_RELEASED 為合法中繼態——
★ 惟 limit-service 從不寫入之(不可達),auth-service 卻寫入之 → 兩服務對同一事實有兩種陳述。


3. 查證(as-found,2026-10-15)

查證項 方法 結果
① 冪等鍵是否含 bucketId 讀 PointsExpiryService.writeLedger ★ 不含(用 account.id()+bucket.code()+today)→ ★ 與 Javadoc 矛盾,確認
① 例外是否逃逸 讀 processExpiry 之 catch ★ 僅 catch (DomainException) → 確認
② 桶/帳戶插入序 讀 AccrualService.accrue ★ 桶(119–121)先於帳戶(125–126)→ 確認
③ limit 部分釋放狀態 讀 JdbcLimitRepository + 既有測試 ★ 維持 HELD → 確認
③ auth 部分釋放狀態 讀 JdbcLimitHoldProjectionRepository ★ 寫 PARTIALLY_RELEASED → 確認
是否近期引入 比對相關 CR/git ★ 屬既有行為(①② 自該類別建立;③ 自雙軌釋放設計),★ 非本輪引入

4. 建議修法方向(★ 逐項,待 BEAM PMO 核定)

項 建議方向 理由
① 冪等鍵 ★ 修實作:冪等鍵改為以 bucketId 為主體(★ 即回歸其 Javadoc 所載 EXP:<bucketId>:<entryType>);★ 併:processExpiry 之 catch 改為攔截更廣之例外(或於 saveLedger 前先以鍵查存),使單桶失敗不拖垮整批 ★ Javadoc 已載正確設計;★ 「逐桶隔離」為明示之設計意圖
② 插入序 ★ 修實作:先 saveAccount,後 upsertBucket(調整 AccrualService.accrue 之敘述序) ★ 滿足外鍵約束;★ 使「首筆回饋自動開戶」真正可行
③ 跨服務一致性 ⚠️ 待裁定(方向性):
(A) 令 limit-service 亦寫 PARTIALLY_RELEASED(對齊 auth 讀模型);
(B) 令 auth 讀模型亦維持 HELD(對齊 limit 權威);
(C) 明訂「權威=limit-service」,auth 讀模型一律鏡射權威(★ 建議)
★ 此為狀態機語意之業務決策(部分釋放是否為可觀察之中繼態),非開發可片面認定;★ 惟依 CQRS「讀模型鏡射權威」原則,(C) 最符架構

★ 契約修改程序(如涉及):改 gen_openapi.py → 重跑產生器 → verify_contract_dto.py 再實測 →
確認 0 不符。★ 不可手改 contracts/openapi/*.yaml。


5. 待 BEAM PMO 裁定事項

  1. ★★ ①:是否同意「冪等鍵改以 bucketId 為主體 + 放寬 catch/改逐桶隔離」?
  2. ★★ ②:是否同意「先插帳戶、後插桶」?
  3. ★★★ ③:部分釋放之中繼態語意——採 (A) 對齊 limit 寫 PARTIALLY_RELEASED、
    (B) 對齊 auth 維持 HELD、或 (C) 明訂權威並令讀模型鏡射(建議)?
  4. ★ 是否將 ①/②/③ 合併為單一修補批次執行(同屬「真機揭露」群)?

6. 影響

  • ★ ① 為可用性缺陷:一旦某帳戶有多個同階段桶同日到期,★ 整批到期處理中止
    (影響面=全體客戶,非單筆)——★ 建議優先處理。
  • ★ ② 為功能性缺陷:★ 新客戶首筆累點必失敗。
  • ★ ③ 為資料一致性風險:兩服務對同一佔用狀態之陳述不一致,
    ★ 依 SCS-001 §7.6「雙軌釋放」之規格語意,須先定權威來源。

7. 如實揭露

  1. ★ 本文未修任何程式——依裁定「請立 CR」,僅立與診斷。
  2. ★ 未宣稱 ①/② 曾於生產發生——★ 屬真機測試揭露;★ 惟 ① 之觸發條件(同帳戶多個同階段桶同日到期)
    非罕見,② 之觸發條件(新客戶首筆累點)必然發生。
  3. ★ ① 所述之「孤兒桶」措辭沿用揭露原文——★ 其精確狀態
    (係指回滾後未標記之桶,抑或他者)★ 待修補時以真機確認後再定性,
    本文不預先斷言。
  4. ★ ③ 之「孰為權威」為架構決策,本文僅提建議((C) 鏡射權威),不代為認定。

8. ★ ①/②/③ 之執行(2026-10-15,BEAM PMO 裁定「同意」)

★ BEAM PMO 2026-10-15 裁定:①/② 建議優先修 → 同意;③ 權威來源(明訂 limit 為權威、讀模型鏡射)→ 同意。

8.1 ① rewards 到期批次:冪等鍵改以 bucketId 為主體 + 逐桶獨立交易

  • 規格先行:SCS-010 補 §7.2(交易邊界與冪等鍵 R1–R3)+ §7.3(帳戶寫入序 R4)。
  • 實作(PointsExpiryService):
    1. 交易邊界:建構子注入 PlatformTransactionManager,以 TransactionTemplate(PROPAGATION_REQUIRES_NEW)逐桶開啟交易;移除 processExpiry 之 @Transactional。
      ★ 為何不能只收窄 catch:DataAccessException 會把交易標記 rollback-only,catch 後續寫入將於 commit 拋 UnexpectedRollbackException → 必須以 REQUIRES_NEW 建立邊界。
    2. 冪等鍵:writeLedger 改簽章為 (UUID bucketId, …),鍵改為 "EXP:" + bucketId + ":" + entryType.code()(回歸其 Javadoc 所載「以桶為主體」)。
    3. catch 放寬:新增 catch (RuntimeException e)(涵蓋 DuplicateKeyException)——單桶失敗只回滾該桶,記 WARN 後續下一桶。
  • 測試(RewardsApplicationServiceIntegrationTest,真機 Oracle):★ 同帳戶同日同階段桶 ≥2 → 兩桶皆成功(4 筆分錄);★ 重跑同批 → 冪等略過(處理 0 桶);★ 單桶失敗不拖垮整批。

8.2 ② rewards 首次累點(新帳戶):先插帳戶、後插桶

  • 實作(AccrualService.accrue):調整敘述序——saveAccount(updated)/updateAccount(updated) 於 upsertBucket(...)/consumeCurBuckets(...) 之前執行,滿足 FK POINTS_EXPIRY_BUCKETS.points_account_id → POINTS_ACCOUNTS.id。
  • 測試(真機):★ 新帳戶首筆累點 → 自動開戶成功(FK 有效,不再 ORA-02291)。

8.3 ③ 跨服務一致性:明訂 limit 為權威、讀模型鏡射

  • 規格先行:SDD-005 §3 改寫——PARTIALLY_RELEASED 不再為本期可達狀態;新增 §3.0.1(權威來源規則 R1–R4:limit-service 為權威、讀模型鏡射、PARTIALLY_RELEASED 保留於 DDL/契約值域但本期不可達、讀取仍須過濾該值);SCS-001 §7.6/§7.15 同步。
  • 實作:
    • JdbcLimitHoldProjectionRepository(auth 讀模型):UPDATE_RELEASE_SQL 之 ELSE 'PARTIALLY_RELEASED' → ELSE status END(部分釋放維持 HELD,鏡射權威)。
    • JdbcLimitPort(auth 之 release()):同型修正(ELSE status END);WHERE 仍保留 status IN ('HELD','PARTIALLY_RELEASED')(讀取相容歷史資料,依 SDD-005 §3.0.1 R4)。
  • 測試(MessagingListenerIntegrationTest,真機):★ 部分釋放 → 維持 HELD;★ 全額釋放 → RELEASED。

8.4 實測(2026-10-15)

模組 命令 結果
rewards-service ./gradlew :services:rewards-service:test ★ 155 測 0 失敗(①/② 之新增/改寫測試全綠)
authorization-service ./gradlew :services:authorization-service:test ★ 148 測 0 失敗(③ 之 partialReleaseMirrorsAuthorityByKeepingHeld 全綠)
SQL python3 tools/validate_sql.py backend ★ 掃描 71 檔,違規 0 處

8.5 未竟(如實揭露)

  • ★ 本文 ① 所述之「孤兒桶」措辭沿用揭露原文——★ 修補時未另行定性其精確狀態(未在本次變更範圍內);★ 如後續需要,應以真機另行確認。
  • ★ ③ 之權威規則已落實於 auth 讀模型;★ limit-service 之權威 SQL(JdbcLimitRepository)本即維持 HELD(未改動)——★ 本次僅令讀模型對齊。
    (1-1/1)