# 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
```