CR #201 » SCS-010_紅利點數服務程式設計規格.md
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.1CC-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(新增RewardControllersValidationTest12 例)。
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-serviceRULE_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_ATTIMESTAMP(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。
未知折抵方式 → 400CC-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–#17 全數重現(rewards-service 8091/
FREEPDB1,種子資料完好)。除下兩點文件落差已更正外,與原表一致。- 兩處文件落差(實測即時更正):
- #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_typeCHECK,見RedemptionType.java)。原表用POINTS_ONLY會被CC-RWD-4000「未知兌換類型」擋下,進不了業務邏輯。✅ 已由CR-012(1.7)解決——雙欄位拆分後,POINTS_ONLY等折抵方式值由新增之settlementMethod承載,契約redemptionType已對齊 DDL。本次重跑以CASH重現,業務行為(跨階段扣抵、點數不足、凍結、併發)與原表期望值一致。- #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 月)。
誤判歷程(完整保留,供日後引以為鑑):
- 首次重跑時排程對每筆過期桶皆日誌
點數到期處理略過:查無帳戶 null→ExpiryBucket.pointsAccountId()為 null。 - 我先後提出兩個錯誤假設:①「運行 JAR 較磁碟 source 舊」(反編譯 bytecode 與重啟複測皆推翻);②「
FETCH FIRST ? ROWS ONLY分頁致 Oracle thin driver 對 RAW 欄getBytes異常」(改寫為ROWNUM子查詢後重測仍失敗,假設被推翻——該改動已還原)。 - 最終於
mapBucket加暫時 debug log 實測getBytes長度:rawIdLen=15, rawPaLen=15——RAW 只回 15 位元組。直查 DBRAWTOHEX對照種子資料:我插入測試桶/帳戶時,hex 常數手打少兩個零(…000000A4誤為…0000A4,30 字元=15 位元組;種子為 32 字元=16 位元組)。 -
OracleRawId.toUuid對「非 16 位元組」回null係防禦性長度檢查正確運作,生產程式碼無缺陷。以正確 32 字元 hex 重插後,排程立即正常處理。 -
已還原:
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 於全專案 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)。★ 零程式變更(純文件更正) |