hyapp-server/docs/自研游戏机器人策略与档位奖池技术方案.md
2026-06-12 19:40:21 +08:00

846 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 自研游戏机器人策略与档位奖池技术方案
本文只描述《自研游戏机器人策略与档位奖池产品文档》里功能设计的技术实现。产品目标、功能原因和运营策略以产品文档为准。
## 技术结论
- 策略、奖池、结算和 `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. 告警。