# 自研游戏机器人策略与档位奖池技术方案

本文只描述《自研游戏机器人策略与档位奖池产品文档》里功能设计的技术实现。产品目标、功能原因和运营策略以产品文档为准。

## 技术结论

- 策略、奖池、结算和 `forced_result` 仍在 `game-service` 内实现。
- `robot-service` 只负责机器人账号池和机器人抽取，不参与胜负策略。
- 奖池从 `app_code + game_id` 升级为 `app_code + game_id + stake_coin`。
- 档位奖池必须支持余额、冻结额度、健康状态和黑色熔断。
- 机器人策略引擎只输出决策，不直接改钱包，不直接改数据库余额。
- 新用户保护按有效机器人局计数，平局不计数、不消耗补贴额度。
- 结算仍复用现有骰子点数和 RPS 手势反推机制。
- 所有策略决策、奖池变更和保护状态变更都必须幂等。

## 功能一：档位独立奖池技术实现

### 数据表

新增 `game_self_game_stake_pools`。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_stake_pools (
  app_code VARCHAR(32) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  stake_coin BIGINT NOT NULL,
  balance_coin BIGINT NOT NULL DEFAULT 0,
  reserved_coin BIGINT NOT NULL DEFAULT 0,
  target_balance_coin BIGINT NOT NULL DEFAULT 0,
  safe_balance_coin BIGINT NOT NULL DEFAULT 0,
  warning_balance_coin BIGINT NOT NULL DEFAULT 0,
  danger_balance_coin BIGINT NOT NULL DEFAULT 0,
  status VARCHAR(32) NOT NULL DEFAULT 'active',
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, game_id, stake_coin)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏档位奖池';
```

新增 `game_self_game_stake_pool_transactions`。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_stake_pool_transactions (
  app_code VARCHAR(32) NOT NULL,
  adjustment_id VARCHAR(96) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  stake_coin BIGINT NOT NULL,
  match_id VARCHAR(96) NOT NULL DEFAULT '',
  user_id BIGINT NOT NULL DEFAULT 0,
  direction VARCHAR(16) NOT NULL,
  amount_coin BIGINT NOT NULL,
  reserved_delta_coin BIGINT NOT NULL DEFAULT 0,
  reason VARCHAR(64) NOT NULL,
  balance_after BIGINT NOT NULL,
  reserved_after BIGINT NOT NULL DEFAULT 0,
  created_by BIGINT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, adjustment_id),
  KEY idx_self_game_stake_pool_tx_pool_time(app_code, game_id, stake_coin, created_at_ms),
  KEY idx_self_game_stake_pool_tx_match(app_code, match_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏档位奖池流水';
```

### 仓储能力

新增 repository 方法：

```go
GetStakePool(ctx, appCode, gameID string, stakeCoin int64) (StakePool, error)
ListStakePools(ctx, appCode, gameID string) ([]StakePool, error)
UpsertStakePoolConfig(ctx, pool StakePool, nowMS int64) (StakePool, error)
ReserveStakePool(ctx, reservation StakePoolReservation) (StakePoolReservation, error)
ReleaseStakePoolReservation(ctx, reservation StakePoolReservation) (StakePoolReservation, error)
AdjustStakePool(ctx, adjustment StakePoolAdjustment) (StakePoolAdjustment, error)
```

实现要求：

- `AdjustStakePool` 必须在事务内 `SELECT ... FOR UPDATE` 锁定单个档位奖池。
- `ReserveStakePool` 必须在机器人入座前冻结本局最大可能支出，防止并发局同时判断余额足够后扣穿奖池。
- `ReleaseStakePoolReservation` 必须在结算完成、取消或失败补偿时释放冻结额度。
- `direction=out` 时余额不能扣成负数。
- 同一个 `adjustment_id` 不能重复写流水。
- 同一个 `reservation_id` 不能重复冻结或重复释放。
- 对局结算写奖池流水后，要同步更新 match 的 `pool_delta_coin`。

### 兜底奖池账务评估

机器人局账务需要从现有 `fee_bps + pool_bps` 口径中单独评估。

目标口径：

- 用户赢机器人：用户收到派奖，平台扣点从派奖侧体现，兜底奖池承担用户净赢额。
- 用户输机器人：用户扣除积分进入当前档位兜底奖池，机器人赢局不再从兜底奖池额外抽平台扣点。
- 平局：不改用户积分，不改奖池，不计有效机器人局。
- 成熟期机器人局回到 50% 基础体感时，兜底奖池长期期望接近不流失。

技术评估项：

- 当前 `SettleByHighestScoreWithPool` 和 `SettleRPSByGestureWithPool` 是否把机器人赢局也按 `fee_bps/pool_bps` 扣点。
- 当前 `dicePoolDelta` 是否需要按 `match_mode=robot` 分出新账务公式。
- `game_orders` 中平台收入、用户派奖和奖池变化是否能对账。
- 历史局继续按旧口径解释，新策略上线后只影响新机器人局。

### 兼容迁移

旧 `game_self_game_pools` 不删除。上线迁移时新增档位奖池：

- 如果旧游戏奖池有余额，由运营按档位分配。
- 如果没有明确分配规则，默认只建档位奖池，余额为 0。
- 旧奖池只保留查询和回滚，不再作为新逻辑写入目标。

## 功能二：新用户独立保护技术实现

### 数据表

新增 `game_self_game_new_user_policies`。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_new_user_policies (
  app_code VARCHAR(32) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  enabled TINYINT(1) NOT NULL DEFAULT 1,
  protection_rounds INT NOT NULL DEFAULT 30,
  protection_hours INT NOT NULL DEFAULT 168,
  max_stake_coin BIGINT NOT NULL DEFAULT 5000,
  lifetime_subsidy_quota_coin BIGINT NOT NULL DEFAULT 30000,
  day1_subsidy_quota_coin BIGINT NOT NULL DEFAULT 15000,
  single_round_subsidy_cap_coin BIGINT NOT NULL DEFAULT 5000,
  strategy_level VARCHAR(32) NOT NULL DEFAULT 'soft_boost',
  lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1,
  lose_streak_trigger INT NOT NULL DEFAULT 2,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏新用户保护策略';
```

新增 `game_self_game_user_protection_states`。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_user_protection_states (
  app_code VARCHAR(32) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  user_id BIGINT NOT NULL,
  used_rounds INT NOT NULL DEFAULT 0,
  lifetime_subsidy_coin BIGINT NOT NULL DEFAULT 0,
  day1_subsidy_coin BIGINT NOT NULL DEFAULT 0,
  status VARCHAR(32) NOT NULL DEFAULT 'active',
  first_applied_at_ms BIGINT NOT NULL DEFAULT 0,
  last_applied_at_ms BIGINT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, game_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏用户新手保护状态';
```

新增保护消费幂等表。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_user_protection_events (
  app_code VARCHAR(32) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  match_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  used_round_delta INT NOT NULL DEFAULT 0,
  lifetime_subsidy_coin_delta BIGINT NOT NULL DEFAULT 0,
  day1_subsidy_coin_delta BIGINT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, game_id, match_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏新手保护消耗幂等事件';
```

### 用户阶段判断

新增 `UserStageResolver`：

```go
type UserStageResolver interface {
    ResolveSelfGameUserStage(ctx context.Context, appCode, gameID string, userID int64, nowMS int64) (UserStage, error)
}
```

第一阶段数据来源：

- user-service 返回注册时间。
- game-service 自己统计该用户自研游戏有效机器人局结算次数。
- protection state 返回已使用保护次数、生命周期补贴金额和首日补贴金额。

### 有效机器人局计数

有效机器人局必须满足：

- `match_mode=robot`。
- 对局已完成结算。
- 对局结果不是平局。
- 钱包和奖池账务已经成功。

不计入有效机器人局：

- 真人局。
- 平局。
- 取消局。
- 结算失败局。
- 钱包扣款或派奖失败局。

实现要求：

- `ConsumeProtectionIfNeeded` 只在有效机器人局后运行。
- 平局不插入 `game_self_game_user_protection_events`。
- 平局不增加 `used_rounds`。
- 平局不增加补贴消耗。

### 状态更新时机

新用户保护状态只在机器人局结算成功后更新。

不更新：

- 匹配取消。
- 平局。
- 钱包扣款失败。
- 钱包派奖失败。
- 对局失败。

实现：

- 在 `RollMatch` 完成钱包和奖池更新后调用 `ConsumeProtectionIfNeeded`。
- `ConsumeProtectionIfNeeded` 先插入 `game_self_game_user_protection_events`。
- 插入成功后再更新 `game_self_game_user_protection_states`。
- 如果事件已存在，直接返回，避免重复消费保护次数。
- 剩余生命周期额度、首日额度或单局额度不足时，策略引擎必须返回 `none` 决策并降级到正常随机，不能继续输出新用户保护决策。

## 功能三：成熟用户自然期目标胜率技术实现

### 数据表

不新增成熟用户消耗配置表。自然期目标胜率复用 `game_self_game_new_user_policies` 作为同一套机器人补位体验策略配置。

新增字段：

- `normal_phase_target_win_rate_percent`
- 默认值：53
- 范围：50-100
- 50：完全自然随机
- 大于 50：只补正用户赢，不输出用户强制输

线上如果已经存在旧 `game_self_game_consume_policies`，只保留数据库对象，不做读取、不做写入、不在 admin 暴露。

### 用户统计快照

保留查询：

```go
GetSelfGameUserStats(ctx, appCode, gameID string, userID int64, nowMS int64) (UserGameStats, error)
```

字段：

- 今日机器人局数。
- 今日净盈亏。
- 近 7 日净盈亏。
- 连赢次数。
- 连输次数。
- 今日新手保护干预次数。
- 最近一次策略干预时间。

统计快照用于新手连输保护、日志解释和后续看板；成熟用户阶段不使用这些统计去强制用户输。

### 策略输出

成熟用户策略只输出：

- `none`
- `POOL_RED_NORMAL_RANDOM`
- `NORMAL_PHASE_TARGET_BASELINE`
- `NORMAL_PHASE_TARGET_MISS`
- `NORMAL_PHASE_TARGET_HIT`

不输出 `force_human_lose`。

当配置值大于 50 且随机命中目标补正时，输出 `force_human_win`，但 `forced_result` 使用不消耗新手保护额度的内部值，结算后不写入新手保护消耗事件。

换算规则：

- dice 无平局：补正概率 = `2 * target - 100`。例如目标 53%，补正概率约 6%。
- rock 有平局且平局不计有效机器人局：补正概率 = `(2 * target - 100) / (200 - target)`。例如目标 53%，补正概率约 4.1%。
- 红色水位仍回到完全自然随机。
- 黑色水位仍不抽机器人。

说明：

- dice 继续按自然随机点数结算。
- rock 继续按自然随机机器人手势结算，平局退款。
- 红色水位只取消保护加成，不改写成用户必输。
- 黑色水位在奖池保护层熔断机器人补位。

## 功能四：奖池水位保护技术实现

### 水位判断

新增：

```go
type PoolLevel string

const (
    PoolLevelGreen PoolLevel = "green"
    PoolLevelYellow PoolLevel = "yellow"
    PoolLevelRed PoolLevel = "red"
    PoolLevelBlack PoolLevel = "black"
)
```

判断规则：

```text
available_coin = balance_coin - reserved_coin
available_coin < required_pool_coin           -> black
balance_coin <= danger_balance_coin           -> red
balance_coin <= warning_balance_coin          -> yellow
balance_coin >= safe_balance_coin             -> green
其它                                          -> yellow
```

### 策略行为

- `black`：暂停该档机器人匹配，或只允许真人匹配。
- `red`：回到 50% 基础体感，不能输出新用户保护加成。
- `yellow`：按奖池健康度缩小保护强度，新用户强正反馈降级为轻度正反馈。
- `green`：允许执行配置策略。

### 资金校验

真人赢之前必须计算 `required_pool_coin`：

```text
required_pool_coin = payout_coin - human_stake_coin
```

如果当前档位奖池余额小于 `required_pool_coin`，直接进入 `pool_guard`，输出 `none` 并熔断机器人补位。

### 黑色熔断

黑色状态处理：

- 当前档位不再触发机器人补位。
- 已经冻结的机器人局继续按已写入策略完成结算。
- 新进入的用户只等待真人匹配，或提示当前档位匹配繁忙。
- 后台必须展示黑色状态和触发原因。

实现要求：

- `attachRobotIfDue` 调用策略引擎前先判断当前档位是否黑色。
- 黑色时不调用 `robot-service` 抽机器人，避免抽到机器人后又无法入座。
- 黑色状态恢复到红色以上后，才允许重新开启该档机器人补位。

## 功能五：风控准入技术实现

### 数据表

新增 `game_self_game_risk_rules`。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_risk_rules (
  app_code VARCHAR(32) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  enabled TINYINT(1) NOT NULL DEFAULT 1,
  device_user_limit INT NOT NULL DEFAULT 3,
  ip_user_limit INT NOT NULL DEFAULT 5,
  protection_profit_limit_coin BIGINT NOT NULL DEFAULT 0,
  high_stake_after_protection_limit_ms BIGINT NOT NULL DEFAULT 3600000,
  full_protection_max_score INT NOT NULL DEFAULT 29,
  reduced_protection_max_score INT NOT NULL DEFAULT 49,
  weak_protection_max_score INT NOT NULL DEFAULT 69,
  disable_protection_min_score INT NOT NULL DEFAULT 70,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人策略风控规则';
```

### 风控接口

新增内部接口：

```go
type RiskEvaluator interface {
    EvaluateSelfGameRobotRisk(ctx context.Context, input RiskInput) (RiskResult, error)
}
```

第一阶段本地判断：

- protection state 中补贴收益是否超限。
- strategy logs 中保护命中是否异常。
- 用户是否保护期低档获利后快速进入高档。

第二阶段接 user-service 或风控服务：

- 同设备账号数。
- 同 IP 账号数。
- 黑名单。

### 风控结果

```go
type RiskResult struct {
    RiskScore int32
    RiskTier string
    BlockProtection bool
    BlockReason string
    ReasonJSON string
}
```

只阻断保护，不阻断正常游戏。

### 风控分层

```text
0-29   -> normal，完整保护
30-49  -> reduced，降低保护强度，只保留低档位保护
50-69  -> weak，只保留 1000 档弱保护，关闭连输保护
70+    -> blocked，关闭全部新用户保护
```

实现要求：

- 风控分层结果写入策略日志 `reason_json`。
- 新用户保护策略根据 `risk_tier` 降级，而不是只看 `BlockProtection`。
- 高风险只关闭保护，不阻止用户正常进入游戏。

## 功能六：全量策略日志技术实现

### 数据表

新增 `game_self_game_strategy_logs`。

```sql
CREATE TABLE IF NOT EXISTS game_self_game_strategy_logs (
  app_code VARCHAR(32) NOT NULL,
  log_id VARCHAR(96) NOT NULL,
  game_id VARCHAR(64) NOT NULL,
  stake_coin BIGINT NOT NULL,
  match_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  strategy_code VARCHAR(64) NOT NULL,
  decision VARCHAR(64) NOT NULL,
  force_result VARCHAR(32) NOT NULL DEFAULT '',
  reason_code VARCHAR(64) NOT NULL,
  reason_json JSON NULL,
  pool_balance_coin BIGINT NOT NULL DEFAULT 0,
  pool_level VARCHAR(32) NOT NULL DEFAULT '',
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, log_id),
  UNIQUE KEY uk_self_game_strategy_match_user(app_code, match_id, user_id),
  KEY idx_self_game_strategy_user_time(app_code, user_id, created_at_ms),
  KEY idx_self_game_strategy_game_time(app_code, game_id, stake_coin, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人策略决策日志';
```

### 写入时机

在机器人入座前写策略日志。

原因：

- 机器人入座时已经确定 `forced_result`。
- 后续结算失败也能查到当时为什么做这个决策。

### 日志内容

`reason_json` 必须包含：

- `user_stage`
- `risk_result`
- `risk_score`
- `risk_tier`
- `pool_level`
- `pool_balance_coin`
- `pool_reserved_coin`
- `required_pool_coin`
- `effective_bot_game_count`
- `lifetime_subsidy_left`
- `day1_subsidy_left`
- `new_user_policy_snapshot`
- `user_stats_snapshot`

## 策略引擎

### 输入

```go
type RobotStrategyInput struct {
    AppCode string
    GameID string
    MatchID string
    UserID int64
    StakeCoin int64
    NowMS int64
    UserStage UserStage
    EffectiveBotGameCount int32
    RecentStats UserGameStats
    StakePool StakePoolSnapshot
    NewUserPolicy NewUserPolicy
    RiskResult RiskResult
}
```

### 输出

```go
type RobotStrategyDecision struct {
    StrategyCode string
    Decision string
    ReasonCode string
    ReasonJSON string
    ForceResult string
    RequiredPoolCoin int64
    ReservationID string
    ShouldConsumeProtection bool
}
```

### 决策顺序

```text
pool_guard
  -> risk_block
  -> new_user_protection
  -> new_user_lose_streak_protection
  -> normal_random
```

实现要求：

- 每个分支都返回明确 `strategy_code` 和 `reason_code`。
- 每个分支都必须可单测。
- `50%`、`70%` 这类绝对胜率不再作为主配置。
- 平局必须返回不消耗有效机器人局和不消耗补贴额度的原因码。
- 当补贴额度不足时，必须返回 `NO_USER_QUOTA` 类原因码并降级为正常随机。
- 新手连输保护的 `LoseStreak` 必须来自同一自研游戏下真人局和机器人局的已结算非平局结果；平局不打断也不累计。
- 新手连输保护命中后只在机器人补位局输出 `force_human_win`，不能回写或改写真人对战结果。

## 现有链路改造点

### attachRobotIfDue

旧链路在机器人入座时直接判断奖池、新用户首胜和固定胜负控制。改造后：

```text
读取 match
读取 config
读取 stake pool
判断黑色熔断
抽机器人
读取 user stage
读取 risk result
读取 new user policy
读取 consume policy
调用 strategy engine
冻结 stake pool reservation
写 strategy log
按 decision 设置 forced_result
JoinDiceRobot
```

### RollMatch

当前结算后调用奖池更新。改造后：

```text
按现有逻辑扣款、结算、派奖
按 stake_coin 更新档位奖池
释放 stake pool reservation
如果策略命中新用户保护，结算成功后消费保护状态
MarkDiceMatchSettled
```

失败处理：

- 结算失败时释放冻结额度。
- 钱包扣款失败时释放冻结额度。
- 用户取消或匹配过期时释放冻结额度。
- 释放失败时写补偿任务或人工对账队列。

### dicePoolDelta

输入不变，输出增加 `stake_coin`。

```go
func dicePoolDelta(match dicedomain.Match) (direction string, amount int64, userID int64, stakeCoin int64)
```

原因：奖池流水必须写入具体档位。

## Admin 后台技术实现

### 接口

#### 档位奖池列表

- 地址：`GET /admin/self-games/:game_id/stake-pools`
- 参数：
  - `game_id`
- 返回值：
  - `items[].stakeCoin`
  - `items[].balanceCoin`
  - `items[].reservedCoin`
  - `items[].availableCoin`
  - `items[].targetBalanceCoin`
  - `items[].safeBalanceCoin`
  - `items[].warningBalanceCoin`
  - `items[].dangerBalanceCoin`
  - `items[].poolLevel`
  - `items[].recentInCoin`
  - `items[].recentOutCoin`
  - `items[].recentNetCoin`
  - `items[].humanWinCapacity`
  - `items[].botMatchEnabled`
- 相关 IM：无

#### 档位奖池配置

- 地址：`PUT /admin/self-games/:game_id/stake-pools/:stake_coin`
- 参数：
  - `targetBalanceCoin`
  - `safeBalanceCoin`
  - `warningBalanceCoin`
  - `dangerBalanceCoin`
  - `status`
- 返回值：更新后的档位奖池
- 相关 IM：无

#### 档位奖池加扣

- 地址：`POST /admin/self-games/:game_id/stake-pools/:stake_coin/adjust`
- 参数：
  - `direction`
  - `amountCoin`
  - `reason`
- 返回值：
  - `adjustmentId`
  - `balanceAfter`
  - `reservedAfter`
- 相关 IM：无

#### 新用户保护配置

- 地址：`GET /v1/admin/game/self-games/:game_id/new-user-policy`
- 参数：无
- 返回值：当前配置
- 相关 IM：无

- 地址：`PATCH /v1/admin/game/self-games/:game_id/new-user-policy`
- 参数：
  - `enabled`
  - `protectionRounds`
  - `protectionHours`
  - `maxStakeCoin`
  - `lifetimeSubsidyQuotaCoin`
  - `day1SubsidyQuotaCoin`
  - `singleRoundSubsidyCapCoin`
  - `strategyLevel`
  - `loseStreakProtectionEnabled`
  - `loseStreakTrigger`
- 返回值：更新后的配置
- 相关 IM：无

#### 风控规则配置

- 地址：`GET /admin/self-games/:game_id/risk-rules`
- 参数：无
- 返回值：当前配置
- 相关 IM：无

- 地址：`PUT /admin/self-games/:game_id/risk-rules`
- 参数：
  - `enabled`
  - `deviceUserLimit`
  - `ipUserLimit`
  - `protectionProfitLimitCoin`
  - `highStakeAfterProtectionLimitMs`
  - `fullProtectionMaxScore`
  - `reducedProtectionMaxScore`
  - `weakProtectionMaxScore`
  - `disableProtectionMinScore`
- 返回值：更新后的配置
- 相关 IM：无

#### 策略日志查询

- 地址：`GET /admin/self-games/strategy-logs`
- 参数：
  - `gameId`
  - `stakeCoin`
  - `userId`
  - `matchId`
  - `strategyCode`
  - `decision`
  - `riskTier`
  - `poolLevel`
  - `startTimeMs`
  - `endTimeMs`
  - `pageSize`
  - `cursor`
- 返回值：
  - `items`
  - `nextCursor`
- 相关 IM：无

### 前端页面

后台前端按产品文档拆成 6 个页签：

- 基础配置。
- 档位奖池。
- 新用户保护。
- 成熟用户观察。
- 风控规则。
- 策略日志。

保存配置时校验：

- 水位阈值必须满足 `danger(red) <= warning(yellow) <= safe(green)`。
- `targetBalanceCoin` 必须大于等于 `safeBalanceCoin`，否则健康度比例没有参考值。
- 新用户最大保护档位必须存在于下注档位。
- 生命周期补贴额度、首日补贴额度、单局保护上限不能为负数。
- 单局保护上限不能大于新用户最大保护档位。
- 风险评分阈值必须满足 `fullProtectionMaxScore < reducedProtectionMaxScore < weakProtectionMaxScore < disableProtectionMinScore`。
- 金额不能为负数。

## 灰度步骤

### 第一阶段：shadow 日志

- 建表。
- 接入策略引擎。
- 只计算决策，不执行 `forced_result`。
- 全量写策略日志。

验证：

- 日志覆盖所有机器人局。
- 策略命中分布符合预期。

### 第二阶段：档位奖池切换

- 新结算只写 `game_self_game_stake_pools`。
- 旧 `game_self_game_pools` 不再写入。
- 后台奖池加扣只作用于档位奖池。

验证：

- 每档余额等于初始余额加流水净额。
- 并发扣减不会出现负数。
- 黑色状态时不再抽机器人补位。
- 冻结额度能在结算、取消和失败补偿后释放。

### 第三阶段：新用户保护灰度

- 只开低档位。
- 只开部分 App 或部分用户。
- 观察保护成本和复玩。

验证：

- 新用户策略不读取成熟用户正常随机逻辑。
- 命中风控的用户不消耗保护。
- 平局不计有效机器人局。
- 平局不消耗新用户补贴额度。
- 剩余额度不足时降级为正常随机。
- 成熟用户连赢或净赢后仍然不输出强制输。

## 测试计划

### Go 单测

- 风控命中时不进入新用户保护。
- 奖池黑色状态时不抽机器人。
- 奖池红色水位优先于新用户保护。
- 新用户保护不读取成熟用户正常随机逻辑。
- 成熟用户连赢、净赢和高频下注后不输出强制输。
- 自然期目标胜率 50 时不输出强制结果。
- 自然期目标胜率大于 50 命中时输出不消耗新手额度的用户赢。
- 平局不计有效机器人局。
- 平局不消耗补贴额度。
- 生命周期额度、首日额度、单局额度不足时降级为正常随机。
- 风险评分分层能正确降级保护。
- 新手连输保护命中后输出 `force_human_win`。
- 非新手保护场景不输出 `force_human_lose`。
- `force_human_win`、`none` 输出稳定。
- 策略日志 reason 完整。

### MySQL 单测

- 档位奖池并发扣减不能扣成负数。
- 档位奖池并发冻结不能超过可用余额。
- 结算成功后冻结额度释放。
- 取消或失败后冻结额度释放。
- 同一个 `adjustment_id` 不重复入流水。
- 同一局重复结算不重复扣奖池。
- 同一局重复结算不重复消耗新手保护次数。
- 平局不插入保护消费事件。
- 策略日志唯一键生效。

### Admin 测试

- 档位奖池列表展示水位和可承受次数。
- 档位奖池列表展示冻结额度、可用余额和黑色熔断状态。
- 档位奖池加扣成功后余额和流水一致。
- 新用户保护配置校验生命周期额度、首日额度、单局保护上限。
- 风控规则配置校验 risk_score 阈值递增。
- 策略日志分页查询正常。

### 产品验收校验

- 3 秒内开局率可按小时、游戏和档位统计。
- 100000 档新手保护命中率必须为 0。
- 40000 档强保护命中率必须为 0。
- 高风险用户保护命中率必须为 0。
- 日终奖池账务差异必须为 0。
- 50% 基础体感机器人局下，兜底奖池净变化需要接近 0，若不接近必须回查账务公式。
- 自然期默认 53% 下，用户长期净变化应接近抽水后的体验目标，且新手补贴消耗不增加。

## 需要保留的兼容点

- 旧 `game_self_game_pools` 不删除。
- 旧 `game_self_game_pool_transactions` 不删除。
- 旧 `robot_user_win_rate_percent` 不再做任何 server/admin/API 兼容；线上库已有遗留列不删除，但新代码不读写、不暴露。
- 已结算历史局不回算档位奖池。
- 历史局仍按原 `pool_delta_coin` 解释。

## 实施顺序

1. 建表和仓储。
2. 策略引擎 shadow 模式。
3. 策略日志后台查询。
4. 档位奖池后台列表和加扣。
5. 档位奖池切换结算。
6. 新用户保护灰度。
7. 风控准入接入。
8. 成熟用户观察看板。
9. 告警。
