Project

General

Profile

CR #201 » SCS-010_紅利點數服務程式設計規格.md

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

 

SCS-010 紅利點數服務(rewards-service)程式設計規格

項目 內容
文件編號 SCS-010
版本 1.17(CR-047 §3.3 活動歸屬落地:POINTS_LEDGER.CAMPAIGN_CODE 寫入(AccrualService 由 request.campaignCode() 帶入;到期/兌換分錄為 null)+PointsLedgerEntry.campaignCode+SELECT/mapRow)。前版 1.16(SCS-026 §7.14 回沖:campaign 回沖指令(refund=true)→ 既有退款分支(ADJUST+負值、自 CUR 扣回);★ 復用既有權威路徑,不新增計算)。前版 1.15(SCS-026 §7.13 R-3b:PointsAccountView 加 tierRank(層級序;權威在本服務,QUALIFICATION_SPEND 升冪,停用不佔序,查無 null)+PointsRepository.findTierRank)。前版 1.14(SCS-026 §7.11:活動比率計點——AccrualRequest.applicableRate(可選):有值時取代標準層級倍率,points = floor(amount × rate) ÷ 點值(BRD 例 1 算式);★ 點值單位更正為分(point-value-minor 預設 100=1 點 1 元,先前誤記 1)。前版 1.13(CR-047:§7.4 補「活動歸屬與回饋價值換算」——AccrualRequest 加可選 campaignCode/instrumentId/postingDate;points.accrued.v1 payload 加 4 可選欄位(不升版);點值=商品參數 rewards/point-value-minor(預設 1=既有防呆;取參失敗回防呆值並記 WARN,不阻斷入點)。前版 1.12(CR-046 B4:新增 §7.4「活動回饋指令之消費」——CampaignRewardCommandListener 消費 campaign.reward.commanded.v1,以 campcmd:<commandId> 冪等入點;⚠️ 活動加成比率(BR-01 rate_schedule)未實作,本期為標準層級倍率)。前版 1.11(CR-044:補 §7.2「到期排程之交易邊界與冪等鍵」、§7.3「首次入點之帳戶寫入順序」——#A-210 真機揭露到期批次在「同帳戶同階段同日到期 ≥2 桶」下永久失效(livelock),及新帳戶首筆入點 ORA-02291;§7「到期/冪等」子項同步補交易邊界與鍵格式;§10 新增 #24 記載修補結果。前版 1.10(CR-033 更正 §7.1 消費者)
狀態 已核定
對應需求 RWD-5.1(點數累積)、RWD-5.2(點數兌換)、RWD-5.3(點數帳戶管理)
服務埠號 8091(CSP-006 §4;領域服務 8081–8089、8091–8098)
套件根 com.beam.cardcore.rw
權威 schema CC_RW(Oracle 26ai Free/PDB FREEPDB1,實測版本 23.26)
關聯文件 SDD-001 §3.8、SDD-005 §10、SDD-006 §3.2/§3.4、SDD-007 §5.4.7、BRD-001 BR-07-03、BRD-004 POC 2-12、GOV-005

本版差異:1.0 為「規格先行」版,埠號與類別名為規劃值。1.1 依實作結果與真實 Oracle 實測回填。
1.2 補 §9.4 WebUI 端到端實測(Vite 代理路徑),並修正 §6.1 CC-RWD-1003 之過度概括措辭、
新增 §10.6 規格空白(FROZEN 帳戶入點行為,待 CR)。
1.3 依 CR-003 處置狀態機三維度拆分。1.4 依 CR-004(已核定/已執行)移除標頭繞道——
§10.4 #15 由「平台缺口待修」改記「已修正」,並新增 #15.1 更正「本服務不用 Bean Validation」
之敘述(查證後確認為專案慣例,非繞道)。
1.5 依 C-26(BEAM PMO 2026-09-20 裁定「驗證層次要對齊其他服務」):控制器層補
@Valid 與 DTO Bean Validation 註解,採分層並存而非取代——新增 §6.2 驗證分工與
錯誤碼分流
;§9.1 測試數 43 → 55(新增 RewardControllersValidationTest 12 例)。
1.6 依 BEAM PMO 2026-09-21「往下做 ④」指示重跑 §9.3 真實 Oracle 端到端(8091/FREEPDB1):#1–#20 全數重現(#11/#12 實測為 CC-PLT-4001;兌換情境須用合法 redemptionType)。#18–#20 曾被誤判為「到期排程 P0 缺陷」,經暫時 debug 實測 getBytes 長度後定案為我方測試資料 hex 少兩個零(15 bytes),程式無缺陷——完整更正歷程見 §9.3.1。
1.7 依 CR-012(BEAM PMO 2026-09-22 授權裁定採「雙欄位」):兌換之「標的類別」與「折抵方式」拆分——契約 redemptionType 對齊 DDL 6 值、新增選填 settlementMethod(預設 POINTS_ONLY);V4 遷移新增 REDEMPTIONS.SETTLEMENT_METHOD;新增 SettlementMethod 列舉。單測 55 → 69 例 0 失敗;真機 e2e 4 情境通過(含「點數+現金換哩程」雙欄位並用);OracleRawId.toUuid 對長度不符改大聲失敗(§9.3.1 事件之根因加固)。
1.8 依 CR-013(BEAM PMO 2026-09-22 核定):cash_value 由折算率計算——語意定案「每 1 點可折抵之現金(元)」,cash_value(分)= pointsUsed × REDEEM_RATE × 100(HALF_UP);由後端依權威參數計算(RedeemRequest 不收現金價值);RedemptionView 補 cashValue;折算率缺漏 → 記 0 兌換照常(fail-soft)。單測 69 → 72 例 0 失敗;真機 e2e 2 情境(3125/154)+DB 複核通過。


1. 對應需求

FR 編號 需求名稱 來源
RWD-5.1 點數累積(reward accrual) BRD-002
RWD-5.2 點數兌換(redeem) BRD-002
RWD-5.3 點數帳戶管理 BRD-002

1.1 上層設計依據(本規格不重複這些圖,僅引用)

圖種 文件 數量(實測) 本服務相關
ER-Diagram SDD-001_領域模型與ER-Diagram 16 個 erDiagram POINTS_ACCOUNTS/LEDGER/TIERS/EXPIRY_BUCKETS/REDEMPTIONS/REWARD_RULE(§3.8)
DFD SDD-002_DFD與流程分解 10 個 flowchart 點數流
Sequence SDD-003_Sequence-Diagrams 15 個 sequenceDiagram 累積/兌換時序(§8)
狀態機 SDD-005_狀態機設計 10 個 stateDiagram ✅ §10 點數帳戶三層狀態機(CUR/AV1/AV2),7 條轉換;本服務實作其中 4 條(2 條不可達,見 §10)
API Contract SDD-006 + contracts/openapi/rewards-service.yaml 17 份 本服務 4 支端點

2. 服務職責與邊界

承作:

  • 點數帳戶(POINTS_ACCOUNTS:三層餘額、到期政策)
  • 點數分錄(POINTS_LEDGER:累積/確認/沒收/兌換/到期/調整/轉移)
  • 點數層級(POINTS_TIERS:倍率、兌換率、資格消費)
  • 到期桶(POINTS_EXPIRY_BUCKETS:逐批到期)
  • 兌換(REDEMPTIONS:現金/類別/商品/里程/費用減免/捐贈)
  • 回饋規則維護(REWARD_RULES:只維護、不執行)

不做(屬他服務):

  • 交易來源 → posting-service/authorization-service(點數累積由事件觸發)
  • 產品層級定義 → product-service
  • 規則執行 → pricing-service 費率引擎/product-service RULE_SETS

3. 領域模型(實作檔案為準)

3.1 聚合根(3)

類別 職責 不變式
PointsAccount 點數帳戶;不可變,所有變更方法回傳新實例(版本 +1) 三餘額皆 >= 0;CLOSED 為終態不可再變更
RedemptionOrder 兌換單 pointsAfter = pointsBefore - pointsUsed;idempotencyKey 不得為空
RewardRule 回饋規則定義 effectiveTo >= effectiveFrom;ruleCode 長度 ≤ 40

3.2 值物件與列舉(12)

類別 值
PointsType EARN/BONUS/REDEEM
PointsBucket CUR(可用)/AV1(到期)/AV2(凍結);AV2.redeemable() == false
AccountStatus ACTIVE/FROZEN/CLOSED;CLOSED 為終態
LedgerEntryType EARN/CONFIRM/FORFEIT/REDEEM/EXPIRE/ADJUST/TRANSFER
LedgerStatus PENDING/CONFIRMED/REVERSED
SourceType AUTHORIZATION/TRANSACTION/MANUAL/PROMOTION
RedemptionType CASH/CATEGORY/GOODS/MILEAGE/FEE_WAIVER/DONATION(兌換標的類別)
SettlementMethod POINTS_ONLY/POINTS_PLUS_CASH/ONLINE_REDEEM/MERGE/EXCHANGE_CENTER(折抵方式;CR-012 雙欄位新增,預設 POINTS_ONLY)
RedemptionStatus PENDING/CONFIRMED/CANCELLED/REVERSED
ExpiryBucketStatus ACTIVE/EXPIRED/REDEEMED
RuleType REWARD/FEE/INTEREST/DEDUCT/ELIGIBILITY
RuleStatus DRAFT/ACTIVE/RETIRED
InsufficientPolicy REJECT(已實作)/PARTIAL/FULL_AUTH(未實作,見 §10)

3.3 其他領域物件(3)

類別 說明
PointsLedgerEntry 分錄;含 bucket(階段)與 beforeBalance/afterBalance 自證
ExpiryBucket 到期桶;points > 0 之 CHECK 使「扣至零」改標 REDEEMED 而保留原值
PointsConsumption 純函式扣抵規劃(先到期先用);Result.consumedByStage + Result.changedBuckets

changedBuckets 只含實際被扣抵之桶。初版誤含全部桶(含未變動者),
呼叫端無差別寫回,使未變動之桶以「版本未遞增」通過樂觀鎖比對而拋
CC-RWD-2001——明明無併發卻回報衝突。已修正並加回歸測試。


4. 程式結構(實作檔案為準)

套件/類別 職責 對應需求
domain/model/PointsAccount RedemptionOrder RewardRule 聚合根 RWD-5.1/5.2/5.3
domain/model/ 其餘 15 檔 值物件、列舉、PointsConsumption、PointsLedgerEntry、ExpiryBucket —
domain/port/PointsRepository RedemptionRepository RewardRuleRepository 持久化埠 —
application/AccrualService 點數累積(含退款反向) RWD-5.1
application/RedemptionService 點數兌換(先到期先用、冪等) RWD-5.2
application/PointsAccountService 帳戶查詢 RWD-5.3
application/RewardRuleService 回饋規則維護(不執行) RWD-5.1
application/PointsExpiryService 到期處理(SDD-005 §10) RWD-5.3
application/PointsViewAssembler Requests 檢視組裝/請求檢核 —
infrastructure/jdbc/JdbcPointsRepository JdbcRedemptionRepository JdbcRewardRuleRepository OracleRawId JDBC 實作(含樂觀鎖、JSON 編解碼) —
infrastructure/config/RewardsTimeConfig Clock bean(業務日來源) —
infrastructure/scheduler/PointsExpiryScheduler 到期排程(fixedDelay 預設 60000ms) RWD-5.3
interfaces/rest/RewardController RedemptionController REST 端點 RWD-5.x

JSON 編解碼一律在 infrastructure/jdbc(new ObjectMapper() 實例欄位),
領域層與應用層不引入 Jackson——與 party/payment/statement 服務一致。
契約之 targets/condition/action 於領域層為 Map<String,Object>。


5. 資料模型(約束為 Oracle 實測值)

5.1 V1 基線(5 表,V1__baseline.sql)

表 關鍵欄位 約束(實測)
POINTS_ACCOUNTS ACCOUNT_ID;POINTS_TYPE;TIER DEFAULT 'CLASSIC';BALANCE/PENDING_BALANCE/CONFIRMED_BALANCE NUMBER(19) DEFAULT 0;EXPIRY_POLICY_CODE DEFAULT 'FIXED_24M';NEXT_EXPIRY_DATE;STATUS DEFAULT 'ACTIVE' points_type IN ('EARN','BONUS','REDEEM');balance/pending_balance/confirmed_balance >= 0;status IN ('ACTIVE','FROZEN','CLOSED')
POINTS_LEDGER POINTS_ACCOUNT_ID(FK);ENTRY_TYPE;POINTS;BEFORE_BALANCE/AFTER_BALANCE;SOURCE_TYPE;EARN_RULE_CODE;IDEMPOTENCY_KEY UNIQUE;STATUS DEFAULT 'PENDING' entry_type IN ('EARN','CONFIRM','FORFEIT','REDEEM','EXPIRE','ADJUST','TRANSFER');source_type IN ('AUTHORIZATION','TRANSACTION','MANUAL','PROMOTION');status IN ('PENDING','CONFIRMED','REVERSED');FK→POINTS_ACCOUNTS(ID)
POINTS_TIERS TIER_CODE UNIQUE;PRODUCT_CODE;EARN_MULTIPLIER NUMBER(5,2) DEFAULT 1.00;REDEEM_RATE NUMBER(9,6);QUALIFICATION_SPEND;STATUS status IN ('ACTIVE','INACTIVE')
POINTS_EXPIRY_BUCKETS POINTS_ACCOUNT_ID(FK);EXPIRY_DATE;POINTS;STATUS points > 0;status IN ('ACTIVE','EXPIRED','REDEEMED');FK→POINTS_ACCOUNTS(ID)
REDEMPTIONS REDEMPTION_NO UNIQUE;POINTS_USED NUMBER(19);CASH_VALUE;TARGETS JSON;CHANNEL VARCHAR2(16) NOT NULL;STATUS DEFAULT 'PENDING' redemption_type IN ('CASH','CATEGORY','GOODS','MILEAGE','FEE_WAIVER','DONATION');points_used > 0;status IN ('PENDING','CONFIRMED','CANCELLED','REVERSED')

含 CREATED_AT/UPDATED_AT TIMESTAMP(6) WITH TIME ZONE、VERSION 樂觀鎖。

5.2 V2 補齊(V2__reward_rules_bucket_stage_and_idempotency.sql)

V1 基線有三處缺口(契約或上層設計已要求、但無處可存),V2 逐項補齊:

# 補齊項 為何 V1 不足以表達
① CREATE TABLE REWARD_RULES SDD-001 §3.8 ER 圖含 REWARD_RULE 實體但同節 DDL 漏列;契約 POST /reward-rules 無表無法落地。依契約欄位建立(非 ER 之 dimensions/conditions/period_type,該組與契約不同——屬另一處三方不一致,見 §10)
② POINTS_EXPIRY_BUCKETS.BUCKET_STAGE、POINTS_LEDGER.BUCKET_STAGE(CHECK IN ('CUR','AV1','AV2')) 三層點數(BRD-001 BR-07-03、客戶 FR 13.1)需標示該批點數屬哪一層。V1 之到期桶僅有 expiry_date,致「先到期先用」(BRD-004 POC 2-12)與 SDD-005 §10 之階段搬移皆無法實作。ledger 亦需同欄位:契約 PointsLedgerView.bucket 且 beforeBalance/afterBalance 為該階段桶之語意,否則兌換時扣抵 CUR 或 AV1 無從追溯(稽核缺口)
③ REDEMPTIONS.IDEMPOTENCY_KEY(UNIQUE)+ POINTS_BEFORE/POINTS_AFTER 契約之寫入端點一律要求 X-Idempotency-Key(SDD-006 §2.1),兌換為扣點之財務操作,重送不得重複扣點。pointsBefore/pointsAfter 為契約 RedemptionView 欄位,僅由分錄事後推導無法區分「同一次兌換跨階段扣抵」之各階段分錄與整筆兌換之總影響
④ CREATE SEQUENCE REDEMPTION_NO_SEQ(NOCACHE NOCYCLE) 兌換編號須連號可稽核(同 posting/statement/payment 之理由),不得以隨機值替代

三層餘額之欄位對應(實作依據):

業務層(客戶 FR 13.1) 儲存欄位 可兌換
CUR 可用點數 POINTS_ACCOUNTS.BALANCE ✅
AV1 到期點數 POINTS_ACCOUNTS.PENDING_BALANCE ✅(寬限期內)
AV2 凍結點數 POINTS_ACCOUNTS.CONFIRMED_BALANCE ❌(CC-RWD-1003)

⚠️ 欄位命名與業務語意有落差(pending/confirmed 並非「待確認/已確認」),
係因 V1 基線先行、契約未定義三層欄位。已以 bucket_stage 補足可追溯性,
命名落差本身列入 §10。

5.3 實測物件清單(CC_RW)

  • 表 7:POINTS_ACCOUNTS、POINTS_TIERS、POINTS_LEDGER、POINTS_EXPIRY_BUCKETS、REDEMPTIONS、REWARD_RULES、flyway_schema_history
  • 索引:IDX_PA_ACCOUNT、IDX_PL_ACCOUNT、IDX_PE_ACCOUNT、IDX_PEB_ACCOUNT_STAGE、IDX_REWARD_RULES_TYPE_STATUS、UQ_REWARD_RULES_CODE、UQ_REDEMPTIONS_IDEM
  • 序列:REDEMPTION_NO_SEQ

5.4 本地測試種子

ops/local/init/oracle/09-dev-rewards-seed.sql:2 層級/4 點數帳戶/6 到期桶。
須帶 NLS_LANG=AMERICAN_AMERICA.AL32UTF8 執行,否則中文變 U+FFFD。

帳戶 情境
…0010 CUR 100,000 → 正常入點/兌換
…0011 CUR 500 → 點數不足(422 CC-RWD-1001)
…0012 CUR 1,000、FROZEN → 422 CC-RWD-1003
…0013 CUR 8,000/AV1 2,000/AV2 1,000 → 三層展示;兌換 2,500 時 AV1 扣 2,000、CUR 扣 500

6. API 設計(來自 contracts/openapi/rewards-service.yaml)

端點 方法 說明 對應 FR
/api/v1/rewards/accruals POST 點數累積 RWD-5.1
/api/v1/rewards/redemptions POST 點數兌換 RWD-5.2
/api/v1/rewards/accounts/{accountId} GET 點數帳戶查詢(三層) RWD-5.3
/api/v1/reward-rules POST 累積規則維護 RWD-5.1

必填標頭(三個 POST):X-Channel、X-Idempotency-Key(長度 8–120)。
回應信封:{traceId, data, error, success};成功之 POST 回 201。

兌換請求之雙欄位(CR-012,1.7):RedeemRequest.redemptionType = 兌換標的類別
(CASH/CATEGORY/GOODS/MILEAGE/FEE_WAIVER/DONATION,對齊主檔 CHECK);
settlementMethod = 折抵方式(POINTS_ONLY/POINTS_PLUS_CASH/ONLINE_REDEEM/
MERGE/EXCHANGE_CENTER),選填、缺省視同 POINTS_ONLY(主檔 DEFAULT 同值)。
兩者正交——「用點數+現金換哩程」= 標的 MILEAGE + 折抵 POINTS_PLUS_CASH。
未知折抵方式 → 400 CC-RWD-4000(SettlementMethod.fromCode,不靜默降級)。
RedemptionView 亦回 redemptionType/settlementMethod 供客戶端自證。

6.1 錯誤碼(SDD-006 §3.2:本服務服務代碼為 RWD)

錯誤碼 HTTP 觸發情境 實測
CC-RWD-1001 422 點數不足(可兌換 = CUR+AV1,AV2 不計入) ✅
CC-RWD-1002 422 規則失效日早於生效日 —
CC-RWD-1003 422 兌換時帳戶非 ACTIVE(FROZEN),或企圖扣抵 AV2 ✅
CC-RWD-1005 422 回饋規則代碼重複 ✅
CC-RWD-2001 409 樂觀鎖衝突(WHERE VERSION = ? 更新 0 列) ✅
CC-RWD-3001 409 非法狀態轉換(搬移來源=目標階段) —
CC-RWD-3002 409 終態不可轉換(帳戶已 CLOSED 仍嘗試入帳) ✅
CC-RWD-4000 400 結構/格式錯誤(含缺必填標頭、未實作之政策、未知列舉值) ✅
CC-RWD-4040 404 查無點數帳戶 ✅

⚠️ 1.0 之 §6 誤寫「400 點數不足、409 重複(冪等鍵)」。實測:點數不足為 422,
冪等重送為成功回應(回前次結果,非 409)。已依實作更正。

⚠️ CC-RWD-1003 之範圍限於兌換:凍結帳戶仍可入點(CLOSED 才擋入點,
回 CC-RWD-3002)。此分工之依據與待確認事項見 §10.6。

6.2 驗證分工與錯誤碼分流(C-26,BEAM PMO 2026-09-20 核定)

本服務原為 17 個服務中唯一採「純應用層檢核」者(其餘 8 個寫入型服務採控制器層
Bean Validation)。C-26 裁定為對齊,但採分層並存——兩層各管不同層次,
規則不重複:

層次 管什麼 失敗之錯誤碼 保護範圍
控制器層(@Valid + DTO 註解) 傳輸層形狀:必填(accountId/txTime/redemptionType/ruleType/status/effectiveFrom)、數值正負(amount/pointsUsed)、長度(ruleCode ≤ 40) 400 CC-PLT-4003(平台統一) 僅 HTTP
應用層(Requests) 業務語意:UUID 格式、ISO-8601 時間、yyyy-MM-dd 日期、未知列舉值、未實作之政策(PARTIAL/FULL_AUTH)、condition/action 是否為 JSON 物件 400 CC-RWD-4000 所有入口(含排程/其他服務/單元測試)

已加註解之欄位:AccrualRequest(accountId、amount、txTime)、
RedeemRequest(accountId、redemptionType、pointsUsed)、
RewardRuleRequest(ruleCode、ruleType、status、effectiveFrom)。

⚠️ 刻意不加格式約束:accountId 不加 @Pattern(UUID)、insufficientPolicy
不加列舉約束。理由——§9.3 實測 #15(accountId 非 UUID)與 #14
(insufficientPolicy=PARTIAL)斷言須回 CC-RWD-4000;若改由控制器層攔下,
這兩個已通過之實測會變成 CC-PLT-4003 而失效,且 #14 會失去
「政策尚未實作」之語意(客戶端無從區分「政策不存在」與「欄位填錯」)。
格式規則具服務語意,故留在應用層。

⚠️ 此分流須由成對案例守住:所有違規皆為 400,若只斷言「不是 200」,
則「控制器層沒生效、全靠應用層兜」與「控制器層生效」兩種情形測試皆通過(假綠)。
RewardControllersValidationTest 對每一端點皆採成對案例:形狀違規 →
CC-PLT-4003 且斷言 fake repository 未被寫入(=應用層沒被呼叫);
語意違規 → CC-RWD-4000(=控制器層放行)。另加 2 例合法請求 → 201,防 @Valid 誤擋。
已以負樣本驗證判定力:移除 @Valid 後 3 例精準轉紅,其餘 9 例不受影響。


7. 關鍵邏輯

  • 累積(RWD-5.1):points = floor(amount / 100) × earn_multiplier(元以下捨去,
    對齊契約範例 12345 → 123)。分錄 EARN、狀態 PENDING(待爭議期後 CONFIRM,見 §10)。
    首筆回饋自動開戶(SDD-005 §10 轉換 1)。點數換算為 0 仍寫分錄,使「每筆來源交易皆已被評估」可稽核。
    退款(isRefund=true)走 ADJUST +負值,並自 CUR 桶依到期日由早到晚扣回。
  • 兌換(RWD-5.2):先到期先用(BRD-004 POC 2-12)——由 PointsConsumption
    依 POINTS_EXPIRY_BUCKETS.EXPIRY_DATE 由早到晚排序扣抵,跨 CUR/AV1 階段;AV2 不參與。
    不足即整筆拒絕(CC-RWD-1001),不做部分扣抵。
    一次兌換之跨階段扣抵以 PointsAccount.deductAll(Map) 單一版本遞增完成。
  • 到期(SDD-005 §10):以排程掃描到期桶,逐桶處理、逐桶隔離失敗(單桶異常只記 WARN 不整批回滾)。
    ★ 交易邊界為「逐桶」——每桶一個獨立交易(REQUIRES_NEW),非整批單一交易,詳 §7.2。
    • CUR 到期 → AV1(效期 +3 個月)
    • AV1 到期 → AV2(效期 +1 個月)
    • AV2 到期 → 餘額清零並轉 CLOSED(以 forfeitAndClose 單一版本遞增)
    • 階段搬移為同帳戶內階段變更,總點數不變——若誤以「先沒收再入點」實作,
      將在過程中產生點數消失的假象且稽核軌跡無法自證。
  • 冪等:入點以 POINTS_LEDGER.IDEMPOTENCY_KEY(UNIQUE)、兌換以
    REDEMPTIONS.IDEMPOTENCY_KEY(UNIQUE)判定;同鍵重送回前次結果,不重複扣點。
    分錄冪等鍵因一次兌換可能跨階段產生多筆,故以 <冪等鍵>#<階段> 命名。
    ★ 到期分錄之冪等鍵為 EXP:<bucketId>:<entryType>——以「桶」為主體(不以帳戶、階段或日期),詳 §7.2。
  • 樂觀鎖:UPDATE … WHERE ID = ? AND VERSION = ?,更新 0 列即 CC-RWD-2001。
    每次 updateInstance 僅允許一次版本遞增(SDD-005 SM-04)——
    鏈式呼叫兩個變更方法會使版本 +2 而資料庫僅 +1,導致「無併發卻回報衝突」。
    故跨階段扣抵與「沒收+結清」皆以單一聚合方法完成。

7.1 事件輸出(Transactional Outbox;CR-027 批次 A-4)

掛點一:AccrualService.accrue 於 saveLedger(entry) 之後、同一交易內 append
掛點二:RedemptionService.redeem 於 save(order) + writeLedgerEntries(...) 之後、同一交易內 append

項目 rewards.points.accrued.v1 rewards.points.redeemed.v1
Kafka 主題 cc.rewards.points.accrued cc.rewards.points.redeemed
分區鍵 aggregateId PointsAccount.id(聚合根識別碼) RedemptionOrder.id(= payload 之 redemptionId)
aggregate_type rewards rewards
event_type(outbox 欄) points.accrued points.redeemed
event_version 1 1

主題/event_type 之推導:依 SAD-004 §5.4 既有慣例——事件全名去 .vN 後,
再移除一層與 aggregate_type 相同之首段(rewards.points.accrued.v1 → points.accrued)。

rewards.points.accrued.v1 payload(SDD-006 §4.2)

欄位 來源 說明
accountId request.accountId() 業務帳戶識別碼(非聚合主鍵)
pointsType account.pointsType().code() EARN/BONUS/REDEEM(帳戶層級)
bucket PointsBucket.CUR 本期入點固定寫入 CUR(§7 累積邏輯)
points entry.amount() 含正負號之點數變動量
afterBalance updated.balanceOf(bucket) 該 bucket 之變動後餘額

rewards.points.redeemed.v1 payload(SDD-006 §4.2)

欄位 來源 說明
redemptionId order.id() 兌換單識別碼(亦為分區鍵)
accountId order.accountId() —
pointsUsed order.pointsUsed() 本次兌換點數(正數)
amountPaid order.amountPaid() 客戶另付現金(分);未付為 0
merchantShare ⛔ 無來源 見下方「契約缺口」

發射與否(冪等)

情境 行為
入點成功(非退款) 寫 1 列 accrued.v1
退款(refund=true) ⛔ 不發射——語意為「點數扣回」(分錄 ADJUST +負值),非「入點」;契約無對應事件(見 §10)
入點冪等重播(findLedgerByIdempotencyKey 命中) 早退於寫入之前 → 不發射
兌換成功 寫 1 列 redeemed.v1
兌換冪等重播(findByIdempotencyKey 命中) 早退於寫入之前 → 不發射
兌換被拒(CC-RWD-1001 不足、CC-RWD-1003 帳戶狀態、CC-RWD-4000 參數) 拋錯於寫入之前 → 不發射

⚠️ 點數為整數點(非金額分);本服務之事件不含 currency 欄位(契約如此)。

⛔ merchantShare 無來源(既有落差,非本次新增):RedemptionOrder 無此欄位、
亦無上游提供(商戶分攤屬商戶合約,本服務未建模)→ 本期一律發射 null,
不臆造數值。此與既有 RedemptionView.merchantShare 之行為同一來源
(見 §10.3 #12:「契約有、主檔無 → 固定回 0/null」)。
⚠️ 兩者取值刻意不同:REST 檢視為 long 原始型別 → 回 0;
事件 payload 為 JSON → 發 null(可區分「契約有此欄但無值」與「值為 0」)。

消費者:notification(accrued/redeemed)——⚠️ 尚未實作(CR-029 §5 C-6:無對應模板,
需業務文案,屬 CR-030 §4.2 留待外部輸入)。

★ 更正(CR-033,2026-10-02):本節原載消費者含 posting(redeemed)——
不成立:現行權威清單 SAD-004 §5.2 未列 posting;
SDD-003 §13 之 Rewards → Posting(產生點數帳務)為同步呼叫且在寫事件之前,
依 CR-030 §2 #16 事件不得重做同步已做之寫入 → posting 非本事件之消費者。

7.2 點數到期排程之交易邊界與冪等鍵(CR-044 ①,BEAM PMO 2026-10-15 核定)

為何需要本節:CR-044(批次 17-b 真機揭露)發現到期批次在特定資料下永久失效,
而 §7「到期」子項雖已明載「逐桶隔離失敗」,卻未定義交易邊界與冪等鍵格式,
致實作可「看起來符合」卻實際違背——本節將該設計意圖補成可驗收之規格。

7.2.1 真機揭露之問題(#A-210)

項 原實作 後果
交易邊界 processExpiry 為單一 @Transactional(整批一筆交易) 任一族失败即回滾整批
冪等鍵 EXP:<accountId>:<bucketStage>:<entryType>:<today>(★ 非以桶為主體,且與 writeLedger 自身 Javadoc 矛盾) 同帳戶、同階段桶於同日到期 ≥2 → 鍵碰撞
例外處理 僅 catch (DomainException) DuplicateKeyException(Spring DataAccessException)逃逸

★ 後果(比「整批中止」更嚴重):PointsExpiryScheduler 已 catch RuntimeException 並記 ERROR,
故排程不會停止;惟因交易全數回滾,同批碰撞桶每輪重跑皆失敗 → 永久處理不了任何桶(livelock),
且僅有一行 ERROR 日誌、不易察覺。

7.2.2 規格(三條,皆為必驗)

# 規則 檢驗方式
R1 冪等鍵以「桶」為主體:EXP:<bucketId>:<entryType>(★ 不含帳戶、階段、日期) 重跑同一桶 → 寫入被冪等略過(不重複軌跡、不拋例外)
R2 交易邊界為逐桶:每一桶以獨立交易(propagation = REQUIRES_NEW)處理;單桶之任何失敗(含 DuplicateKeyException)只回滾該桶 構造「同帳戶同階段同日到期 ≥2 桶」→ ★ 其餘桶仍須成功,僅失敗桶記 WARN
R3 失敗隔離:外層 processExpiry 之例外處理涵蓋 RuntimeException,逐桶記 WARN 後繼續下一桶 同上;並驗證回傳處理數僅計成功者

7.2.3 ⚠️ 為何「只放寬 catch」不夠(設計要點)

★ Spring 之 @Transactional 在方法拋出符合 rollback 規則之例外(DataAccessException 屬之)時,
會將交易標記 rollback-only。此時若 catch 後繼續在同一交易中寫入,
將於 commit 時拋 UnexpectedRollbackException → 整批仍失敗。

→ ★ 故 R2(REQUIRES_NEW 建立交易邊界)為必要條件,加寬 catch 只是配套;
兩者缺一皆無法達成「單桶失敗不拖垮整批」。

7.3 首次入點之帳戶寫入順序(CR-044 ②,BEAM PMO 2026-10-15 核定)

規則 內容
R4 自動開戶(SDD-005 §10 轉換 1「首筆回饋入帳 → CUR」)時,帳戶列必須先於桶列寫入——FK:POINTS_EXPIRY_BUCKETS.points_account_id → POINTS_ACCOUNTS.id
  • 原實作於 newAccount 分支先 upsertBucket(...)(→ saveBucket)後 saveAccount(...) → 真機 ORA-02291(parent key not found)。
  • ★ 修正為 saveAccount(updated) → upsertBucket(...)。
  • ★ 兩者屬同一交易(AccrualService.accrue 為 @Transactional),調整敘述序不影響原子性,
    僅滿足外鍵約束。⚠️ 不可改為「先建帳戶、桶失敗即留下無桶空帳戶」——違反同一交易之意圖。

7.4 活動回饋指令之消費(CR-046 B4,2026-10-17)

CampaignRewardCommandListener(infrastructure/messaging)消費
cc.campaign.reward.commanded(事件 campaign.reward.commanded.v1;campaign 之
RewardCommandRelay 由 REWARD_CMDS(PENDING) 派發,SCS-026 §7.6):

項目 語意
入點路徑 復用既有權威累積路徑 AccrualService.accrue(RWD-5.1)——指令僅載認列事實(amount/mcc/merchantId/postingId),點數由本服務依帳戶層級 earn_multiplier 計算(CR-045 D3:計算權威在 rewards)
冪等鍵 POINTS_LEDGER.IDEMPOTENCY_KEY = campcmd:<commandId>(UNIQUE)——relay 至少一次投遞重放安全;同指令重放回前次結果
到期日錨點 以指令之 postingDate(認列日)起算 CUR 效期——不用事件 occurredAt 反推業務日
類別防禦 僅處理 txType=PURCHASE(campaign 端僅發此類);非預期類別記 WARN 略過,不臆造處理
失敗處理 例外記 WARN 並略過(SAD-004 §5.4 不無限重試);指令可由 campaign 端 REWARD_CMDS 稽核重放
⚠️ 已知限制 活動加成比率(BR-01 rate_schedule)尚未實作——本期入點為標準層級倍率;活動加成不得臆造,加成規則實作前不得宣稱「活動回饋已完整」
★ CR-047 活動歸屬 AccrualRequest.campaignCode/instrumentId/postingDate(皆可選;來源為 campaign 指令)→ 帶入 points.accrued.v1 payload(4 可選欄位,不升版)供 campaign 回填上限用量
★ CR-047 回饋價值換算 valueMinor = points × 點值;點值=商品參數 rewards/point-value-minor(單位=分;「點」為資料、「元」為參數);預設 100 分=既有防呆(1 點 1 元);取參失敗回防呆值並記 WARN(不阻斷入點)
★ SCS-026 §7.11 活動比率 AccrualRequest.applicableRate(可選):有值時 valueMinor = floor(amount × rate)、points = valueMinor ÷ 點值(取代標準倍率);退款一律走既有倍率(回沖以原點數為準)

8. 外部依賴與降級

依賴 失敗處理(Stand-in)
posting-service/authorization-service(交易事件源) 經 Kafka 事件消費;消費落後僅影響點數即時性,不影響本金帳務

⚠️ 本服務目前未實作 Kafka 消費(見 §10),故無實際降級路徑可驗證。


9. 測試與驗收(TRP-001)

9.1 單元測試(./gradlew :services:rewards-service:test)

69 tests, 0 failures(實測,./gradlew :services:rewards-service:test;1.7 版,含 CR-012 新增)→ 1.8:72 tests, 0 failures(CR-013 增現金價值 3 例)

測試類別 案例數 對應
PointsAccountTest 10 UT-RWD-001/003
PointsConsumptionTest 8 UT-RWD-002(先到期先用)
AccrualServiceTest 13 UT-RWD-001(含 §10.6 之凍結/結清入點決策)
RedemptionServiceTest 14 UT-RWD-002(1.7 增 CR-012 雙欄位 3 例;1.8 增 CR-013 現金價值 3 例:折算率計算 3125/進位 HALF_UP 154/缺漏記 0 兌換照常)
LayeringTest(ArchUnit) 4 CSP-002 §6.3
RewardControllersValidationTest 12 §6.2 驗證分工(C-26)——standalone MockMvc,不啟動容器(CSP-002 §6.2);含成對分流案例與 2 例合法請求
SettlementMethodTest 5 CR-012——值域與主檔 CHECK 一致、fromCode 未知值/空字串拋 CC-RWD-4000、預設值
OracleRawIdTest 6 §9.3.1 根因加固——RAW(16) 往返、種子 hex 布局、長度不符大聲失敗(防靜默回 null 回歸)

9.2 品質關卡(正規範圍:逐服務 + 只掃 db/migration/)

掃描器 範圍 結果
scan_scaffold.py backend/services/rewards-service 0 命中
scan_mockdata.py 同上 0 命中
scan_faketest.py 同上 0 命中
validate_sql.py …/db/migration 0 違規(2 檔)
validate_mermaid.py docs 0 錯誤(111 區塊)

9.3 真實 Oracle 實測(8091,FREEPDB1)

4 支端點 + 10 個錯誤情境 + 併發 + 到期排程。 2026-09-21 重跑(rewards-service 8091/FREEPDB1,種子資料完好):#1–#20 全數重現(含兩處文件落差已更正)。#18–#20 之排查波折(曾誤判為 P0 缺陷、實為我方測試資料錯誤)完整記錄於 §9.3.1。

# 情境 期望 實測結果
1 GET 帳戶 0013 三層餘額 CUR 8000 / AV1 2000 / AV2 1000,totalBalance 11000 ✅
2 POST 入點 0010(amount=12345,CLASSIC ×1.00) 123 點 points=123,100000→100123,201 ✅
3 同冪等鍵重送入點 不重複入點 回同一 ledgerId;分錄總數仍為 3 筆 ✅
4 POST 兌換 0013、2,500 點 AV1 先扣 2,000、CUR 扣 500 pointsBefore 10000 → pointsAfter 7500;AV1 2000→0、CUR 8000→7500 ✅
5 兌換後帳戶 AV1 歸零 CUR 7500 / AV1 0 / AV2 1000 ✅
6 POST 回饋規則 建立成功 201,condition/action JSON 正確落庫 ✅
7 規則代碼重複 422 CC-RWD-1005 ✅
8 點數不足(0011 兌 1,000) 422 CC-RWD-1001 訊息「可扣抵 500、請求 1000(AV2 凍結點數不計入)」✅
9 帳戶凍結(0012)兌換 422 CC-RWD-1003 ✅(同帳戶入點則為 201 成功,見 §10.6)
10 查無帳戶 404 CC-RWD-4040 ✅
11 缺 X-Channel 400 CC-PLT-4001(平台統一,非 CC-RWD-4000;見 §9.3.1) ✅(實測 2026-09-21)
12 缺 X-Idempotency-Key 400 CC-PLT-4001(同上) ✅(實測 2026-09-21)
13 冪等鍵過短(5 字元) 400 CC-RWD-4000 ✅
14 insufficientPolicy=PARTIAL 400(明確拒絕,不靜默降級) ✅
15 accountId 非 UUID 400 CC-RWD-4000 ✅
16 退款但查無帳戶 404 CC-RWD-4040 ✅
17 併發:6 筆同時兌換 0011 各 100 點 無遺失更新 2026-09-21 實測:2 成功 + 4 回 409 CC-RWD-2001;餘額 500→300(勝負比依排程競爭而變,本次 2/4;原表記 3/3→200 為另一次時序觀察,不變的是「無遺失更新」)✅
18 到期排程:僅 AV2 到期 §10 轉換 6 conf 150→0、DB 直查 STATUS=CLOSED、VERSION 0→1、分錄 EXPIRE AV2 -150(150→0)✅(實測 2026-09-21)
19 到期排程:僅 CUR 到期 §10 轉換 2 CUR 400→0、AV1 0→400、新桶效期 +3 月(2026-12-21)、分錄 EXPIRE CUR -400+TRANSFER AV1 +400、總點數 400 不變 ✅(實測 2026-09-21)
20 到期排程:僅 AV1 到期 §10 轉換 4 AV1 250→0、AV2 0→250、新桶效期 +1 月(2026-10-21)、分錄 EXPIRE AV1 -250+TRANSFER AV2 +250 ✅(實測 2026-09-21)

樂觀鎖實測證據:帳戶 0010 入點後 VERSION = 1(非 2)——
證明「單一版本遞增」設計正確,未落入鏈式呼叫使版本虛增之陷阱。

排程逐桶隔離證據:單輪處理 3 桶,全部成功;若單桶失敗僅記 WARN 並跳過,不整批回滾。

⚠️ 2026-09-21 真實 Oracle 端到端重測(BEAM PMO「往下做 ④」指示):

  1. #1–#17 全數重現(rewards-service 8091/FREEPDB1,種子資料完好)。除下兩點文件落差已更正外,與原表一致。
  2. 兩處文件落差(實測即時更正):
    • #11/#12:原表記 CC-RWD-4000,實測為 CC-PLT-4001——缺標頭由 GlobalExceptionHandler 統一回 400,與 CR-004(1.4 版)之記載一致;原 §9.3 表未同步。
    • 兌換情境(#4/#5/#8/#9/#14/#17)須用合法 redemptionType:契約列舉 POINTS_ONLY/POINTS_PLUS_CASH/ONLINE_REDEEM/MERGE/EXCHANGE_CENTER,但服務 RedemptionType 僅接受 CASH/CATEGORY/GOODS/MILEAGE/FEE_WAIVER/DONATION(對齊 REDEMPTIONS.redemption_type CHECK,見 RedemptionType.java)。原表用 POINTS_ONLY 會被 CC-RWD-4000「未知兌換類型」 擋下,進不了業務邏輯。✅ 已由 CR-012(1.7)解決——雙欄位拆分後,POINTS_ONLY 等折抵方式值由新增之 settlementMethod 承載,契約 redemptionType 已對齊 DDL。本次重跑以 CASH 重現,業務行為(跨階段扣抵、點數不足、凍結、併發)與原表期望值一致。
  3. #18–#20 未通過(重要缺陷,待 CR):到期排程 PointsExpiryScheduler(@Scheduled 每 60 秒)確實撈到過期桶,但 PointsExpiryService.processOne 對每一筆皆日誌 點數到期處理略過:查無帳戶 null 後略過——ExpiryBucket.pointsAccountId() 為 null。詳見 §9.3.1。

9.3.1 ⚠️ 更正:#18–#20 通過;先前「P0 缺陷」為誤判(根因=我方測試資料錯誤,2026-09-21)

最終結論(實測):到期排程功能正常。以三筆已過期桶(帳戶 0014=AV2 150、0015=CUR 400、0016=AV1 250)重現 SDD-005 §10 之三種轉換,日誌 點數到期處理完成:3 桶,DB 證據:0014 → STATUS=CLOSED/conf 150→0/VERSION 0→1/分錄 EXPIRE AV2 -150;0015 → EXPIRE CUR -400+TRANSFER AV1 +400(新桶 +3 月);0016 → EXPIRE AV1 -250+TRANSFER AV2 +250(新桶 +1 月)。

誤判歷程(完整保留,供日後引以為鑑):

  1. 首次重跑時排程對每筆過期桶皆日誌 點數到期處理略過:查無帳戶 null → ExpiryBucket.pointsAccountId() 為 null。
  2. 我先後提出兩個錯誤假設:①「運行 JAR 較磁碟 source 舊」(反編譯 bytecode 與重啟複測皆推翻);②「FETCH FIRST ? ROWS ONLY 分頁致 Oracle thin driver 對 RAW 欄 getBytes 異常」(改寫為 ROWNUM 子查詢後重測仍失敗,假設被推翻——該改動已還原)。
  3. 最終於 mapBucket 加暫時 debug log 實測 getBytes 長度:rawIdLen=15, rawPaLen=15——RAW 只回 15 位元組。直查 DB RAWTOHEX 對照種子資料:我插入測試桶/帳戶時,hex 常數手打少兩個零(…000000A4 誤為 …0000A4,30 字元=15 位元組;種子為 32 字元=16 位元組)。
  4. OracleRawId.toUuid 對「非 16 位元組」回 null 係防禦性長度檢查正確運作,生產程式碼無缺陷。以正確 32 字元 hex 重插後,排程立即正常處理。
  5. 已還原:findExpiredBuckets 回復原 FETCH FIRST ? ROWS ONLY(ROWNUM 改動基於錯誤假設,不保留);debug log 已移除;rewards-service 已以還原後之程式重建並重測通過。

附帶觀察(待裁定,未擅自變更):OracleRawId.toUuid 對長度不符之 raw 靜默回 null(fail-silent)——本次即因 null 一路傳到排程日誌才現形,除錯成本高。可考慮改為拋錯(bad data 應大聲失敗),但影響所有 RAW 讀取路徑,須 BEAM PMO 裁定後另立 CR。

9.4 WebUI 端到端實測(SDD-007 §5.4.7)

路徑:瀏覽器 → Vite 5173 → /rewards-api 代理(rewrite 去前置)→ rewards-service 8091 → Oracle CC_RW。
此路徑與 §9.3 之直連 8091 不同——代理設定本身也是被測對象(rewrite 錯誤會使 /rewards-api/v1/... 變成 404)。

# 情境 期望 實測結果
1 GET 帳戶 0013(經代理) 三層餘額 CUR 8000 / AV1 2000 / AV2 1000,totalBalance 11000 ✅
2 POST 入點(123.45 元、PLATINUM ×2.00) 246 點入 CUR 8000→8246,201 ✅
3 POST 兌換 2,500 點 AV1 先扣 2,000、CUR 再扣 500 可兌換 10246→7746;AV1 2000→0、CUR 8246→7746、AV2 1000 未動 ✅
4 兌換後總點數守恆 11000+246−2500 8746 ✅
5 POST 回饋規則(經代理) 201 ruleId 取得、status ACTIVE ✅
6 錯誤情境 10 項(經代理) 見 §6.1 全部符合;含 404 CC-RWD-4040、400 CC-RWD-4000×5、422 CC-RWD-1001/1003/1005 ✅
7 雙欄位兌換(CR-012,1.7):表單選標的 MILEAGE + 折抵 POINTS_PLUS_CASH,兌換 15 點 201;結果顯示兩維度;DB 落值同 畫面 RD2026090018/MILEAGE/POINTS_PLUS_CASH/CONFIRMED;可兌換 7,500→7,485;DB 直查 SETTLEMENT_METHOD='POINTS_PLUS_CASH' ✅(2026-09-22,真機瀏覽器+Keycloak 登入,截圖 docs/08-evidence/webui-rewards-dual-field-01.png)

前端建置:npm run typecheck 0 錯誤;npm run build 成功(1.7 版:dist/assets/index-*.js 290.53 kB/gzip 84.32 kB)。

⚠️ 實測發現之規格措辭錯誤(已修):§6.1 原將 CC-RWD-1003 記為「帳戶非 ACTIVE」,
讀來像「凍結帳戶什麼都不能做」。實測凍結帳戶入點成功(201)、兌換被擋(422)。
已限縮為「兌換時帳戶非 ACTIVE」,並於 §10.6 補記規格空白與決策依據。


10. 已知限制(誠實揭露)

10.1 契約與 DDL/SDD 不一致(以 DDL 為準,待修約)

# 不一致 本服務之處理
1 契約 RedeemRequest.redemptionType 列舉(POINTS_ONLY/POINTS_PLUS_CASH/ONLINE_REDEEM/MERGE/EXCHANGE_CENTER)與 DDL CHECK(CASH/CATEGORY/GOODS/MILEAGE/FEE_WAIVER/DONATION)完全不同 ✅ 已由 CR-012 解決(1.7)——BEAM PMO 2026-09-22 核定採雙欄位:redemptionType 對齊 DDL 6 值(標的類別)、新增 settlementMethod(折抵方式,V4 遷移,預設 POINTS_ONLY)。真機 e2e 4 情境通過(含雙欄位並用),詳 CR-012 §8
2 契約 RedemptionView.status(SUCCESS/FAILED/REVERSED)與 DDL CHECK(PENDING/CONFIRMED/CANCELLED/REVERSED)不同 以 DDL 為準,實作回 CONFIRMED
3 契約 POST /reward-rules 之 201 回應 schema 誤用 RedemptionView 本實作回傳語意正確之 RewardRuleView。須改 gen_openapi.py 後重跑(不可手改 YAML)
4 契約 PointsLedgerView.pointsType 範例為 CASHBACK,不在 DDL 列舉(EARN/BONUS/REDEEM) 以 DDL 為準
5 SDD-001 §3.8 ER 圖含 REWARD_RULE 實體,但同節 DDL 區塊漏列;且 ER 之 dimensions/conditions/period_type 與契約欄位不同 V2 依契約欄位建立 REWARD_RULES
6 契約 PointsLedgerView.bucket 與 beforeBalance/afterBalance 之階段語意,V1 無對應欄位 V2 補 BUCKET_STAGE
7 契約 RedemptionView.pointsBefore/pointsAfter,V1 REDEMPTIONS 無對應欄位 V2 補 POINTS_BEFORE/POINTS_AFTER

10.2 SDD-005 §10 狀態機之不可達轉換(已於 CR-003 處置)

轉換 為何不可達 CR-003 之置
轉換 3「CUR 到期且餘額 < 0 → 不移轉」 POINTS_ACCOUNTS.BALANCE 有 >= 0 之 CHECK(實測),負餘額不可能存在 已自 SDD-005 §10.1 移除(不可達之轉換不應存在於狀態機)
轉換 5「AV1 到期且餘額 = 0 → CLOSED」 到期處理以桶驅動,而桶之 POINTS 有 > 0 之 CHECK(實測),「餘額為 0 的 AV1 到期」不存在對應之桶事件 已改述為 SDD-005 §10.1 轉換 4「AV1 到期且無餘額 → 結束(不建新桶)」;CLOSED 改歸 §10.3 帳戶狀態機(原圖將帳戶狀態誤繪入階段機)

CR-003 另將本節原單一狀態機依維度拆分為三(§10.1 桶階段/§10.2 桶狀態/§10.3 帳戶),
詳見 SDD-005 §10 與 CR-003 §4.4。

10.3 未實作 / 部分實作

# 項目 現況
8 insufficientPolicy 之 PARTIAL/FULL_AUTH 未實作。收到時以 CC-RWD-4000 明確拒絕,不靜默降級——否則客戶端會誤認部分折抵已生效
9 分錄 PENDING → CONFIRMED(CONFIRM) 未實作。累積分錄停留 PENDING;缺爭議期確認之排程或事件觸發
10 Kafka 事件消費(交易事件觸發入點) 未實作。目前僅有 REST 入點端點,§8 之降級路徑無從驗證
11 CASH_VALUE ✅ 已由 CR-013 解決(1.8)——BEAM PMO 2026-09-22 核定:cash_value(分)= pointsUsed × REDEEM_RATE(每 1 點可折抵之現金(元)),HALF_UP 至分;由後端依權威參數計算(RedeemRequest 不收現金價值——客戶端宣稱金額即財務漏洞);RedemptionView 補回 cashValue(單據自證);折算率缺漏 → 記 0 兌換照常(fail-soft+WARN)。真機 e2e:2,500 點 → 3125 分、123 點 → 154 分(HALF_UP),DB 同值
13 帳務日 以 UTC 日期代替營業日曆(Clock bean 可注入,尚無營業日曆服務)
14 POINTS_TIERS 維護端點 契約未定義,僅有種子資料;findEarnMultiplier 於查無層級時回 1.00
21 事件消費者 未實作——消費者為 notification(accrued/redeemed)→ 現階段「有人發、沒人收」。⚠️ 阻塞為「無對應模板」(需業務文案,CR-029 §5 C-6/CR-030 §4.2)。★ posting 已非消費者(CR-033;見 §7.1 之更正)
22 退款(refund=true)無對應事件 退款走分錄 ADJUST +負值,語意為「點數扣回」而非「入點」;SDD-006 §4.2 之 rewards.points.* 無此事件 → 本期不發射(不將扣回誤標為 accrued)。待裁定是否另立事件
23 sequenceNo 為「同 JVM 內遞增之時間戳基底序號」 多實例部署可能重複;正解為「聚合層級持久化序號」(SAD-004 §5.4),本期未做(與 CR-027 §5 #12 同型)

10.4 平台缺口(非本服務特有)

# 缺口 影響與對策
15 GlobalExceptionHandler 未攔截 MethodArgumentNotValidException/MissingRequestHeaderException 等請求層例外 ✅ 已由 CR-004 修正(2026-09-19)。原缺口:請求層例外落兜底分支回 500(契約要求 400),故本服務曾以 Requests 明確檢核、標頭以 required = false 接收後自行檢核。CR-004 已於 common-spring 補 5 個請求層 handler(涵蓋 9 種例外型別),並已移除本服務之標頭繞道——RewardController/RedemptionController 之 X-Channel/X-Idempotency-Key 回歸預設 required = true,缺漏時由平台統一回 400 CC-PLT-4001。本服務之實測結果見 CR-004 §4.1 B 節:6/6 全對、0 筆 ERROR 日誌。§10.4 原「不用 Bean Validation」之敘述已不適用——見下方 #15.1
15.1 本服務「不用 Bean Validation」為規避 #15 之繞道 ✅ 敘述已更正(含更正我方之錯誤)。① 原記「Bean Validation 於全專案 9/9 服務皆 0 使用」為誤——2026-09-19 全專案複查確認:8/17 服務採控制器層 Bean Validation(@Valid @RequestBody + DTO 註解,如 party-service/limit-service/payment-service,且該 8 服務之應用層無對應檢核),本服務為唯一採應用層檢核者。錯誤根因為以失效之搜尋樣式(macOS BSD grep 之 |)盤點,致 0 命中而誤判;詳見 CR-004 §6.2。② 故本服務之檢核保留(理由:AccrualServiceTest 直接呼叫應用服務,控制器層驗證攔不到;requireIdempotencyKey 之長度規則屬業務規則),Requests.requireChannel() 已刪除,Requests javadoc 已改寫。③ 新增待裁定項:是否於控制器補 Bean Validation 以與其他 8 服務對齊(代價為規則重複或須放寬既有斷言)——不擅自變更已通過測試之行為,待 BEAM PMO 裁定
16 Spring Boot 4 未自動配置 ObjectMapper bean(自動配置已模組化) 本服務不注入 ObjectMapper,改由 infrastructure/jdbc 以 new ObjectMapper() 實例持有——同時符合既有 8 個服務之慣例
17 平台表 outbox/idempotency_records/audit_trail 尚未存在於任何服務 schema ops/local/init/oracle/01-init.sh 載明應由各服務 V1__baseline.sql 建立,但實測 CC_RW 與 CC_INST 皆無。屬全平台落差(Transactional Outbox 於 CSP-006 已規劃、尚未實作),非本服務特有
18 CC_RW 之 Flyway 歷史為 BASELINE 1 + V2 V1 之 5 表係先前以外部方式建立(Flyway 曾因缺 spring-boot-flyway 靜默不執行)。已核對 5 表 + 3 索引與 V1__baseline.sql 一致。全新建庫時 Flyway 會正常以 SQL 執行 V1,此為既有環境之歷史遺留

10.5 設計上之語意落差(已補救,仍記錄)

# 項目 說明
19 POINTS_ACCOUNTS 三餘額欄位名(balance/pending_balance/confirmed_balance)與業務語意(CUR/AV1/AV2)不符 以順序對應(見 §5.2 表)。已以 bucket_stage 補足可追溯性;欄位正名需 CR
20 契約無「查分錄」端點 PointsLedgerView 僅由入點回應暴露;無獨立查詢端點

10.6 FROZEN 帳戶之入點行為(已於 CR-003 封存為規格)

項目 內容
現象 凍結(FROZEN)帳戶可入點(201 成功),但不可兌換(422 CC-RWD-1003);CLOSED 帳戶則連入點都擋(409 CC-RWD-3002)
原規格現況 SDD-005 §10(1.2 版)之狀態機為點數階段(CUR/AV1/AV2),未定義帳戶營運狀態(ACTIVE/FROZEN/CLOSED)之轉換;SCS-010 1.0/1.1 亦未述及。此為規格空白,非規格衝突
✅ CR-003 之處置 於 SDD-005 新增 §10.3 帳戶營運狀態機(ACTIVE ⇄ FROZEN → CLOSED),並以「各狀態之操作權限」表明列三狀態之入點/兌換權限——封存現行實作。此即本節原「待辦 ①」。行為不變:FROZEN 可入點、不可兌換
決策依據(維持) ① SDD-006 §3.4 對 CC-RWD-1003 之語意明訂為「點數帳戶已凍結/AV2 到期/不可兌換」——限縮於兌換
② 錯誤碼清單中不存在「凍結帳戶入點」之專屬碼,而 CC-RWD-3002 之語意恰為「帳戶已 CLOSED 仍嘗試入帳」——可見設計意圖為「CLOSED 擋全部、FROZEN 僅擋兌換」
③ 實務:凍結限制的是負債側(兌換);消費仍會發生,點數屬客戶既有權益。若擋下入點,該筆來源交易之冪等鍵已被消耗,日後無回放路徑可補,將產生「客戶消費了卻沒有點數」且無法救回的爭議
已凍結此決策 AccrualServiceTest.凍結帳戶仍可入點但不可兌換()、AccrualServiceTest.已結清帳戶入點回3002()——避免日後被無聲改動
仍待決(若改為「凍結應全面停止累積」) 須:① 改 SDD-005 §10.3 之操作權限表 ② 於 SDD-006 §3.4 新增專屬錯誤碼 ③ 同步改 AccrualService 與測試。在此之前不變更行為

11. 修訂記錄

版本 日期 修訂內容
1.0 — 規格先行版(埠號與類別名為規劃值)
1.1 2026-09-19 依實作與真實 Oracle 實測回填:埠號 8091;§3/§4 改為實作檔案;§5 補 V2 四項補齊與三層欄位對應;§6 更正錯誤碼(點數不足 422、冪等重送非 409);§7 補先到期先用、單一版本遞增、逐桶隔離;§9 補 41 單測/5 關卡/20 項實測證據;§10 新增 20 項誠實揭露(契約與 DDL 不一致 7 項、不可達轉換 2 項、未實作 7 項、平台缺口 4 項、語意落差 2 項)
1.2 2026-09-19 ① §9.4 新增 WebUI 端到端實測(Vite 5173 代理 → 8091 → Oracle;6 項)與前端建置數據。② 修正 §6.1 CC-RWD-1003 措辭:原「帳戶非 ACTIVE」讀來像「凍結帳戶什麼都不能做」,實測凍結帳戶入點為 201、僅兌換被擋;已限縮為「兌換時帳戶非 ACTIVE」,CC-RWD-3002 之「已 CLOSED」補「仍嘗試入帳」並由 — 改為已實測。③ §9.3 第 9 列加註同帳戶入點行為。④ 新增 §10.6 規格空白:FROZEN 帳戶入點行為(允許)之三重依據、決策凍結測試、以及若 CR 改判時之三項待辦。⑤ §9.1 單測數由 41 更新為 43(AccrualServiceTest 11→13)
1.3 2026-09-19 依 CR-003 更新:① §10.2 之 2 條不可達轉換已處置——轉換 3 自 SDD-005 §10.1 移除(負餘額受 balance >= 0 所限,不可能存在);轉換 5 改述為 §10.1 轉換 4「AV1 到期且無餘額 → 不建新桶」,CLOSED 改歸新增之 §10.3 帳戶狀態機(原圖將帳戶狀態誤繪入階段機)。② §10.6 由「規格空白(待 CR 確認)」升為「已封存為規格」——SDD-005 §10.3 已新增帳戶營運狀態機與操作權限表,封存「FROZEN 可入點、不可兌換」之現行行為;行為不變,待辦 ① 完成。③ §10.4 #15 補註 CR-004(平台層請求層例外之狀態碼缺口,已立 CR 待核定)及其核定後本服務應改回標準 Bean Validation 之指示。④ SDD-005 §10 原單一狀態機已依維度拆分為三
1.4 2026-09-19 依 CR-004(已核定/已執行) 更新:① §10.4 #15 改記「已修正」——平台層已補 5 個請求層 handler(涵蓋 9 種例外型別),本服務之標頭繞道已移除;RewardController/RedemptionController 之 X-Channel/X-Idempotency-Key 回歸預設 required = true,缺漏時由平台統一回 400 CC-PLT-4001。實測 6/6 全對、0 筆 ERROR 日誌(CR-004 §4.1 B 節)。② 新增 #15.1 更正敘述(含更正我方之錯誤)——原記「Bean Validation 全專案 9/9 服務皆 0 使用」與事實相反:2026-09-19 全專案複查確認 8/17 服務採控制器層 Bean Validation,本服務為唯一採應用層檢核者(根因:以失效之搜尋樣式盤點,見 CR-004 §6.2)。故本服務之服務層檢核保留(AccrualServiceTest 直接呼叫應用服務、長度規則屬業務規則);Requests.requireChannel() 已刪除。③ 新增待裁定項:是否於控制器補 Bean Validation 與其他 8 服務對齊——不擅自變更已通過測試之行為,待 BEAM PMO 裁定
1.5 2026-09-20 依 C-26(BEAM PMO 裁定「驗證層次要對齊其他服務」):控制器層補 @Valid 與 DTO Bean Validation 註解,採分層並存而非取代——新增 §6.2 驗證分工與錯誤碼分流;§9.1 測試數 43 → 55(新增 RewardControllersValidationTest 12 例)。本版差異詳見檔頭
1.6 2026-09-21 依 BEAM PMO「往下做 ④」指示重跑 §9.3 真實 Oracle 端到端(8091/FREEPDB1):#1–#20 全數重現。兩處文件落差即時更正——#11/#12 實測為 CC-PLT-4001(原表誤記 CC-RWD-4000)、兌換情境須用合法 redemptionType(契約 POINTS_ONLY 會被 CC-RWD-4000「未知兌換類型」 擋下,見 RedemptionType.java,契約/DDL 脫鉤待 CR)。#17 併發實測 2 成功+4×409(勝負比依時序而變,「無遺失更新」不變)。#18–#20 之誤判更正:排程曾日誌 查無帳戶 null,我先後誤判「舊 JAR」與「FETCH FIRST 分頁致 driver getBytes 異常」皆被推翻(ROWNUM 改動已還原);最終以暫時 debug 實測 getBytes 長度=15 位元組定案——我插入測試桶之 hex 常數少兩個零(30 字元=15 bytes),OracleRawId.toUuid 之 16-byte 防禦檢查正確拒絕,程式無缺陷;以正確 32 字元重插後排程正常(點數到期處理完成:3 桶;0014 CLOSED/VERSION 1/EXPIRE AV2 -150 等分錄齊備)。新增 §9.3.1 完整保留誤判歷程;附帶觀察:toUuid 對長度不符靜默回 null(fail-silent)使除錯困難,改拋錯待 BEAM PMO 裁定。另依 §9.3 重跑之全列舉實測(契約 5/5 拒收、DDL 6/6 通過)立案 CR-012(§10 #1 同步回寫)
1.7 2026-09-22 依 CR-012(BEAM PMO 2026-09-22 授權裁定採「雙欄位」) 執行:①契約 redemptionType 對齊 DDL 6 值(標的類別)、新增選填 settlementMethod(折抵方式,預設 POINTS_ONLY);②V4 遷移新增 REDEMPTIONS.SETTLEMENT_METHOD(含 CHECK、DEFAULT);③新增 SettlementMethod 列舉;④RedeemRequest/RedemptionView/RedemptionOrder/RedemptionService/JdbcRedemptionRepository 全鏈落地(null → 預設;空字串/未知值 → CC-RWD-4000)。單測 55 → 69 例 0 失敗;真機 e2e 4 情境通過(含「點數+現金換哩程」雙欄位並用;帳務自洽 100117→100103);三支掃描器 0 命中、validate_sql 0 違規;§3/§6/§9.1/§10 #1 同步更新。另 §9.3.1 根因加固:OracleRawId.toUuid 對非 16 位元組改大聲失敗(新增 OracleRawIdTest 6 例固化,防「靜默回 null」回歸)
1.8 2026-09-22 依 CR-013(BEAM PMO 核定) 執行:cash_value 由折算率計算——語意定案「每 1 點可折抵之現金(元)」(種子 CLASSIC 0.010000/PLATINUM 0.012500),cash_value(分)= pointsUsed × REDEEM_RATE × 100(HALF_UP 至分);由後端依權威參數計算(RedeemRequest 不收現金價值——客戶端宣稱金額即財務漏洞);RedemptionView 補 cashValue(單據自證);折算率缺漏 → 記 0 兌換照常(fail-soft+WARN,方向與 earn_multiplier 缺漏給 1 倍各自正確)。PointsRepository 增 findRedeemRate;RedemptionService 以帳戶自身 tier 查率計算。單測 69 → 72 例 0 失敗;真機 e2e:2,500 點 → 3125 分、123 點 → 154 分(HALF_UP),DB 同值;契約重生僅 rewards-service.yaml 變動、0 錯誤。§10 #11 改記已解決
1.9 2026-10-01 CR-027 批次 A-4(#A-136):①**§7.1 新增事件輸出規格**——rewards.points.accrued.v1(入點)/rewards.points.redeemed.v1(兌換):主題/分區鍵(聚合根識別碼)/payload 逐欄來源對映/發射與否之六種情境(含退款不發射、兩種冪等重播早退、三種拒絕早退);②載明 outbox event_type 之推導慣例(去版本後再移除一層與 aggregate_type 相同之首段 → points.accrued/points.redeemed);③**§10.3 新增 3 項**(#21 消費者未實作、#22 退款無對應事件、#23 sequenceNo 多實例風險);④merchantShare 之 null 明訂沿用既有 §10.3 #12(非新增落差)
1.10 2026-10-02 CR-033:更正事件消費者之記載——①**§7.1** 原載「消費者:notification/posting」→ 更正為僅 notification(現行 SAD-004 §5.2 未列 posting;SDD-003 §13 之 Rewards → Posting 為同步呼叫且在寫事件之前,依 CR-030 §2 #16 事件不得重做同步已做之寫入);②**§10 #21** 同步更正,並載明真正阻塞為「無對應模板」(需業務文案,CR-029 §5 C-6)。★ 零程式變更(純文件更正)
(43-43/67)