# SuperSorry system_coin 字段不一致案例研究

**发现日期**: 2026-03-17
**游戏**: SuperSorry（超级跪）
**问题类型**: 服务端埋点 - 数据不一致
**严重程度**: Critical
**状态**: 已修复

---

## 执行摘要

### 问题描述

SuperSorry 金币场**平局**场景下，`system_coin` 字段上报不一致：
- **应该是**: 0（平局全额返还，平台不抽水）
- **实际情况**: 有的是 0，有的是 5

导致数据分析时无法准确计算平台收入。

### 用户反馈

> "平局的时候我看 system_coin 有的是打 0 有的是打 5 怎么回事？"

### 根本原因

**双路径问题**：

1. **正常路径**（Bug）：
   ```go
   systemCoin := int32(supersorry.CoinMatchTicket)  // 硬编码 5
   // 平局时未修改 → systemCoin = 5 ❌
   ```

2. **异常路径**（偶然正确）：
   ```go
   // RPC 失败返回 nil
   // fsm_handler.go 中 systemCoin 默认为 0
   systemCoin := int32(0)  // 默认值 = 0 ✅
   ```

**结果**：
- 正常流程 → system_coin = 5 ❌
- 异常流程 → system_coin = 0 ✅（偶然正确）

---

## 案例价值

### 核心教训

1. **异常路径也要测试** - 不能只测正常流程
2. **偶然正确是危险的** - Bug 被异常掩盖，更难发现
3. **硬编码 + 条件分支 = 高风险** - 容易遗漏某些分支

### 典型反模式

**"偶然正确"反模式**：
- Bug 存在，但在某些情况下结果偶然正确
- 测试时可能通过，但实际上逻辑错误
- 修复一个问题可能暴露另一个问题

---

## 技术细节

### SuperSorry 金币经济模型

```
入场费: 10 金币
胜利奖励: 15 金币
平台抽水: 5 金币

结算逻辑:
- 胜利: 获得 15 金币，净收益 = 15 - 10 = +5，平台抽水 5
- 失败: 获得 0 金币，净收益 = 0 - 10 = -10，平台抽水 10
- 平局: 获得 10 金币（全额返还），净收益 = 10 - 10 = 0，平台抽水 0
```

**system_coin 定义**：
- 平台实际收入/抽水金额
- 平局时应该是 0（全额返还，平台无收入）

---

### 代码分析

#### 修复前的代码（Bug）

```go
// wegame/app/exosystem/service/supersorry_rpc.go:59-109
func (s *Service) RpcSuperSorryCheckout(in *exosystemRpc.SuperSorryCheckoutIn) (*exosystemRpc.SuperSorryCheckoutOut, error) {
    // ... 其他逻辑 ...

    for i := range in.Players {
        player := &in.Players[i]

        var giveCoin int32
        systemCoin := int32(supersorry.CoinMatchTicket)  // ❌ 硬编码为 5
        isCoinMatch := isCoinMode && player.PreDeduct > 0

        if isCoinMatch {
            var reasonStr string
            if in.WinnerUid == 0 {
                // 平局
                giveCoin = player.PreDeduct
                // ❌ 没有设置 systemCoin = 0
                reasonStr = "金币场结算_平局返还"
            } else if player.ForceQuit {
                // 强退
                giveCoin = 0
                reasonStr = "金币场结算_强退不返还"
            } else if player.Uid == in.WinnerUid {
                // 胜利
                giveCoin = supersorry.CoinMatchWinReward
                reasonStr = "金币场结算_胜利奖金"
            } else {
                // 失败
                giveCoin = 0
                reasonStr = "金币场结算_败方不返还"
            }

            // ... 发放金币 ...
        }

        out.PlayerResults = append(out.PlayerResults, exosystemRpc.SuperSorryCheckoutPlayerResult{
            Uid:        player.Uid,
            SystemCoin: systemCoin,  // ❌ 平局时仍然是 5
            // ... 其他字段 ...
        })
    }

    return out, nil
}
```

**问题**：
- `systemCoin` 默认值 5
- 平局分支中没有修改 `systemCoin`
- 结果：平局时 `systemCoin = 5`（错误）

---

#### 修复后的代码

```go
// wegame/app/exosystem/service/supersorry_rpc.go:59-109 (修复后)
func (s *Service) RpcSuperSorryCheckout(in *exosystemRpc.SuperSorryCheckoutIn) (*exosystemRpc.SuperSorryCheckoutOut, error) {
    // ... 其他逻辑 ...

    for i := range in.Players {
        player := &in.Players[i]

        var giveCoin int32
        systemCoin := int32(supersorry.CoinMatchTicket)  // 默认 5
        isCoinMatch := isCoinMode && player.PreDeduct > 0

        if isCoinMatch {
            var reasonStr string
            if in.WinnerUid == 0 {
                // 平局
                giveCoin = player.PreDeduct
                systemCoin = 0  // ✅ 修复：平局时抽水为 0
                reasonStr = "金币场结算_平局返还"
            } else if player.ForceQuit {
                // 强退
                giveCoin = 0
                reasonStr = "金币场结算_强退不返还"
            } else if player.Uid == in.WinnerUid {
                // 胜利
                giveCoin = supersorry.CoinMatchWinReward
                reasonStr = "金币场结算_胜利奖金"
            } else {
                // 失败
                giveCoin = 0
                reasonStr = "金币场结算_败方不返还"
            }

            // ... 发放金币 ...
        }

        out.PlayerResults = append(out.PlayerResults, exosystemRpc.SuperSorryCheckoutPlayerResult{
            Uid:        player.Uid,
            SystemCoin: systemCoin,  // ✅ 平局时是 0
            // ... 其他字段 ...
        })
    }

    return out, nil
}
```

**修复提交**: commit 76d0a2c503

---

### 为什么会不一致

#### 路径 1：正常流程（Bug）

```
玩家A 和 玩家B 平局
  ↓
game 层调用 exosystem RPC
  ↓
supersorry_rpc.go:RpcSuperSorryCheckout
  ↓
systemCoin = 5 (默认值)
  ↓
if in.WinnerUid == 0 (平局分支)
  giveCoin = player.PreDeduct
  // ❌ 没有设置 systemCoin = 0
  ↓
返回 PlayerResults with SystemCoin = 5
  ↓
game 层上报埋点
  ↓
EndGame 事件 with system_coin = 5 ❌
```

---

#### 路径 2：异常流程（偶然正确）

```
玩家A 和 玩家B 平局
  ↓
game 层调用 exosystem RPC
  ↓
❌ RPC 调用失败（网络超时/服务重启/其他异常）
  ↓
sideeffect.go:exoCheckout() 返回 nil
  ↓
fsm_handler.go:L224-250
  var systemCoin int32  // 默认值 = 0
  if cr, ok := rawResultMap[player.Uid]; ok {
      systemCoin = cr.SystemCoin  // ❌ 但 cr 不存在
  }
  // systemCoin 保持默认值 0
  ↓
game 层上报埋点
  ↓
EndGame 事件 with system_coin = 0 ✅（偶然正确）
```

**关键代码**：

```go
// wegame/app/game/supersorry/game/sideeffect.go:61-86
func (g *SuperSorryGame) exoCheckout() []exosystem.SuperSorryCheckoutPlayerResult {
    // ... 构造参数 ...
    out, err := exosystem.SuperSorryCheckout(int64(g.Rid), g.Winner, g.GameMode, players)
    if err != nil {
        logger.Error(g.Rid, 0, consts.GameLogicTag, "Checkout RPC 失败:", err)
        return nil  // ❌ 返回 nil
    }
    return out.PlayerResults
}

// wegame/app/game/supersorry/game/fsm_handler.go:224-250
var enterCoin, coin, theoryCoin, systemCoin int32  // systemCoin 默认 0
if cr, ok := rawResultMap[player.Uid]; ok {
    enterCoin = cr.EnterCoin
    coin = cr.Coin
    theoryCoin = cr.TheoryCoin
    systemCoin = cr.SystemCoin  // 如果 cr 不存在，systemCoin 保持 0
}

supersorryHttp.EndGame(player.Uid, supersorryHttp.EndGameParam{
    SystemCoin: systemCoin,  // 使用默认值 0
    // ...
})
```

---

### 为什么"偶然正确"很危险

#### 1. Bug 被掩盖

- 正常流程有 bug（systemCoin = 5）
- 异常流程偶然正确（systemCoin = 0）
- 测试时如果遇到异常，反而会通过

#### 2. 难以发现

- 数据不一致才暴露问题
- 单次测试可能看不出来
- 需要大量数据才能发现"有的 0 有的 5"

#### 3. 修复一个问题可能暴露另一个

- 如果先修复 RPC 失败处理（不返回 nil）
- 反而会让所有平局都变成 systemCoin = 5
- Bug 变得更严重

#### 4. 逻辑依赖脆弱

- 正确性依赖"RPC 偶尔失败"
- 如果 RPC 稳定性提升，bug 反而会更频繁出现

---

## 测试盲区分析

### 为什么测试没发现

#### 问题 1：只测了正常流程

**测试场景**：
```
玩家A vs 玩家B，平局
→ 查看 system_coin = ?
```

**可能结果**：
- 运气好：RPC 失败 → system_coin = 0 → 测试通过 ✅（偶然）
- 运气不好：RPC 成功 → system_coin = 5 → 发现 bug ❌

**问题**：
- 单次测试不可靠
- 依赖运气

#### 问题 2：没有测试数据一致性

**应该测试**：
- 执行 10 次平局场景
- 检查 10 次 system_coin 是否都是 0
- 不一致 → 发现问题

**实际测试**：
- 只测 1 次
- 值是 0 → 认为正确
- 没有验证一致性

#### 问题 3：没有测试异常路径

**应该测试**：
- 正常流程：RPC 成功
- 异常流程：RPC 失败
- 两种情况下 system_coin 应该都是 0

**实际测试**：
- 只测正常流程
- 或者只测异常流程
- 没有对比

---

## 完整测试矩阵

### 正确的测试方法

| 场景 | RPC 状态 | 游戏结果 | system_coin 预期 | system_coin 实际 | 状态 |
|------|---------|---------|-----------------|-----------------|------|
| 场景1 | 成功 | 胜利 | 5 | 5 | ✅ |
| 场景2 | 成功 | 失败 | 10 | 10 | ✅ |
| 场景3 | 成功 | 平局 | 0 | 5 | ❌ Bug |
| 场景4 | 失败 | 胜利 | 0 (降级) | 0 | ✅ |
| 场景5 | 失败 | 失败 | 0 (降级) | 0 | ✅ |
| 场景6 | 失败 | 平局 | 0 (降级) | 0 | ✅ 偶然 |

**发现**：
- 只有场景3 有 bug
- 场景6 偶然正确（但逻辑错误）

### 一致性测试

```bash
# 重复测试平局场景 10 次
for i in {1..10}; do
    # 触发平局
    play_game_until_draw

    # 查询 system_coin
    system_coin=$(query_tracking_data "system_coin")
    echo "Test $i: system_coin = $system_coin"
done

# 预期结果：10 次都是 0
# 实际结果（修复前）：有些是 0，有些是 5
```

---

## 修复验证

### 验证方法

#### 1. 单元测试

```go
func TestSystemCoinInDrawScenario(t *testing.T) {
    // 构造平局场景
    in := &exosystemRpc.SuperSorryCheckoutIn{
        WinnerUid: 0,  // 平局
        GameMode:  2,  // 金币场
        Players: []exosystemRpc.SuperSorryCheckoutPlayer{
            {Uid: 1001, PreDeduct: 10},
            {Uid: 1002, PreDeduct: 10},
        },
    }

    out, err := service.RpcSuperSorryCheckout(in)
    require.NoError(t, err)

    // 验证每个玩家的 SystemCoin 都是 0
    for _, result := range out.PlayerResults {
        assert.Equal(t, int32(0), result.SystemCoin,
            "平局时 SystemCoin 应该是 0")
    }
}
```

#### 2. 集成测试

```bash
# 执行 10 次平局测试
./test_draw_scenario.sh --repeat=10

# 检查所有 system_coin 都是 0
SELECT DISTINCT system_coin
FROM tracking_events
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND game_result = 0  -- 平局
  AND date = '2026-03-17';

-- 预期结果：只有一行，值为 0
```

#### 3. 数据验证

```sql
-- 修复前的数据分布
SELECT system_coin, COUNT(*)
FROM tracking_events
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND game_result = 0  -- 平局
  AND date < '2026-03-17'
GROUP BY system_coin;

-- 结果：
-- system_coin | count
-- -----------+-------
--     0      |  234   -- 异常流程
--     5      |  456   -- 正常流程（Bug）

-- 修复后的数据
SELECT system_coin, COUNT(*)
FROM tracking_events
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND game_result = 0  -- 平局
  AND date >= '2026-03-17'
GROUP BY system_coin;

-- 结果：
-- system_coin | count
-- -----------+-------
--     0      |  100%  -- 全部正确
```

---

## 经验教训

### 教训 1：测试要验证一致性

**错误做法**：
```
测试1次 → 值正确 → 认为没问题
```

**正确做法**：
```
测试10次 → 所有值都一致 → 才能确认没问题
```

**原因**：
- 不一致本身就是 bug 的信号
- 单次测试可能遇到偶然正确的情况

---

### 教训 2：异常路径也要测试

**应该测试的路径**：
- ✅ 正常流程
- ✅ 异常流程（RPC 失败、网络超时、服务重启等）
- ✅ 降级逻辑
- ✅ 边界条件

**不能只测试**：
- ❌ 快乐路径（Happy Path）

---

### 教训 3：默认值要谨慎

**危险的模式**：
```go
value := defaultValue  // 设置默认值
if condition1 {
    value = ...
} else if condition2 {
    value = ...
} else if condition3 {
    // ❌ 忘记设置 value
}
```

**更安全的模式**：
```go
var value type  // 零值
switch {
case condition1:
    value = ...
case condition2:
    value = ...
case condition3:
    value = ...  // ✅ 强制设置
default:
    panic("unhandled case")  // ✅ 防止遗漏
}
```

---

### 教训 4：偶然正确比明显错误更危险

**明显错误**：
- 容易发现
- 快速修复

**偶然正确**：
- 难以发现（数据不一致）
- 逻辑错误（但结果对）
- 修复其他问题可能暴露此问题

**预防方法**：
- 测试数据一致性
- 测试异常路径
- Code Review 检查所有分支

---

## 数据修复

### 识别错误数据

```sql
-- 查询修复前的错误数据
SELECT
    uid,
    rid,
    game_result,
    system_coin,
    created_at
FROM tracking_events
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND game_result = 0  -- 平局
  AND system_coin = 5  -- 错误的值
  AND created_at < '2026-03-17'  -- 修复前
ORDER BY created_at DESC;
```

### 修正数据

```sql
-- 修正平局场景的 system_coin
UPDATE tracking_events
SET
    system_coin = 0,  -- 修正为 0
    is_corrected = true,  -- 标记已修正
    corrected_at = NOW(),
    correction_reason = 'Bug修复: 平局时system_coin应为0'
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND game_result = 0  -- 平局
  AND system_coin = 5  -- 错误的值
  AND created_at < '2026-03-17';  -- 修复前

-- 验证修正结果
SELECT COUNT(*) as corrected_count
FROM tracking_events
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND is_corrected = true;
```

---

## 预防措施

### 1. Code Review 检查清单

在审查结算代码时，必须检查：

- [ ] 是否有硬编码的默认值？
- [ ] 每个分支是否都正确设置了所有字段？
- [ ] 平局/强退等特殊场景是否有遗漏？
- [ ] 是否有"声明变量但某些分支没赋值"的情况？

### 2. 单元测试覆盖所有分支

```go
func TestSystemCoinAllScenarios(t *testing.T) {
    testCases := []struct {
        name           string
        winnerUid      int32
        playerUid      int32
        forceQuit      bool
        expectedCoin   int32
        expectedSystem int32
    }{
        {"胜利", 1001, 1001, false, 5, 5},
        {"失败", 1002, 1001, false, -10, 10},
        {"平局", 0, 1001, false, 0, 0},     // ← 必须测试
        {"强退", 1002, 1001, true, -10, 10},
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // 执行测试
            result := checkout(tc.winnerUid, tc.playerUid, tc.forceQuit)
            assert.Equal(t, tc.expectedSystem, result.SystemCoin)
        })
    }
}
```

### 3. 一致性测试

```go
func TestSystemCoinConsistency(t *testing.T) {
    // 重复测试 10 次
    var results []int32
    for i := 0; i < 10; i++ {
        result := checkout(0, 1001, false)  // 平局
        results = append(results, result.SystemCoin)
    }

    // 验证所有结果都一致
    for _, r := range results {
        assert.Equal(t, results[0], r, "所有结果应该一致")
    }

    // 验证值正确
    assert.Equal(t, int32(0), results[0], "平局时 SystemCoin 应该是 0")
}
```

### 4. 数据监控告警

```sql
-- 监控查询：检测 system_coin 不一致
SELECT
    game_result,
    COUNT(DISTINCT system_coin) as distinct_values,
    MIN(system_coin) as min_value,
    MAX(system_coin) as max_value
FROM tracking_events
WHERE event_name = 'EndGame'
  AND game_type = 1080
  AND date = CURRENT_DATE
GROUP BY game_result
HAVING COUNT(DISTINCT system_coin) > 1;  -- 发现不一致

-- 如果查询有结果 → 触发告警
```

---

## 总结

### 案例特点

这是一个**"偶然正确"反模式**的典型案例：

1. **正常流程有 bug** - systemCoin = 5（错误）
2. **异常流程偶然正确** - systemCoin = 0（对但不可靠）
3. **数据不一致** - 同一场景不同结果
4. **难以发现** - 单次测试可能通过

### 核心教训

1. **一致性测试** - 重复测试，验证结果一致
2. **异常路径测试** - 不只测正常流程
3. **避免硬编码默认值** - 每个分支显式设置
4. **偶然正确更危险** - 比明显错误更难发现

### 应用范围

这个教训适用于所有**有多分支逻辑的字段**：
- 结算金额
- 状态码
- 奖励计算
- 任何依赖条件分支的值

---

**案例记录日期**: 2026-03-17
**记录人**: Claude (AI Assistant)
**案例状态**: 已修复，教训已整理

---

**记住**：偶然正确比明显错误更危险。测试要验证一致性，要覆盖异常路径。
