# Risk Service gRPC API ## Overview Risk Service 提供馬來賠率風控計算的 gRPC 接口,用於管理投注回合、評估投注風險、計算動態賠率。 **Proto 定義**: `api/proto/risk/v1/risk_service.proto` --- ## Changelog ### v2.6.0 (2025-01) **New Features:** - `SubscribeOdds` - 即時賠率推送 Streaming RPC - 客戶端訂閱後可接收所有活躍回合的賠率變化 - 支援推送頻率配置(每回合獨立設定) - 支援訂閱過濾(特定 round_ids) - 回合結束時自動發送 ROUND_ENDED 事件 **New RPC:** ```protobuf rpc SubscribeOdds(SubscribeOddsRequest) returns (stream OddsUpdate); ``` **New Request Fields (StartRoundRequest):** - `odds_push_frequency_ms` (int32) - 賠率推送頻率(毫秒),預設 100ms,範圍 50-5000ms **New Messages:** - `SubscribeOddsRequest` - 訂閱請求 - `round_ids` (repeated string) - 可選:過濾特定回合(空 = 全部) - `override_frequency_ms` (int32) - 可選:覆蓋推送頻率 - `OddsUpdate` - 賠率更新事件(包含完整回合狀態,與 GetRoundState 對齊) - `round_id`, `meron_odds_bps`, `wala_odds_bps` - 賠率資訊 - `meron_pool_cents`, `wala_pool_cents` - 池子資訊 - `update_type` - 更新類型(ODDS_CHANGE / ROUND_STARTED / ROUND_ENDED) - `sequence` - 序列號(用於檢測丟失) - `round_status`, `winner` - 僅 ROUND_ENDED 時有值 - `meron_net_income_cents`, `wala_net_income_cents` - 淨收入追蹤 - `total_bets`, `meron_bets`, `wala_bets` - 投注統計 - `max_loss_cents`, `effective_max_loss_cents` - 風險敞口 - `provisional_meron_risk_cents`, `provisional_wala_risk_cents`, `pending_bets` - 待確認風險 - `started_at` - 回合開始時間 - `house_profit_cents` - 莊家盈虧(僅 ROUND_ENDED 時有值) **New Enum:** - `OddsUpdateType` - 賠率更新類型 - `ODDS_UPDATE_TYPE_UNSPECIFIED` (0) - `ODDS_UPDATE_TYPE_ODDS_CHANGE` (1) - 賠率變更 - `ODDS_UPDATE_TYPE_ROUND_STARTED` (2) - 新回合開始 - `ODDS_UPDATE_TYPE_ROUND_ENDED` (3) - 回合結束 **Usage Example:** ```bash # 訂閱所有回合的賠率更新 grpcurl -plaintext -d '{}' localhost:50051 risk.v1.RiskService/SubscribeOdds # 訂閱特定回合,覆蓋推送頻率為 200ms grpcurl -plaintext -d '{"round_ids":["round-001"],"override_frequency_ms":200}' \ localhost:50051 risk.v1.RiskService/SubscribeOdds # 開始回合時設定推送頻率 grpcurl -plaintext -d '{"round_id":"round-001","odds_push_frequency_ms":500}' \ localhost:50051 risk.v1.RiskService/StartRound ``` --- ### v2.5.0 (2025-01) **New Features:** - `DecayConfig` - 漸進式風險衰減配置 - 根據風險使用率動態調整賠率,平滑過渡而非突然拒絕 - 支援多種衰減模式:EXPONENTIAL(指數)、QUADRATIC(二次)、PIECEWISE(分段線性) - 可配置衰減起始點、衰減地板、衰減強度 **DecayConfig 參數:** | 欄位 | 類型 | 預設值 | 說明 | |------|------|--------|------| | `enabled` | bool | true | 是否啟用漸進衰減 | | `mode` | DecayMode | EXPONENTIAL | 衰減模式 | | `start_ratio_bps` | int32 | 5000 (50%) | 開始衰減的風險使用率 | | `floor_bps` | int32 | 5000 (50%) | 賠率衰減地板(最低保留比例)| | `intensity_bps` | int32 | 3000 | 衰減強度(僅 EXPONENTIAL 模式)| | `zones` | []DecayZone | - | 自定義區間(僅 PIECEWISE 模式)| **DecayMode 枚舉:** | 值 | 數值 | 說明 | |----|------|------| | `DECAY_MODE_UNSPECIFIED` | 0 | 使用預設 (EXPONENTIAL) | | `DECAY_MODE_NONE` | 1 | 不衰減,僅使用硬限制 | | `DECAY_MODE_EXPONENTIAL` | 2 | 指數衰減(最平滑,推薦)| | `DECAY_MODE_QUADRATIC` | 3 | 二次曲線(前慢後快)| | `DECAY_MODE_PIECEWISE` | 4 | 自定義分段線性 | **Usage Example:** ```go // 使用漸進式風險衰減 resp, _ := client.StartRound(ctx, &pb.StartRoundRequest{ RoundId: "round-001", Config: &pb.RiskConfig{ MaxRoundLossCents: 10000000, DecayConfig: &pb.DecayConfig{ Enabled: true, Mode: pb.DECAY_MODE_EXPONENTIAL, StartRatioBps: 5000, // 風險使用率 50% 時開始衰減 FloorBps: 5000, // 賠率最低保留 50% IntensityBps: 3000, // 中等衰減速度 }, }, }) ``` **衰減行為說明:** - 風險使用率 < 50%:賠率不衰減,維持市場賠率 - 風險使用率 50% ~ 100%:賠率逐步降低至 floor - 指數衰減比線性衰減更平滑,推薦用於生產環境 ### v2.4.0 (2024-12) **New Features:** - `auto_confirm` - EvaluateBet 自動確認功能 - 當 `auto_confirm=true` 且評估結果為 ACCEPTED(賠率沒變),直接返回 CONFIRMED 狀態 - 減少 RPC 往返:常見情況下從 2 次(EvaluateBet + ConfirmBet)降為 1 次 - `auto_accept_better_odds` - 自動接受更好賠率 - 需與 `auto_confirm=true` 搭配使用 - 當實際賠率比玩家預期更好時,也自動確認 - `odds_change` 回應欄位 - 賠率變動方向 - `ODDS_UNCHANGED` (0): 賠率沒變 - `ODDS_BETTER` (1): 賠率變好(對玩家有利) - `ODDS_WORSE` (2): 賠率變差(對玩家不利) **New Response Fields (EvaluateBetResponse):** - `status: CONFIRMED` - 新狀態,表示已自動確認 - `auto_confirmed` (bool) - 是否為自動確認 - `odds_change` (enum) - 賠率變動方向 - `confirmed_at` (int64) - 確認時間戳(自動確認時有值) **Behavior Summary:** | auto_confirm | auto_accept_better_odds | 賠率狀態 | 結果 | |--------------|------------------------|----------|------| | false | - | - | 原有行為 (ACCEPTED/NEEDS_CONFIRMATION/REJECTED) | | true | false | unchanged | CONFIRMED (自動確認) | | true | false | better | NEEDS_CONFIRMATION (需手動確認) | | true | false | worse | NEEDS_CONFIRMATION (需手動確認) | | true | true | unchanged | CONFIRMED (自動確認) | | true | true | better | CONFIRMED (自動確認) | | true | true | worse | NEEDS_CONFIRMATION (需手動確認) | **Usage Example:** ```go // 快速投注:賠率沒變就直接確認 evalResp, _ := client.EvaluateBet(ctx, &pb.EvaluateBetRequest{ RoundId: "round-001", PlayerId: "player-001", Side: pb.Side_SIDE_MERON, AmountCents: 1000000, BetSlug: "bet-abc-123", ExpectedOddsBps: 8400, AutoConfirm: true, // 啟用自動確認 AutoAcceptBetterOdds: true, // 賠率變好也自動確認 }) if evalResp.Status == pb.EvaluateBetResponse_CONFIRMED { // 已自動確認,無需調用 ConfirmBet log.Printf("投注已確認: odds=%d, change=%s", evalResp.OriginalOddsBps, evalResp.OddsChange) } else if evalResp.Status == pb.EvaluateBetResponse_NEEDS_CONFIRMATION { // 賠率變差,需要玩家手動確認 // 顯示 UI 讓玩家選擇接受或取消 } ``` ### v2.3.1 (2024-12) **Bug Fixes:** - `unlimited_betting` 現在同時影響 EvaluateBet 和 ConfirmBet - 修復:ConfirmBet 時若 `unlimited_betting=true`,跳過 Provisional Risk 驗證 - 之前:ConfirmBet 仍會因 Provisional Risk 累積而拒絕投注 - 現在:與 EvaluateBet 行為一致,全收所有投注 ### v2.3.0 (2024-12) **Breaking Changes:** - `auto_cancel_on_loss` 已棄用,請改用 `unlimited_betting` + `force_settle` 組合 - StopRound 取消邏輯變更:ForceSettle=false 時,虧損超限一律建議取消(不再依賴 auto_cancel_on_loss) **New Features:** - `unlimited_betting` - 新增獨立控制收單風險檢查的參數 - `false` (預設): 嚴格風控,超過 MaxRoundLoss 容量時拒絕投注 - `true`: 跳過風險檢查,按市場賠率全收 **Behavior Changes:** - 參數職責分離: - `unlimited_betting`: 控制 EvaluateBet 和 ConfirmBet 時的風險檢查 - `force_settle`: 只控制 StopRound 時的取消建議 - 組合效果表: | unlimited_betting | force_settle | 收單行為 | 結算行為 | |-------------------|--------------|----------|----------| | false | false | 嚴格風控 | 可取消 | | true | false | 全收 | 可取消 | | true | true | 全收 | 一律成局 | | false | true | 嚴格風控 | 一律成局 | **Migration Guide:** - `auto_cancel_on_loss: true` → `unlimited_betting: true` (向後相容,會自動轉換) - 若需要「全收單 + 一律成局」: 使用 `unlimited_betting: true, force_settle: true` ### v2.2.0 (2024-12) **Breaking Changes:** - `ResumeRound` RPC 已移除 - StopRound 現在是永久性的結束下注階段 - `StopRound` - 現在會執行風險評估並返回取消建議 **New Features:** - `StopRoundResponse` 新增風險評估欄位: - `should_cancel` - 是否建議取消回合 - `cancel_reason` - 建議取消原因 - `is_one_sided` - 是否單邊投注 - `meron_wins_exceeds_max_loss` / `wala_wins_exceeds_max_loss` - 各方勝利時是否會超過最大虧損 - `if_meron_wins_profit_cents` / `if_wala_wins_profit_cents` - 各方勝利時莊家盈虧 - `max_allowed_cents` - 配置的最大允許虧損 - 風險檢查從 EndRound 移至 StopRound,呼叫方根據建議決定後續動作 **Behavior Changes:** - `StopRound` - 永久結束下注階段(無法恢復),清除待確認投注,執行風險評估 - `EndRound` - 簡化為純結算,不再執行風險檢查(auto_cancel_on_loss 和 accept_one_side_bet 現在在 StopRound 評估) - 呼叫方應先調用 `StopRound`,根據 `should_cancel` 決定調用 `EndRound` 或 `CancelRound` ### v2.1.0 (2024-12) **New Features:** - `StopRound` RPC - 暫停回合投注,暫停後不接受新投注和確認 - ~~`ResumeRound` RPC~~ - 已在 v2.2.0 移除 - `ROUND_STATUS_FILTER_STOPPED` - 新增回合狀態過濾選項 - `ROUND_STOPPED` 錯誤碼 - 回合已暫停時的錯誤回應 - `ConfirmBetResponse.failure_reason` - 新增失敗原因欄位 **Behavior Changes:** - `EvaluateBet` - 暫停的回合返回 `REJECTED` (rejection_reason: `round_stopped`) - `ConfirmBet` - 暫停的回合返回 `FAILED` (待確認投注會被清除) - 暫停的回合仍可調用 `EndRound` / `CancelRound` 結束 --- ## Flow Diagrams ### 完整回合生命週期 ```mermaid sequenceDiagram participant R as Rails participant S as Risk Service participant St as State %% 開始回合 rect rgb(240, 248, 255) Note over R,St: 1. 開始回合 R->>S: StartRound(round_id, config) S->>St: Create RoundState Note over St: ACTIVE S-->>R: initial_meron_odds_bps: 8400<br/>initial_wala_odds_bps: -10000 end %% 投注階段 rect rgb(255, 250, 240) Note over R,St: 2. 投注階段 (可重複多次) loop 每筆投注 R->>S: EvaluateBet(player_id, side, amount, bet_slug) S-->>R: status: ACCEPTED R->>S: ConfirmBet(bet_slug, ACCEPT) S->>St: 記錄投注 S-->>R: status: SUCCESS end end %% 結束下注階段 (v2.2.0 新流程) rect rgb(255, 243, 224) Note over R,St: 3. 結束下注階段 (永久性) R->>S: StopRound(round_id) S->>St: 清除待確認投注 S->>S: 風險評估 Note over St: STOPPED S-->>R: should_cancel, cancel_reason<br/>is_one_sided, if_meron/wala_wins_profit end %% 結算階段 rect rgb(240, 255, 240) Note over R,St: 4. 結算階段 (根據 StopRound 建議) alt should_cancel = false R->>S: EndRound(round_id, winner) S->>St: Calculate Settlement Note over St: ENDED S-->>R: house_profit_cents: 12300 else should_cancel = true R->>S: CancelRound(round_id, reason) S->>St: Refund All Bets Note over St: CANCELLED S-->>R: refunded_bets: 15 end end ``` ### 風險評估與取消建議 (v2.2.0 新流程) StopRound 現在會執行風險評估,返回取消建議。呼叫方根據建議決定調用 EndRound 或 CancelRound。 ```mermaid sequenceDiagram participant R as Rails participant S as Risk Service participant St as State %% 開始回合 rect rgb(240, 248, 255) Note over R,St: 1. 開始回合 R->>S: StartRound(config) Note right of R: unlimited_betting: true<br/>force_settle: false S->>St: 建立 RoundState S-->>R: initial_odds_bps Note over St: ACTIVE end %% 投注階段 rect rgb(255, 250, 240) Note over R,St: 2. 投注階段 loop 大量單邊投注 R->>S: EvaluateBet(MERON, 大額) S-->>R: ACCEPTED / NEEDS_CONFIRMATION R->>S: ConfirmBet S->>St: 累積風險敞口 S-->>R: SUCCESS end Note over St: 風險敞口接近 MaxRoundLoss end %% 結束下注 (風險評估) rect rgb(255, 243, 224) Note over R,St: 3. 結束下注 + 風險評估 R->>S: StopRound(round_id) S->>St: 清除待確認投注 S->>S: 風險評估 Note right of S: 檢查 is_one_sided<br/>檢查 would_exceed_max_loss alt force_settle=true S-->>R: should_cancel: false else 單邊投注 & accept_one_side_bet=false S-->>R: should_cancel: true<br/>cancel_reason: "one_sided_betting" else 虧損超限 (v2.3.0: 不需 auto_cancel_on_loss) S-->>R: should_cancel: true<br/>cancel_reason: "max_round_loss_exceeded"<br/>if_meron_wins_profit_cents: -15000000 else 正常 S-->>R: should_cancel: false end Note over St: STOPPED end %% 結算階段 (根據建議) rect rgb(240, 255, 240) Note over R,St: 4. 結算階段 (根據建議決定) alt should_cancel = true R->>S: CancelRound(round_id, cancel_reason) S->>St: 退還所有投注 Note over St: CANCELLED S-->>R: refunded_bets: 50 else should_cancel = false R->>S: EndRound(round_id, winner: MERON) S->>St: 純結算 (無風險檢查) Note over St: ENDED S-->>R: house_profit_cents: 12300 end end ``` **StopRound 風險評估欄位:** | 欄位 | 說明 | |------|------| | `should_cancel` | 建議是否取消回合 | | `cancel_reason` | 建議取消原因 (`one_sided_betting` / `max_round_loss_exceeded`) | | `is_one_sided` | 是否為單邊投注 (MeronPool=0 或 WalaPool=0) | | `meron_wins_exceeds_max_loss` | 若 Meron 贏,虧損是否超過 MaxRoundLoss | | `wala_wins_exceeds_max_loss` | 若 Wala 贏,虧損是否超過 MaxRoundLoss | | `if_meron_wins_profit_cents` | 若 Meron 贏,莊家盈虧 (分) | | `if_wala_wins_profit_cents` | 若 Wala 贏,莊家盈虧 (分) | | `max_allowed_cents` | 配置的最大允許虧損 (分) | **行為對比 (v2.1.0 vs v2.2.0):** | 版本 | 風險檢查位置 | 決策者 | |------|-------------|--------| | v2.1.0 | EndRound 內部自動判斷 | Risk Service | | v2.2.0 | StopRound 返回建議 | 呼叫方 (Rails) | **優點:** - 呼叫方有完整決策權,可根據業務需求覆寫建議 - 風險狀態在結束下注時即確定,避免結算時的意外 - 清晰的職責分離:StopRound 評估,呼叫方決策,EndRound/CancelRound 執行 ### 兩階段投注流程 ```mermaid sequenceDiagram participant R as Rails participant S as Risk Service participant P as PendingBet %% 評估階段 rect rgb(240, 248, 255) Note over R,P: 1. EvaluateBet 評估階段 R->>S: EvaluateBet Note right of R: round_id, player_id<br/>side: MERON<br/>amount_cents: 1000000<br/>bet_slug: "BET-ABC-123" S->>S: Risk Assessment Note right of S: Check player limit<br/>Calculate max_loss<br/>Determine odds end %% 回應分支 alt Scenario A: ACCEPTED (原賠率可用) rect rgb(240, 255, 240) S->>P: Store PendingBet Note over P: expires_at: now + 5min S-->>R: status: ACCEPTED<br/>original_odds_bps: 7800<br/>bet_slug: "BET-ABC-123" R->>S: ConfirmBet(ACCEPT, odds: 7800) S->>P: Commit to Round Note over P: Confirmed S-->>R: status: SUCCESS<br/>final_odds_bps: 7800 end else Scenario B: NEEDS_CONFIRMATION (賠率被調整) rect rgb(255, 255, 224) S->>P: Store PendingBet with adjusted_odds S-->>R: status: NEEDS_CONFIRMATION<br/>original_odds_bps: 7800<br/>adjusted_odds_bps: 5200 Note over R: 顯示給玩家:<br/>賠率從 0.78 調整為 0.52<br/>是否接受? alt 玩家接受 R->>S: ConfirmBet(ACCEPT, odds: 5200) S->>P: Commit to Round Note over P: Confirmed S-->>R: status: SUCCESS<br/>final_odds_bps: 5200 else 玩家取消 R->>S: ConfirmBet(CANCEL) S->>P: Remove PendingBet Note over P: Cancelled S-->>R: status: CANCELLED end end else Scenario C: REJECTED (風控拒絕) rect rgb(255, 240, 240) S-->>R: status: REJECTED<br/>rejection_reason: "..." Note over P: No State end end ``` ### 冪等性處理 (Idempotency) `bet_slug` 由 Rails 生成,作為投注的唯一標識符,確保重試安全。 ```mermaid sequenceDiagram participant R as Rails participant S as Risk Service %% 情況 1: 網路超時後重試 EvaluateBet rect rgb(255, 250, 240) Note over R,S: 情況 1: 網路超時後重試 EvaluateBet R->>S: EvaluateBet(bet_slug: "X") Note right of S: 已處理並確認 S--xR: (timeout) R->>S: EvaluateBet(bet_slug: "X") Note right of R: 重試相同 bet_slug S-->>R: status: ALREADY_CONFIRMED Note right of S: 或 ALREADY_CANCELLED end %% 情況 2: 使用 GetBetStatus 查詢 rect rgb(240, 248, 255) Note over R,S: 情況 2: 使用 GetBetStatus 查詢 R->>S: ConfirmBet(bet_slug: "X") Note right of S: 可能已處理 S--xR: (timeout) R->>S: GetBetStatus(bet_slug: "X") Note right of R: 查詢實際狀態 S-->>R: status: CONFIRMED<br/>final_odds_bps: 7800 Note right of S: 確認已成功 end ``` ### 錯誤處理流程 ```mermaid sequenceDiagram participant R as Rails participant S as Risk Service R->>S: EvaluateBet(...) alt INVALID_ARGUMENT (不要重試) rect rgb(255, 240, 240) S-->>R: [INVALID_ROUND_ID] round_id 格式無效 S-->>R: [INVALID_PLAYER_ID] player_id 為空 S-->>R: [INVALID_SIDE] side 未指定 S-->>R: [INVALID_BET_SLUG] bet_slug 為空 S-->>R: [INVALID_AMOUNT] amount <= 0 end else NOT_FOUND (資源不存在) rect rgb(255, 250, 240) S-->>R: [ROUND_NOT_FOUND] 回合尚未開始或已結束 end else ALREADY_EXISTS (重複建立) rect rgb(255, 255, 224) S-->>R: [ROUND_ALREADY_EXISTS] 回合已存在 end else PERMISSION_DENIED (權限錯誤) rect rgb(255, 230, 230) S-->>R: player_id mismatch (ConfirmBet 時) end else FAILED_PRECONDITION (狀態不符) rect rgb(240, 240, 255) S-->>R: bet already processed 投注已處理 end else INTERNAL (可重試) rect rgb(240, 248, 255) S-->>R: server error 伺服器內部錯誤 Note right of R: 建議使用指數退避重試 end end ``` **錯誤處理建議:** | gRPC Code | 是否重試 | 說明 | |-----------|---------|------| | `INVALID_ARGUMENT` | 否 | 修正參數後重試 | | `NOT_FOUND` | 否 | 確認回合已開始 | | `ALREADY_EXISTS` | 否 | 回合已存在,直接使用 | | `PERMISSION_DENIED` | 否 | 檢查 player_id | | `FAILED_PRECONDITION` | 否 | 使用 GetBetStatus 查詢 | | `INTERNAL` | 是 | 指數退避重試 | --- ## Precision Units ### Money (金額) - 單位: 分 (cents) - 換算: 1 元 = 100 分 - 欄位後綴: `_cents` - 範例: `1000000` 分 = 10,000 元 ### Odds (賠率) - 單位: 基點 (basis points) - 換算: 1.0 = 10000 基點 - 欄位後綴: `_bps` - 馬來賠率範圍: - 正數: 3000 ~ 9900 (0.30 ~ 0.99) - 負數: -10000 ~ -3000 (-1.0 ~ -0.30) - 範例: `7800` bps = 0.78, `-10000` bps = -1.0 ### Rate (比率) - 單位: 基點 (basis points) - 換算: 100% = 10000 基點 - 欄位後綴: `_bps` - 範例: `450` bps = 4.5% --- ## Enums ### Side 投注方/贏家。 | 值 | 數值 | 說明 | |----|------|------| | `SIDE_UNSPECIFIED` | 0 | 無效值,服務端拒絕 | | `SIDE_MERON` | 1 | 紅方 | | `SIDE_WALA` | 2 | 藍方 | ### PlayerChoice 玩家對調整賠率的選擇。 | 值 | 數值 | 說明 | |----|------|------| | `PLAYER_CHOICE_UNSPECIFIED` | 0 | 無效值,服務端拒絕 | | `PLAYER_CHOICE_ACCEPT` | 1 | 接受調整後賠率 | | `PLAYER_CHOICE_CANCEL` | 2 | 取消投注 | --- ## Service: RiskService ### Round Lifecycle (回合生命週期) #### StartRound 開始新回合。 ```protobuf rpc StartRound(StartRoundRequest) returns (StartRoundResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合唯一標識 | | `config` | RiskConfig | No | 風控配置,使用預設值若未提供 | | `odds_push_frequency_ms` | int32 | No | (v2.6.0) 賠率推送頻率(毫秒),預設 100ms,範圍 50-5000ms | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `initial_meron_odds_bps` | int32 | 開盤 Meron 賠率 (基點) | | `initial_wala_odds_bps` | int32 | 開盤 Wala 賠率 (基點) | **Errors**: - `ALREADY_EXISTS`: 回合已存在 --- #### GetRoundState 查詢回合狀態。 ```protobuf rpc GetRoundState(GetRoundStateRequest) returns (RoundStateResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `round_id` | string | 回合 ID | | `meron_pool_cents` | int64 | Meron 池 (分) | | `wala_pool_cents` | int64 | Wala 池 (分) | | `meron_net_income_cents` | int64 | Meron 淨收入 (分) | | `wala_net_income_cents` | int64 | Wala 淨收入 (分) | | `total_bets` | int32 | 總投注數 | | `max_loss_cents` | int64 | 最大可能損失 (分) - 僅含已確認投注 | | `current_meron_odds_bps` | int32 | 當前 Meron 賠率 (基點) | | `current_wala_odds_bps` | int32 | 當前 Wala 賠率 (基點) | | `started_at` | int64 | 開始時間 (Unix timestamp) | | **Provisional Risk (v2.2.0+)** | | | | `provisional_meron_risk_cents` | int64 | Meron 方向的暫存風險 (分) | | `provisional_wala_risk_cents` | int64 | Wala 方向的暫存風險 (分) | | `pending_bets` | int32 | 待確認投注數量 | | `effective_max_loss_cents` | int64 | 有效最大損失 (含 Provisional Risk, 分) | **Errors**: - `NOT_FOUND`: 回合不存在 --- #### EndRound 結束回合並結算。 **重要 (v2.2.0 變更)**:EndRound 現在是純結算操作,不再執行風險檢查。風險評估已移至 StopRound。 建議流程:先調用 StopRound 獲取風險評估,根據 `should_cancel` 決定調用 EndRound 或 CancelRound。 ```protobuf rpc EndRound(EndRoundRequest) returns (EndRoundResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | | `winner` | Side | Yes | 贏家 (SIDE_MERON 或 SIDE_WALA) | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `house_profit_cents` | int64 | 莊家盈利 (分) | | `total_bets` | int32 | 總投注數 | | `auto_cancelled` | bool | 永遠為 false (v2.2.0 後 EndRound 不會自動取消) | | `final_status` | RoundFinalStatus | 永遠為 SETTLED (v2.2.0 後 EndRound 只做結算) | | `cancel_reason` | string | 永遠為空 (取消邏輯已移至 StopRound) | | `winner` | Side | 勝方 | | `meron_pool_cents` | int64 | Meron 總投注 (分) | | `wala_pool_cents` | int64 | Wala 總投注 (分) | | `meron_bets` | int32 | Meron 投注數 | | `wala_bets` | int32 | Wala 投注數 | | `final_meron_odds_bps` | int32 | 最終 Meron 賠率 (基點) | | `final_wala_odds_bps` | int32 | 最終 Wala 賠率 (基點) | | `started_at` | int64 | 開始時間 (Unix timestamp) | | `ended_at` | int64 | 結束時間 (Unix timestamp) | | `max_exposure_cents` | int64 | 結算前最大風險敞口 (分) | | `max_allowed_cents` | int64 | 配置的最大允許損失 (分) | | `remaining_reserve_cents` | int64 | 結算前剩餘額度 (分) | | `meron_net_income_cents` | int64 | Meron 淨收入 (分) | | `wala_net_income_cents` | int64 | Wala 淨收入 (分) | | `if_other_wins_profit_cents` | int64 | 若另一方贏,莊家盈虧 (分) | | `min_bet_amount_cents` | int64 | 配置的最小投注額 (分) | | `max_bet_amount_cents` | int64 | 配置的最大投注額 (分) | | `rake_rate_bps` | int32 | 配置的抽水率 (基點) | | `odds_floor_bps` | int32 | 配置的賠率地板 (基點) | | `odds_ceiling_bps` | int32 | 配置的賠率天花板 (基點) | **RoundFinalStatus 枚舉**: | 值 | 數值 | 說明 | |----|------|------| | `ROUND_FINAL_STATUS_UNSPECIFIED` | 0 | 未指定 | | `ROUND_FINAL_STATUS_SETTLED` | 1 | 正常結算 | | `ROUND_FINAL_STATUS_CANCELLED` | 2 | 被取消 (僅由 CancelRound 返回) | **Errors**: - `NOT_FOUND`: 回合不存在 - `INVALID_ARGUMENT`: winner 無效 - `FAILED_PRECONDITION`: 回合未停止 (應先調用 StopRound) --- #### CancelRound 取消回合,退還所有投注。 ```protobuf rpc CancelRound(CancelRoundRequest) returns (CancelRoundResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | | `reason` | string | No | 取消原因 | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `refunded_bets` | int32 | 退款投注數 | **Errors**: - `NOT_FOUND`: 回合不存在 --- #### StopRound 永久結束回合下注階段。停止後執行風險評估,返回取消建議供呼叫方決策。 **重要 (v2.2.0 變更)**:StopRound 現在是永久性操作,無法恢復投注。ResumeRound 已移除。 ```protobuf rpc StopRound(StopRoundRequest) returns (StopRoundResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | | `reason` | string | No | 停止原因 (用於審計) | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | **時間戳** | | | | `stopped_at` | int64 | 停止時間 (Unix timestamp) | | `started_at` | int64 | 回合開始時間 (Unix timestamp) | | **Pending Bets 處理結果** | | | | `pending_bets_expired` | int32 | 過期/清理的待確認投注數 | | `provisional_risk_released_cents` | int64 | 釋放的 Provisional Risk (分) | | **投注統計** | | | | `confirmed_bets` | int32 | 已確認投注總數 | | `meron_bets` | int32 | Meron 投注數 | | `wala_bets` | int32 | Wala 投注數 | | **池資訊 (分)** | | | | `meron_pool_cents` | int64 | Meron 總投注 | | `wala_pool_cents` | int64 | Wala 總投注 | | **淨收入追蹤 (分)** | | | | `meron_net_income_cents` | int64 | Meron 淨收入 | | `wala_net_income_cents` | int64 | Wala 淨收入 | | **當前賠率 (基點)** | | | | `current_meron_odds_bps` | int32 | 當前 Meron 賠率 | | `current_wala_odds_bps` | int32 | 當前 Wala 賠率 | | **假設性結算分析** | | | | `if_meron_wins_profit_cents` | int64 | 若 Meron 贏,莊家盈虧 (分,正=盈利,負=虧損) | | `if_wala_wins_profit_cents` | int64 | 若 Wala 贏,莊家盈虧 (分) | | **風險評估結果** | | | | `should_cancel` | bool | **建議是否取消回合** | | `cancel_reason` | string | 建議取消原因 (`one_sided_betting` / `max_round_loss_exceeded`) | | **詳細風險分析** | | | | `is_one_sided` | bool | 是否單邊投注 (Meron 或 Wala 池為 0) | | `meron_wins_exceeds_max_loss` | bool | 若 Meron 贏,虧損是否超過 MaxRoundLoss | | `wala_wins_exceeds_max_loss` | bool | 若 Wala 贏,虧損是否超過 MaxRoundLoss | | **風險敞口資訊** | | | | `max_exposure_cents` | int64 | 最大風險敞口 (分) = 兩方結算中虧損較大者 | | `max_allowed_cents` | int64 | 配置的 MaxRoundLoss (分) | | **使用的風控配置** | | | | `min_bet_amount_cents` | int64 | 最小投注金額 (分) | | `max_bet_amount_cents` | int64 | 最大投注金額 (分) | | `rake_rate_bps` | int32 | 抽水率 (基點) | | `odds_floor_bps` | int32 | 賠率下限 (基點) | | `odds_ceiling_bps` | int32 | 賠率上限 (基點) | **風險評估邏輯** (v2.3.0 更新): 1. 若 `force_settle = true`: - `should_cancel = false`,跳過所有檢查 2. 若 `accept_one_side_bet = false` 且 MeronPool=0 或 WalaPool=0: - `should_cancel = true`, `cancel_reason = "one_sided_betting"` 3. 若最大虧損超過 MaxRoundLoss: - `should_cancel = true`, `cancel_reason = "max_round_loss_exceeded"` - 注意:v2.3.0 後不再依賴 `auto_cancel_on_loss`,ForceSettle=false 時一律檢查 **行為說明**: - 停止後 **EvaluateBet** 返回 `REJECTED` (reason: `round_stopped`) - 停止後 **ConfirmBet** 返回 `FAILED` (待確認投注已被清除) - 待確認投注 (PendingBets) 會被立即清除,釋放 ProvisionalRisk - **GetRoundState** / **GetCurrentOdds** / **GetRiskExposure** 正常運作 - 呼叫方應根據 `should_cancel` 決定調用 **EndRound** 或 **CancelRound** - 冪等性:對已停止的回合再次調用 StopRound 返回相同結果 **典型使用流程**: ``` StopRound(round_id) → 檢查 should_cancel → 若 true: CancelRound(round_id, cancel_reason) → 若 false: EndRound(round_id, winner) ``` **Errors**: - `NOT_FOUND`: 回合不存在 - `FAILED_PRECONDITION`: 回合已結算或已取消 --- ### Betting Flow (投注流程) 投注採用兩階段提交: 1. **EvaluateBet**: 評估投注,返回狀態和賠率 2. **ConfirmBet**: 玩家確認或取消 **重要:** - `bet_slug` (由 Rails 提供) 作為冪等性 key - **評估有效期 30 秒** (`TokenTTL`):EvaluateBet 後需在 30 秒內完成 ConfirmBet,超時返回 `BET_EXPIRED` 錯誤 - 客戶端應在收到 EvaluateBet 回應後立即顯示確認 UI,並在 30 秒內完成 ConfirmBet #### EvaluateBet 評估投注風險。 ```protobuf rpc EvaluateBet(EvaluateBetRequest) returns (EvaluateBetResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | | `player_id` | string | Yes | 玩家 ID | | `side` | Side | Yes | 投注方 | | `amount_cents` | int64 | Yes | 投注金額 (分) | | `bet_slug` | string | Yes | 投注單號 (冪等性 key) | | `expected_odds_bps` | int32 | No | 玩家預期賠率 (基點),Rails 顯示給玩家的賠率 (0=不檢查) | | `auto_confirm` | bool | No | **[v2.4.0]** 啟用自動確認,若評估結果為 ACCEPTED 則直接確認 | | `auto_accept_better_odds` | bool | No | **[v2.4.0]** 賠率變好時也自動確認 (需 auto_confirm=true) | **賠率變動檢測**: 當 `expected_odds_bps` 有值時,系統會比較實際提供的賠率與玩家預期: - 若賠率不同(無論變好或變差)→ 返回 `NEEDS_CONFIRMATION` - 若賠率相同 → 返回 `ACCEPTED` 這確保玩家看到的賠率與實際成交的賠率一致,避免因投注延遲造成的賠率差異。 **自動確認邏輯 (v2.4.0)**: 當 `auto_confirm=true` 時,系統會在符合條件時自動完成確認: - 賠率沒變 (`odds_change=UNCHANGED`) → 自動確認,返回 `CONFIRMED` - 賠率變好 (`odds_change=BETTER`) + `auto_accept_better_odds=true` → 自動確認 - 賠率變差 (`odds_change=WORSE`) → 不自動確認,返回 `NEEDS_CONFIRMATION` **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `status` | Status | 評估狀態 | | `original_odds_bps` | int32 | 原始賠率 (基點) | | `adjusted_odds_bps` | int32 | 調整後賠率 (基點),僅 NEEDS_CONFIRMATION 時有值 | | `bet_slug` | string | 投注單號 | | `expires_at` | int64 | 過期時間 (Unix timestamp) | | `rejection_reason` | string | 拒絕原因,僅 REJECTED 時有值 | | `auto_confirmed` | bool | **[v2.4.0]** 是否已自動確認 | | `odds_change` | OddsChange | **[v2.4.0]** 賠率變動方向 | | `confirmed_at` | int64 | **[v2.4.0]** 確認時間 (Unix timestamp),僅自動確認時有值 | **Status 枚舉**: | 值 | 數值 | 說明 | |----|------|------| | `ACCEPTED` | 0 | 接受,可直接確認 | | `NEEDS_CONFIRMATION` | 1 | 賠率被調整,需玩家確認 | | `REJECTED` | 2 | 拒絕 | | `ALREADY_CONFIRMED` | 3 | 已確認 (冪等回應) | | `ALREADY_CANCELLED` | 4 | 已取消 (冪等回應) | | `CONFIRMED` | 5 | **[v2.4.0]** 已自動確認 | **OddsChange 枚舉 (v2.4.0)**: | 值 | 數值 | 說明 | |----|------|------| | `ODDS_UNCHANGED` | 0 | 賠率沒變 | | `ODDS_BETTER` | 1 | 賠率變好(對玩家有利,實際賠率 > 預期賠率) | | `ODDS_WORSE` | 2 | 賠率變差(對玩家不利,實際賠率 < 預期賠率) | **Errors**: - `INVALID_ARGUMENT`: 參數無效 (round_id, player_id, side, bet_slug, amount) - `NOT_FOUND`: 回合不存在 --- #### ConfirmBet 確認或取消投注。 ```protobuf rpc ConfirmBet(ConfirmBetRequest) returns (ConfirmBetResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | | `bet_slug` | string | Yes | 投注單號 | | `player_id` | string | Yes | 玩家 ID (驗證歸屬) | | `player_choice` | PlayerChoice | Yes | 玩家選擇 | | `accepted_odds_bps` | int32 | No | 接受的賠率 (基點) | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `status` | Status | 確認狀態 | | `bet_slug` | string | 投注單號 | | `final_odds_bps` | int32 | 最終賠率 (基點) | | `failure_reason` | string | 失敗原因,僅 FAILED 時有值 (如: `round_stopped`) | **Status 枚舉**: | 值 | 數值 | 說明 | |----|------|------| | `SUCCESS` | 0 | 確認成功 | | `FAILED` | 1 | 確認失敗 | | `EXPIRED` | 2 | 已過期 | | `CANCELLED` | 3 | 已取消 | | `ALREADY_CONFIRMED` | 4 | 已確認 (冪等回應) | **Errors**: - `INVALID_ARGUMENT`: 參數無效 - `NOT_FOUND`: 回合不存在 - `PERMISSION_DENIED`: player_id 不匹配 --- #### GetBetStatus 查詢投注狀態 (用於網路超時後確認結果)。 ```protobuf rpc GetBetStatus(GetBetStatusRequest) returns (GetBetStatusResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | | `bet_slug` | string | Yes | 投注單號 | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `status` | Status | 投注狀態 | | `bet_slug` | string | 投注單號 | | `final_odds_bps` | int32 | 最終賠率 (基點) | | `evaluated_at` | int64 | 評估時間 | | `confirmed_at` | int64 | 確認時間 (0 表示未確認) | | `expires_at` | int64 | 過期時間 | **Status 枚舉**: | 值 | 數值 | 說明 | |----|------|------| | `NOT_FOUND` | 0 | 找不到 | | `PENDING` | 1 | 等待確認 | | `CONFIRMED` | 2 | 已確認 | | `CANCELLED` | 3 | 已取消 | | `EXPIRED` | 4 | 已過期 | **查詢邏輯 (v2.4.0+)**: 1. 先從記憶體查詢活躍回合 2. 若回合已結束 (不在記憶體),自動 fallback 到 MySQL 查詢歷史記錄 3. 回合結束後仍可查詢到已確認的投注狀態 --- ### Real-time Streaming (即時推送) (v2.6.0) #### SubscribeOdds 訂閱即時賠率更新。連接後會收到所有活躍回合的賠率變化。 ```protobuf rpc SubscribeOdds(SubscribeOddsRequest) returns (stream OddsUpdate); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_ids` | repeated string | No | 過濾特定回合(空 = 接收全部)| | `override_frequency_ms` | int32 | No | 覆蓋推送頻率(毫秒),範圍 50-5000ms | **Streamed Response (OddsUpdate)**: | 欄位 | 類型 | 說明 | |------|------|------| | `round_id` | string | 回合 ID | | `meron_odds_bps` | int32 | Meron 賠率 (基點) | | `wala_odds_bps` | int32 | Wala 賠率 (基點) | | `meron_pool_cents` | int64 | Meron 池 (分) | | `wala_pool_cents` | int64 | Wala 池 (分) | | `timestamp_ms` | int64 | 時間戳 (毫秒) | | `update_type` | OddsUpdateType | 更新類型 | | `sequence` | uint64 | 序列號(用於檢測丟失)| | `round_status` | string | 回合狀態(僅 ROUND_ENDED 時有值:settled/cancelled)| | `winner` | string | 勝方(僅結算時有值:meron/wala)| | `meron_net_income_cents` | int64 | Meron 淨收入 (分) - 若 Meron 贏,莊家的淨收入 | | `wala_net_income_cents` | int64 | Wala 淨收入 (分) - 若 Wala 贏,莊家的淨收入 | | `total_bets` | int32 | 總投注數 | | `meron_bets` | int32 | Meron 投注數 | | `wala_bets` | int32 | Wala 投注數 | | `max_loss_cents` | int64 | 當前最大可能虧損 (分) | | `effective_max_loss_cents` | int64 | 有效最大虧損 (分) - 考慮預備金限制 | | `provisional_meron_risk_cents` | int64 | Meron 暫存風險 (分) - 待確認投注的風險預留 | | `provisional_wala_risk_cents` | int64 | Wala 暫存風險 (分) - 待確認投注的風險預留 | | `pending_bets` | int32 | 待確認投注數 | | `started_at` | int64 | 回合開始時間 (Unix timestamp) | | `house_profit_cents` | int64 | 莊家盈虧 (分) - 僅 ROUND_ENDED 時有值 | **OddsUpdateType**: | 值 | 數值 | 說明 | |----|------|------| | `ODDS_UPDATE_TYPE_UNSPECIFIED` | 0 | 未指定 | | `ODDS_UPDATE_TYPE_ODDS_CHANGE` | 1 | 賠率變更(定期推送)| | `ODDS_UPDATE_TYPE_ROUND_STARTED` | 2 | 新回合開始 | | `ODDS_UPDATE_TYPE_ROUND_ENDED` | 3 | 回合結束 | **行為說明**: - 連接後立即收到所有活躍回合的當前狀態(`ODDS_CHANGE` 類型) - 新回合開始時收到 `ROUND_STARTED` 事件 - 定期收到 `ODDS_CHANGE` 事件(頻率由 StartRound 的 `odds_push_frequency_ms` 決定) - 回合結束時收到 `ROUND_ENDED` 事件(含最終狀態和勝方) - 若訂閱特定 `round_ids`,所有這些回合結束後,stream 會自動關閉 - 訂閱者緩衝區滿時,舊的更新會被丟棄(非阻塞設計) **推送頻率限制**: | 限制 | 值 | |------|------| | 最小頻率 | 50ms | | 最大頻率 | 5000ms | | 預設頻率 | 100ms | **Usage Example**: ```go // Go client 訂閱賠率更新 stream, err := client.SubscribeOdds(ctx, &pb.SubscribeOddsRequest{ RoundIds: []string{"round-001", "round-002"}, // 過濾特定回合 OverrideFrequencyMs: 200, // 覆蓋為 200ms }) if err != nil { log.Fatal(err) } for { update, err := stream.Recv() if err == io.EOF { break // Stream 結束 } if err != nil { log.Fatal(err) } switch update.UpdateType { case pb.OddsUpdateType_ODDS_UPDATE_TYPE_ROUND_STARTED: log.Printf("Round %s started", update.RoundId) case pb.OddsUpdateType_ODDS_UPDATE_TYPE_ODDS_CHANGE: log.Printf("Round %s odds: meron=%d wala=%d", update.RoundId, update.MeronOddsBps, update.WalaOddsBps) case pb.OddsUpdateType_ODDS_UPDATE_TYPE_ROUND_ENDED: log.Printf("Round %s ended: status=%s winner=%s", update.RoundId, update.RoundStatus, update.Winner) } } ``` --- ### Query (查詢) #### GetCurrentOdds 查詢當前賠率。 ```protobuf rpc GetCurrentOdds(GetCurrentOddsRequest) returns (OddsResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `meron_odds_bps` | int32 | Meron 賠率 (基點) | | `wala_odds_bps` | int32 | Wala 賠率 (基點) | --- #### GetRiskExposure 查詢風險敞口。 ```protobuf rpc GetRiskExposure(GetRiskExposureRequest) returns (RiskExposureResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `current_exposure_cents` | int64 | 當前風險敞口 (分) | | `max_allowed_cents` | int64 | 最大允許損失 (分) | | `remaining_reserve_cents` | int64 | 剩餘額度 (分) | | `is_at_risk` | bool | 是否處於高風險 (>80%) | | `meron_pool_cents` | int64 | Meron 總投注 (分) | | `wala_pool_cents` | int64 | Wala 總投注 (分) | | `meron_bets` | int32 | Meron 投注數 | | `wala_bets` | int32 | Wala 投注數 | | `total_bets` | int32 | 總投注數 | | `current_meron_odds_bps` | int32 | 當前 Meron 賠率 (基點) | | `current_wala_odds_bps` | int32 | 當前 Wala 賠率 (基點) | | `if_meron_wins_profit_cents` | int64 | 若 Meron 贏,莊家盈虧 (分) | | `if_wala_wins_profit_cents` | int64 | 若 Wala 贏,莊家盈虧 (分) | **假設性結算分析**: `if_meron_wins_profit_cents` 和 `if_wala_wins_profit_cents` 提供即時的假設性結算預測, 可用於顯示「如果現在結束,莊家會...」的資訊。正值表示盈利,負值表示虧損。 --- ### History Query (歷史查詢) #### GetRoundHistory 查詢單一回合詳情(包含所有投注記錄)。 ```protobuf rpc GetRoundHistory(GetRoundHistoryRequest) returns (GetRoundHistoryResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `round_id` | string | Yes | 回合 ID | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `round` | RoundHistoryRecord | 回合記錄 | | `bets` | repeated BetHistoryRecord | 該回合所有投注 | **Errors**: - `NOT_FOUND`: 回合不存在 - `INVALID_ROUND_ID`: round_id 格式無效 --- #### ListRounds 時間範圍查詢回合列表。 ```protobuf rpc ListRounds(ListRoundsRequest) returns (ListRoundsResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `start_time` | int64 | Yes | 開始時間 (Unix timestamp 秒) | | `end_time` | int64 | Yes | 結束時間 (Unix timestamp 秒) | | `status` | RoundStatusFilter | No | 狀態過濾 (UNSPECIFIED=不過濾) | | `page_size` | int32 | No | 每頁筆數 (預設 20,最大 100) | | `page_token` | string | No | 分頁 cursor token | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `rounds` | repeated RoundHistoryRecord | 回合列表 | | `next_page_token` | string | 下一頁 token,空字串表示沒有更多 | | `total_count` | int32 | 符合條件的總筆數 (首頁才計算) | **Errors**: - `INVALID_TIME_RANGE`: 時間範圍無效 - `INVALID_PAGE_TOKEN`: 分頁 token 無效 --- #### ListPlayerBets 查詢玩家投注歷史。 ```protobuf rpc ListPlayerBets(ListPlayerBetsRequest) returns (ListPlayerBetsResponse); ``` **Request**: | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `player_id` | string | Yes | 玩家 ID | | `start_time` | int64 | No | 開始時間 (Unix timestamp 秒) | | `end_time` | int64 | No | 結束時間 (Unix timestamp 秒) | | `round_id` | string | No | 篩選特定回合 | | `page_size` | int32 | No | 每頁筆數 (預設 20,最大 100) | | `page_token` | string | No | 分頁 cursor token | **Response**: | 欄位 | 類型 | 說明 | |------|------|------| | `bets` | repeated BetHistoryRecord | 投注列表 | | `next_page_token` | string | 下一頁 token | | `total_count` | int32 | 符合條件的總筆數 | **Errors**: - `INVALID_PLAYER_ID`: player_id 為空 - `INVALID_PAGE_TOKEN`: 分頁 token 無效 --- #### RoundHistoryRecord 回合歷史記錄結構。 | 欄位 | 類型 | 說明 | |------|------|------| | `round_id` | string | 回合 ID | | `status` | string | 狀態 (betting/stopped/settled/cancelled) | | `winner` | string | 贏家 (meron/wala),取消時為空 | | `meron_pool_cents` | int64 | Meron 池 (分) | | `wala_pool_cents` | int64 | Wala 池 (分) | | `meron_net_income_cents` | int64 | Meron 淨收入 (分) | | `wala_net_income_cents` | int64 | Wala 淨收入 (分) | | `total_bets` | int32 | 總投注數 | | `meron_bets` | int32 | Meron 投注數 | | `wala_bets` | int32 | Wala 投注數 | | `house_profit_cents` | int64 | 莊家盈利 (分) | | `max_loss_cents` | int64 | 結算時最大損失 (分) | | `final_meron_odds_bps` | int32 | 最終 Meron 賠率 (基點) | | `final_wala_odds_bps` | int32 | 最終 Wala 賠率 (基點) | | `started_at` | int64 | 開始時間 (Unix timestamp) | | `ended_at` | int64 | 結束時間 (Unix timestamp),0 表示尚未結束 | | `max_round_loss_cents` | int64 | 配置的最大回合損失 (分) | | `rake_rate_bps` | int32 | 抽水率 (基點) | --- #### BetHistoryRecord 投注歷史記錄結構。 | 欄位 | 類型 | 說明 | |------|------|------| | `bet_id` | string | 投注 ID (bet_slug) | | `round_id` | string | 回合 ID | | `player_id` | string | 玩家 ID | | `side` | string | 投注方 (meron/wala) | | `status` | string | 狀態 (pending/confirmed/cancelled/expired) | | `amount_cents` | int64 | 投注金額 (分) | | `win_payout_cents` | int64 | 如果贏的賠付 (分) | | `loss_income_cents` | int64 | 如果輸的收入 (分) | | `original_odds_bps` | int32 | 原始賠率 (基點) | | `adjusted_odds_bps` | int32 | 調整後賠率 (基點),0 表示未調整 | | `final_odds_bps` | int32 | 最終賠率 (基點) | | `opponent_odds_bps` | int32 | 對手方賠率 (基點) | | `bet_sequence` | int32 | 該回合第幾注 | | `player_round_total_cents` | int64 | 玩家該回合累積投注 (分) | | `created_at` | int64 | 創建時間 (Unix timestamp) | | `confirmed_at` | int64 | 確認時間 (Unix timestamp),0 表示未確認 | --- #### RoundStatusFilter 回合狀態過濾枚舉。 | 值 | 說明 | |------|------| | `ROUND_STATUS_FILTER_UNSPECIFIED` | 不過濾,返回所有狀態 | | `ROUND_STATUS_FILTER_BETTING` | 進行中 | | `ROUND_STATUS_FILTER_SETTLED` | 已結算 | | `ROUND_STATUS_FILTER_CANCELLED` | 已取消 | | `ROUND_STATUS_FILTER_STOPPED` | 已暫停 | --- ## RiskConfig 風控配置。 | 欄位 | 類型 | 預設值 | 說明 | |------|------|--------|------| | `max_round_loss_cents` | int64 | 10000000 (100,000元) | 回合最大損失 | | `min_bet_amount_cents` | int64 | 100000 (1,000元) | 單注最小額 | | `max_bet_amount_cents` | int64 | 34500000 (345,000元) | 單注最大額 | | `player_round_limit_cents` | int64 | 138000000 (1,380,000元) | 玩家回合限額 | | `odds_floor_bps` | int32 | 3000 (0.30) | 賠率地板 | | `odds_ceiling_bps` | int32 | 9900 (0.99) | 賠率天花板 | | `rake_rate_bps` | int32 | 450 (4.5%) | 抽水率 | | `auto_cancel_on_loss` | bool | false | **[已棄用]** 請改用 `unlimited_betting` | | `accept_one_side_bet` | bool | false | 允許單邊投注 (false=單邊時自動取消) | | `force_settle` | bool | false | 強制成局,StopRound 永不建議取消 | | `unlimited_betting` | bool | false | **[v2.3.0]** 跳過風險容量檢查,按市場賠率全收 (見下方警告) | | `initial_meron_odds_bps` | int32 | 8400 (0.84) | 開盤 Meron 賠率 (0=使用預設) | | `initial_wala_odds_bps` | int32 | -10000 (-1.0) | 開盤 Wala 賠率 (0=使用預設) | | `decay_config` | DecayConfig | 見下方 | **[v2.5.0]** 漸進式風險衰減配置 | ### DecayConfig (v2.5.0) 漸進式風險衰減,根據風險使用率動態調整賠率。 | 欄位 | 類型 | 預設值 | 說明 | |------|------|--------|------| | `enabled` | bool | true | 是否啟用漸進衰減 (false=回退到硬限制模式) | | `mode` | DecayMode | EXPONENTIAL | 衰減模式 | | `start_ratio_bps` | int32 | 5000 (50%) | 開始衰減的風險使用率 (0~9000) | | `floor_bps` | int32 | 5000 (50%) | 賠率衰減地板,最低保留比例 (3000~10000) | | `intensity_bps` | int32 | 3000 | 衰減強度,數值越大衰減越快 (1000~10000),僅 EXPONENTIAL 模式 | | `zones` | []DecayZone | - | 自定義分段區間,僅 PIECEWISE 模式,最多 5 個區間 | **DecayMode 枚舉:** | 值 | 說明 | 適用場景 | |----|------|---------| | `NONE` | 不衰減,僅硬限制 | 向後兼容 V1 行為 | | `EXPONENTIAL` | 指數衰減,最平滑 | 推薦用於生產環境 | | `QUADRATIC` | 二次曲線,前慢後快 | 需要前期容忍度 | | `PIECEWISE` | 自定義分段線性 | 精確控制衰減曲線 | **DecayZone 結構 (用於 PIECEWISE 模式):** | 欄位 | 類型 | 說明 | |------|------|------| | `start_ratio_bps` | int32 | 區間起點 (基點, 包含) | | `end_ratio_bps` | int32 | 區間終點 (基點, 不包含) | | `start_decay_bps` | int32 | 起點的衰減因子 (10000 = 100% 不衰減) | | `end_decay_bps` | int32 | 終點的衰減因子 | **衰減公式 (EXPONENTIAL):** ``` risk_ratio = current_exposure / max_round_loss if risk_ratio < start_ratio: decay = 1.0 (無衰減) else: progress = (risk_ratio - start_ratio) / (1.0 - start_ratio) decay = floor + (1.0 - floor) * exp(-intensity * progress) final_odds = market_odds * decay ``` ### ⚠️ UnlimitedBetting 警告 當 `unlimited_betting = true` 時: **行為說明**: - 跳過風險容量檢查,所有投注都會被接受 - 使用純市場賠率(基於池子比例) - **若市場賠率低於 floor (0.30),仍提供 floor 賠率** - 可能導致虧損超過 `max_round_loss` **風險情境**: ``` 情境: 極端單邊投注 - MeronPool = 500萬, WalaPool = 0 - 市場賠率: Meron = 0 (因為 Wala 池子為 0) - 實際提供: Meron = 0.30 (floor) 後果: - 繼續收 Meron 單,每筆賠付 = 投注額 × 0.30 - 若 Meron 贏: 總賠付 = 500萬 × 0.30 = 150萬 - 莊家虧損 = 150萬 (遠超 MaxRoundLoss) ``` **建議使用場景**: - 重要賽事,業務需求必須成局 - 營運方明確接受無限風險 - 搭配 `force_settle = true` 使用(全收 + 一律成局) ### 配置驗證 StartRound 時會驗證 RiskConfig,若配置無效會返回 `INVALID_ARGUMENT` 錯誤: | 驗證項目 | 規則 | |---------|------| | 金額限制 | `min_bet > 0`, `max_bet > 0`, `min_bet <= max_bet` | | 賠率範圍 | `odds_floor` 和 `odds_ceiling` 在 1-9900 bps | | 抽水率 | `rake_rate` 在 0-5000 bps (0-50%) | | 開盤賠率 | 必須是有效馬來賠率 (3000-9900 或 -10000 到 -3000 bps) | | 組合限制 | 不允許兩邊開盤賠率都是負數 | | 最大虧損 | `max_round_loss >= 0` | ### 回合取消邏輯 (v2.3.0 更新) **v2.3.0 更新**:`unlimited_betting` 和 `force_settle` 職責分離。 #### 參數職責 | 參數 | 影響階段 | 說明 | |------|---------|------| | `unlimited_betting` | EvaluateBet + ConfirmBet | 控制收單時是否跳過風險容量檢查 (v2.3.1: 同時影響兩階段) | | `force_settle` | StopRound | 控制結算時是否建議取消 | #### StopRound 取消評估邏輯 1. **`force_settle = true`**: `should_cancel = false`,跳過所有檢查 2. **`force_settle = false`** 時按順序檢查: - 若 `accept_one_side_bet = false` 且單邊投注:`should_cancel = true` - 若虧損超過 MaxRoundLoss:`should_cancel = true` #### 組合行為 | unlimited_betting | force_settle | EvaluateBet | StopRound | |-------------------|--------------|-------------|-----------| | false | false | 嚴格風控,可能拒單 | 虧損超限或單邊時建議取消 | | true | false | 全收,按市場賠率 | 虧損超限或單邊時建議取消 | | true | true | 全收,按市場賠率 | 永不建議取消 | | false | true | 嚴格風控,可能拒單 | 永不建議取消 | #### StopRound 取消條件 (cancel_reason) `force_settle = false` 時,StopRound 會評估以下條件並設定 `cancel_reason`: | cancel_reason | 條件 | 說明 | |--------------|------|------| | `one_sided_betting` | `accept_one_side_bet=false` 且單邊投注 (MeronPool=0 或 WalaPool=0) | 單邊投注無對沖,結算即為莊家全輸或全贏 | | `max_round_loss_exceeded` | `if_meron_wins_loss > max_round_loss` 或 `if_wala_wins_loss > max_round_loss` | 任一方勝利時的莊家虧損超過 MaxRoundLoss | #### 組合情境說明 **情境 1: `unlimited_betting=false, force_settle=false` (預設保守)** - EvaluateBet: 投注前檢查剩餘容量,超過 MaxRoundLoss 則調整賠率或拒絕 - StopRound: 評估風險,超限則建議取消 - 適用: 標準保守營運,控制單回合風險 **情境 2: `unlimited_betting=true, force_settle=false` (全收可取消)** - EvaluateBet: 跳過風險容量檢查,按市場賠率接受所有投注 - StopRound: 仍評估風險,若累積投注導致潛在虧損超過 MaxRoundLoss 則建議取消 - 適用: 提升收單成功率,但保留結算前取消權利 - ⚠️ **風險**: 可能累積大量投注後被建議取消,影響玩家體驗 **情境 3: `unlimited_betting=true, force_settle=true` (最激進)** - EvaluateBet: 跳過風險容量檢查,全收 - StopRound: 永不建議取消 - 適用: 完全接受市場風險,一律結算 - ⚠️ **風險**: 極端情況可能虧損遠超 MaxRoundLoss **情境 4: `unlimited_betting=false, force_settle=true` (嚴格收單 + 強制結算)** - EvaluateBet: 嚴格風控,可能拒絕高風險投注 - StopRound: 永不建議取消 - 適用: 投注階段嚴格把關,結算階段保證成局 **呼叫方流程**: ``` StopRound → 檢查 should_cancel → 若 true: CancelRound / 若 false: EndRound ``` **注意**: 呼叫方可以忽略 `should_cancel` 建議,強制調用 EndRound 或 CancelRound --- ## Error Handling (錯誤處理設計) 本服務使用**兩層錯誤處理設計**: 1. **gRPC Status Errors** - 系統層錯誤,表示請求無法處理 2. **Response rejection_reason** - 業務層拒絕,請求已處理但投注被拒 ### 判斷準則 ``` ┌─────────────────────────────────────────────────────────────┐ │ 錯誤判斷流程 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ EvaluateBet Request │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 參數驗證失敗? │──Yes──▶ gRPC INVALID_ARGUMENT │ │ └────────┬────────┘ (round_id/player_id/side 無效) │ │ │ No │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 回合不存在? │──Yes──▶ gRPC NOT_FOUND │ │ └────────┬────────┘ (ROUND_NOT_FOUND) │ │ │ No │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 回合狀態不對? │──Yes──▶ Response: REJECTED │ │ │ (已停止/已結束) │ rejection_reason: round_stopped│ │ └────────┬────────┘ │ │ │ No │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 金額不合規? │──Yes──▶ Response: REJECTED │ │ │ (過小/過大) │ rejection_reason: │ │ └────────┬────────┘ amount_too_small/amount_too_large│ │ │ No │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 風險容量耗盡? │──Yes──▶ Response: REJECTED │ │ └────────┬────────┘ rejection_reason: │ │ │ No risk_capacity_exceeded │ │ ▼ │ │ Response: ACCEPTED / NEEDS_CONFIRMATION │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### 錯誤類型對比 | 層級 | 觸發條件 | 回應格式 | 客戶端處理 | |------|---------|---------|------------| | **gRPC Error** | 參數格式錯誤、資源不存在、系統錯誤 | `status.Code != OK` | 檢查錯誤碼,修正參數或重試 | | **rejection_reason** | 業務規則拒絕 (金額/風險/狀態) | `status: REJECTED` + `rejection_reason` | 顯示拒絕原因給玩家 | ### gRPC Error Codes #### 輸入驗證錯誤 (INVALID_ARGUMENT) | Error Tag | 說明 | |-----------|------| | `INVALID_ROUND_ID` | round_id 無效或為空 | | `INVALID_PLAYER_ID` | player_id 無效或為空 | | `INVALID_SIDE` | side 未指定或無效 | | `INVALID_BET_SLUG` | bet_slug 無效或為空 | | `INVALID_AMOUNT` | amount 無效 (零或負數) | | `INVALID_WINNER` | winner 無效 | | `INVALID_PLAYER_CHOICE` | player_choice 無效 | | `INVALID_CONFIG` | RiskConfig 驗證失敗 (參見配置驗證) | | `INVALID_TIME_RANGE` | 時間範圍無效 (ListRounds) | | `INVALID_PAGE_TOKEN` | 分頁 token 無效 | #### 資源錯誤 | gRPC Code | Error Tag | 說明 | |-----------|-----------|------| | `NOT_FOUND` | `ROUND_NOT_FOUND` | 回合不存在 | | `NOT_FOUND` | `BET_NOT_FOUND` | 投注不存在 | | `ALREADY_EXISTS` | `ROUND_ALREADY_EXISTS` | 回合已存在 | #### 業務邏輯錯誤 (FAILED_PRECONDITION) | Error Tag | 說明 | |-----------|------| | `ROUND_NOT_ACTIVE` | 回合未啟用 (已結束或已取消) | | `ROUND_STOPPED` | 回合已暫停,不接受新投注 | | `ROUND_ALREADY_CANCELLED` | 回合已取消 | | `ROUND_ALREADY_SETTLED` | 回合已結算 | #### 權限與過期錯誤 | gRPC Code | Error Tag | 說明 | |-----------|-----------|------| | `PERMISSION_DENIED` | `PLAYER_MISMATCH` | ConfirmBet 時 player_id 不匹配 | | `DEADLINE_EXCEEDED` | `BET_EXPIRED` | 評估已過期 (EvaluateBet 後超過 30 秒) | #### 內部錯誤 (INTERNAL) | Error Tag | 說明 | |-----------|------| | `INTERNAL_ERROR` | 伺服器內部錯誤 | | `PERSISTENCE_FAILED` | 持久化失敗 | ### Rejection Reasons (業務拒絕原因) 當 EvaluateBet 返回 `status: REJECTED` 時,`rejection_reason` 欄位說明拒絕原因: | rejection_reason | 說明 | 建議處理 | |------------------|------|---------| | `amount_too_small` | 投注金額低於最小限額 | 顯示最小投注額,讓玩家調整金額 | | `amount_too_large` | 投注金額超過最大限額 | 顯示最大投注額,讓玩家調整金額 | | `exceeds_round_limit` | 玩家回合累積投注超過限額 | 顯示剩餘可投注額度 | | `odds_below_floor` | 計算後賠率低於地板值 | 告知玩家該方向賠率過低 | | `risk_capacity_exceeded` | 風險容量已耗盡 (MaxRoundLoss) | 告知玩家該回合暫停收單 | | `round_stopped` | 回合已暫停,不接受新投注 | 告知玩家投注時間已結束 | | `round_cancelled` | 回合已取消 | 告知玩家回合已取消 | | `buffer_risk_exceeded` | 批次風控:暫存風險過高 | 稍後重試 | | `too_many_pending_bets` | 背壓:待處理投注過多 | 稍後重試 | 當 ConfirmBet 返回 `status: FAILED` 時,`failure_reason` 欄位說明失敗原因: | failure_reason | 說明 | |----------------|------| | `round_stopped` | 確認時回合已暫停 | | `token_expired` | 超時未確認 (> 30 秒) | | `player_cancelled` | 玩家選擇取消 | ### 客戶端錯誤處理範例 ```go resp, err := client.EvaluateBet(ctx, req) if err != nil { // gRPC 層錯誤 st, _ := status.FromError(err) switch st.Code() { case codes.InvalidArgument: // 參數錯誤,修正後重試 log.Printf("參數錯誤: %s", st.Message()) case codes.NotFound: // 回合不存在 log.Printf("回合不存在: %s", req.RoundId) case codes.Internal: // 伺服器錯誤,可重試 log.Printf("伺服器錯誤,稍後重試") } return } // 檢查業務層拒絕 if resp.Status == pb.EvaluateBetResponse_REJECTED { // 業務規則拒絕,顯示原因給玩家 switch resp.RejectionReason { case "amount_too_small": showError("投注金額過低,請增加金額") case "amount_too_large": showError("投注金額過高,請減少金額") case "risk_capacity_exceeded": showError("該回合暫停收單,請稍後再試") case "round_stopped": showError("投注時間已結束") default: showError("投注被拒絕: " + resp.RejectionReason) } return } // 投注被接受,繼續確認流程 if resp.Status == pb.EvaluateBetResponse_ACCEPTED { // 直接確認 } else if resp.Status == pb.EvaluateBetResponse_NEEDS_CONFIRMATION { // 顯示調整後賠率給玩家確認 } ``` --- ## Usage Examples ### Go Client ```go import ( "context" pb "github.com/fatpit/risk-service/api/proto/risk/v1" "google.golang.org/grpc" ) conn, _ := grpc.Dial("localhost:50051", grpc.WithInsecure()) client := pb.NewRiskServiceClient(conn) // Start round resp, _ := client.StartRound(ctx, &pb.StartRoundRequest{ RoundId: "round-001", Config: &pb.RiskConfig{ MaxRoundLossCents: 10000000, // 100,000 元 MinBetAmountCents: 100000, // 1,000 元 MaxBetAmountCents: 34500000, // 345,000 元 PlayerRoundLimitCents: 138000000, // 1,380,000 元 OddsFloorBps: 3000, // 0.30 OddsCeilingBps: 9900, // 0.99 RakeRateBps: 450, // 4.5% InitialMeronOddsBps: 8000, // 0.80 (自定義開盤,0=預設 0.84) InitialWalaOddsBps: -9500, // -0.95 (自定義開盤,0=預設 -1.0) }, }) // Evaluate bet // 先查詢當前賠率顯示給玩家 oddsResp, _ := client.GetCurrentOdds(ctx, &pb.GetCurrentOddsRequest{RoundId: "round-001"}) evalResp, _ := client.EvaluateBet(ctx, &pb.EvaluateBetRequest{ RoundId: "round-001", PlayerId: "player-001", Side: pb.Side_SIDE_MERON, AmountCents: 1000000, // 10,000 元 BetSlug: "bet-abc-123", ExpectedOddsBps: oddsResp.MeronOddsBps, // 傳入顯示給玩家的賠率 }) // Confirm bet confirmResp, _ := client.ConfirmBet(ctx, &pb.ConfirmBetRequest{ RoundId: "round-001", BetSlug: evalResp.BetSlug, PlayerId: "player-001", PlayerChoice: pb.PlayerChoice_PLAYER_CHOICE_ACCEPT, AcceptedOddsBps: evalResp.OriginalOddsBps, }) // End round endResp, _ := client.EndRound(ctx, &pb.EndRoundRequest{ RoundId: "round-001", Winner: pb.Side_SIDE_MERON, }) ``` ### Ruby Client (Rails) ```ruby require 'risk/v1/risk_service_services_pb' stub = Risk::V1::RiskService::Stub.new('localhost:50051', :this_channel_is_insecure) # Start round resp = stub.start_round(Risk::V1::StartRoundRequest.new( round_id: 'round-001', config: Risk::V1::RiskConfig.new( max_round_loss_cents: 10_000_000, min_bet_amount_cents: 100_000, # 1,000 元 max_bet_amount_cents: 34_500_000, player_round_limit_cents: 138_000_000, # 1,380,000 元 odds_floor_bps: 3000, odds_ceiling_bps: 9900, rake_rate_bps: 450, initial_meron_odds_bps: 8000, # 自定義開盤 0.80 (0=預設 0.84) initial_wala_odds_bps: -9500 # 自定義開盤 -0.95 (0=預設 -1.0) ) )) # 先查詢賠率顯示給玩家 odds_resp = stub.get_current_odds(Risk::V1::GetCurrentOddsRequest.new(round_id: 'round-001')) # Evaluate bet (傳入顯示給玩家的賠率) eval_resp = stub.evaluate_bet(Risk::V1::EvaluateBetRequest.new( round_id: 'round-001', player_id: 'player-001', side: :SIDE_MERON, amount_cents: 1_000_000, bet_slug: 'bet-abc-123', expected_odds_bps: odds_resp.meron_odds_bps # 傳入顯示給玩家的賠率 )) # Convert bps to float for display odds = eval_resp.original_odds_bps / 10000.0 # => 0.78 ``` ### grpcurl (CLI) ```bash # Start round (with custom opening odds) grpcurl -plaintext -d '{ "round_id": "test-001", "config": { "max_round_loss_cents": 10000000, "rake_rate_bps": 450, "initial_meron_odds_bps": 8000, "initial_wala_odds_bps": -9500 } }' localhost:50051 risk.v1.RiskService/StartRound # Get current odds (顯示給玩家) grpcurl -plaintext -d '{"round_id": "test-001"}' \ localhost:50051 risk.v1.RiskService/GetCurrentOdds # Evaluate bet (帶入顯示給玩家的賠率) grpcurl -plaintext -d '{ "round_id": "test-001", "player_id": "player-001", "side": "SIDE_MERON", "amount_cents": 1000000, "bet_slug": "bet-abc-123", "expected_odds_bps": 8400 }' localhost:50051 risk.v1.RiskService/EvaluateBet # Get round state grpcurl -plaintext -d '{"round_id": "test-001"}' \ localhost:50051 risk.v1.RiskService/GetRoundState # End round grpcurl -plaintext -d '{"round_id": "test-001", "winner": "SIDE_MERON"}' \ localhost:50051 risk.v1.RiskService/EndRound # ==================== Stop Round (v2.2.0 新流程) ==================== # Stop round (永久結束下注,返回風險評估) grpcurl -plaintext -d '{"round_id": "test-001", "reason": "betting_closed"}' \ localhost:50051 risk.v1.RiskService/StopRound # 回應範例: # { # "stoppedAt": "1734566400", # "pendingBetsExpired": 2, # "provisionalRiskReleasedCents": "500000", # "confirmedBets": 15, # "meronBets": 8, # "walaBets": 7, # "meronPoolCents": "5000000", # "walaPoolCents": "3000000", # "meronNetIncomeCents": "225000", # "walaNetIncomeCents": "135000", # "currentMeronOddsBps": 7800, # "currentWalaOddsBps": -7800, # "ifMeronWinsProfitCents": "-1500000", # "ifWalaWinsProfitCents": "2000000", # "shouldCancel": false, # "isOneSided": false, # "meronWinsExceedsMaxLoss": false, # "walaWinsExceedsMaxLoss": false, # "maxExposureCents": "1500000", # "maxAllowedCents": "10000000", # "startedAt": "1734566000", # "minBetAmountCents": "100000", # "maxBetAmountCents": "34500000", # "rakeRateBps": 450, # "oddsFloorBps": 3000, # "oddsCeilingBps": 9900 # } # 若回應 shouldCancel=true,根據 cancelReason 決定取消 # grpcurl -plaintext -d '{"round_id": "test-001", "reason": "one_sided_betting"}' \ # localhost:50051 risk.v1.RiskService/CancelRound # ==================== History Query ==================== # Get round history (單一回合詳情) grpcurl -plaintext -d '{"round_id": "test-001"}' \ localhost:50051 risk.v1.RiskService/GetRoundHistory # List rounds (時間範圍查詢) grpcurl -plaintext -d '{ "start_time": 1734480000, "end_time": 1734566400, "status": "ROUND_STATUS_FILTER_SETTLED", "page_size": 20 }' localhost:50051 risk.v1.RiskService/ListRounds # List player bets (玩家投注歷史) grpcurl -plaintext -d '{ "player_id": "player-001", "page_size": 50 }' localhost:50051 risk.v1.RiskService/ListPlayerBets # List player bets with filters grpcurl -plaintext -d '{ "player_id": "player-001", "round_id": "test-001", "start_time": 1734480000, "end_time": 1734566400 }' localhost:50051 risk.v1.RiskService/ListPlayerBets ```