CR #245 » CR-044_三項真機揭露(A-210-A-206)之診斷與待裁定.md
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 裁定事項
- ★★ ①:是否同意「冪等鍵改以
bucketId為主體 + 放寬catch/改逐桶隔離」? - ★★ ②:是否同意「先插帳戶、後插桶」?
- ★★★ ③:部分釋放之中繼態語意——採 (A) 對齊
limit寫PARTIALLY_RELEASED、
(B) 對齊auth維持HELD、或 (C) 明訂權威並令讀模型鏡射(建議)? - ★ 是否將 ①/②/③ 合併為單一修補批次執行(同屬「真機揭露」群)?
6. 影響
- ★ ① 為可用性缺陷:一旦某帳戶有多個同階段桶同日到期,★ 整批到期處理中止
(影響面=全體客戶,非單筆)——★ 建議優先處理。 - ★ ② 為功能性缺陷:★ 新客戶首筆累點必失敗。
- ★ ③ 為資料一致性風險:兩服務對同一佔用狀態之陳述不一致,
★ 依SCS-001 §7.6「雙軌釋放」之規格語意,須先定權威來源。
7. 如實揭露
- ★ 本文未修任何程式——依裁定「請立 CR」,僅立與診斷。
- ★ 未宣稱 ①/② 曾於生產發生——★ 屬真機測試揭露;★ 惟 ① 之觸發條件(同帳戶多個同階段桶同日到期)
非罕見,② 之觸發條件(新客戶首筆累點)必然發生。 - ★ ① 所述之「孤兒桶」措辭沿用揭露原文——★ 其精確狀態
(係指回滾後未標記之桶,抑或他者)★ 待修補時以真機確認後再定性,
本文不預先斷言。 - ★ ③ 之「孰為權威」為架構決策,本文僅提建議((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):-
交易邊界:建構子注入
PlatformTransactionManager,以TransactionTemplate(PROPAGATION_REQUIRES_NEW)逐桶開啟交易;移除processExpiry之@Transactional。
★ 為何不能只收窄 catch:DataAccessException會把交易標記rollback-only,catch 後續寫入將於 commit 拋UnexpectedRollbackException→ 必須以REQUIRES_NEW建立邊界。 -
冪等鍵:
writeLedger改簽章為(UUID bucketId, …),鍵改為"EXP:" + bucketId + ":" + entryType.code()(回歸其 Javadoc 所載「以桶為主體」)。 -
catch 放寬:新增
catch (RuntimeException e)(涵蓋DuplicateKeyException)——單桶失敗只回滾該桶,記 WARN 後續下一桶。
-
交易邊界:建構子注入
-
測試(
RewardsApplicationServiceIntegrationTest,真機 Oracle):★ 同帳戶同日同階段桶 ≥2 → 兩桶皆成功(4 筆分錄);★ 重跑同批 → 冪等略過(處理 0 桶);★ 單桶失敗不拖垮整批。
8.2 ② rewards 首次累點(新帳戶):先插帳戶、後插桶
-
實作(
AccrualService.accrue):調整敘述序——saveAccount(updated)/updateAccount(updated)於upsertBucket(...)/consumeCurBuckets(...)之前執行,滿足 FKPOINTS_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.1R4)。
-
-
測試(
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(未改動)——★ 本次僅令讀模型對齊。