# 房内猜拳 Flutter 对接

本文只记录房内猜拳 App 端 HTTP 接口和腾讯 IM 自定义消息字段。独立猜拳本阶段不接入。

## 1. 基本约定

- 所有 HTTP 地址都有 `/api/v1` 前缀。
- 所有接口都需要 `Authorization: Bearer <access_token>`。
- 成功响应统一是 gateway envelope：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}
```

- 所有时间字段都是 Unix epoch milliseconds。
- Flutter 模型里用户 ID、挑战 ID、礼物 ID 建议统一按 `String` 保存。
- HTTP 返回的发起人和应战人资料在 `challenge.initiator.user`、`challenge.challenger.user`。
- IM 里的发起人和应战人也必须带 `nickname` 和 `avatar`；客户端不要为了基础展示再查用户资料。

## 2. 通用挑战单字段

`challenge` 字段在列表、详情、发起、应战接口中结构一致。

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge_id` | string | 挑战单 ID。 |
| `room_id` | string | 房间 ID。 |
| `region_id` | number | 用户语区 ID。 |
| `status` | string | `pending`、`matched`、`revealing`、`finished`、`timeout`、`settlement_failed`、`cancelled`。 |
| `stake_gift_id` | string | 下注礼物 ID，Flutter 优先用这个字段。 |
| `stake_gift_id_number` | number | 下注礼物 ID 数字值。 |
| `stake_coin` | number | 本局礼物对应金币价值。 |
| `initiator` | object | 发起人。 |
| `challenger` | object | 应战人；`pending` 时可能为空用户。 |
| `winner_user_id` | string | 胜者用户 ID；平局或未结算为空或 `0`。 |
| `winner_user_id_number` | number | 胜者用户 ID 数字值。 |
| `settlement_status` | string | 结算状态，例如 `pending`、`settled`、`failed`。 |
| `failure_reason` | string | 结算失败原因。 |
| `timeout_at_ms` | number | 无人应战自动过期时间。 |
| `reveal_at_ms` | number | 揭晓时间。 |
| `created_at_ms` | number | 创建时间。 |
| `matched_at_ms` | number | 应战成功时间。 |
| `settled_at_ms` | number | 结算时间。 |
| `updated_at_ms` | number | 更新时间。 |

`initiator` 和 `challenger` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户 ID，Flutter 优先用这个字段。 |
| `user_id_number` | number | 用户 ID 数字值。 |
| `gesture` | string | `rock`、`paper`、`scissors`。 |
| `result` | string | `win`、`lose`、`draw`；未结算时为空。 |
| `balance_after` | number | 结算后的金币余额。 |
| `joined_at_ms` | number | 参与时间。 |
| `user.user_id` | string | 展示用户 ID。 |
| `user.display_user_id` | string | 短号或展示号。 |
| `user.nickname` | string | 昵称；发起人和应战人都要有。 |
| `user.avatar` | string | 头像；发起人和应战人都要有。 |

## 3. 查询配置

地址：

```http
GET /api/v1/games/room-rps/config
```

参数：无。

返回值：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `config.status` | string | `active` 或 `disabled`。 |
| `config.challenge_timeout_ms` | number | 发起后无人应战的超时时间，默认 10 分钟。 |
| `config.reveal_countdown_ms` | number | 双方出拳后的揭晓倒计时，默认 3 秒。 |
| `config.stake_gifts` | array | 礼物下注档位，固定 4 档。 |
| `server_time_ms` | number | 服务端时间。 |

`stake_gifts` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gift_id` | string | 礼物 ID，Flutter 优先用这个字段提交。 |
| `gift_id_number` | number | 礼物 ID 数字值。 |
| `gift_name` | string | 礼物名称。 |
| `gift_icon_url` | string | 礼物图标。 |
| `gift_price_coin` | number | 礼物价格金币。 |
| `enabled` | bool | 是否启用。 |
| `sort_order` | number | 排序。 |

相关 IM：无。

## 4. 查询房间挑战单

地址：

```http
GET /api/v1/games/room-rps/challenges
```

参数：

| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | query | 是 | 房间 ID。 |
| `status` | query | 否 | 挑战状态。 |
| `page_size` | query | 否 | 页大小，默认 20。 |
| `cursor` | query | 否 | 下一页游标。 |

返回值：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenges` | array | 挑战单列表，结构见通用挑战单字段。 |
| `next_cursor` | string | 下一页游标。 |
| `page_size` | number | 本次页大小。 |
| `server_time_ms` | number | 服务端时间。 |

相关 IM：无。客户端漏掉 IM 后，用这个接口补拉当前房间挑战单。

## 5. 发起挑战

地址：

```http
POST /api/v1/games/room-rps/challenges/create
Content-Type: application/json
```

参数：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID。 |
| `gift_id` | number | 是 | 后台配置的礼物档位 ID。 |
| `gesture` | string | 是 | `rock`、`paper`、`scissors`。 |

请求示例：

```json
{
  "room_id": "room_1001",
  "gift_id": 10001,
  "gesture": "rock"
}
```

返回值：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge` | object | 挑战单，结构见通用挑战单字段。 |
| `server_time_ms` | number | 服务端时间。 |

相关 IM：`room_rps_challenge_created`。

## 6. 应战

地址：

```http
POST /api/v1/games/room-rps/challenges/{challenge_id}/accept
Content-Type: application/json
```

参数：

| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `challenge_id` | path | 是 | 挑战单 ID。 |
| `gesture` | body | 是 | `rock`、`paper`、`scissors`。 |

请求示例：

```json
{
  "gesture": "paper"
}
```

返回值：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge` | object | 应战后的挑战单，结构见通用挑战单字段。 |
| `server_time_ms` | number | 服务端时间。 |

相关 IM：

- `room_rps_challenge_accepted`
- `room_rps_reveal_countdown`
- `room_rps_finished`

当挑战到达 `timeout_at_ms` 后，应战接口返回 HTTP `409`：

```json
{
  "code": "ROOM_RPS_CHALLENGE_EXPIRED",
  "message": "猜拳已过期",
  "request_id": "req_xxx"
}
```

Flutter 必须按 `code` 识别该状态，关闭应战弹窗并提示“猜拳已过期”；不要依赖 `message` 判断业务类型。

## 7. 查询挑战详情

地址：

```http
GET /api/v1/games/room-rps/challenges/{challenge_id}
```

参数：

| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `challenge_id` | path | 是 | 挑战单 ID。 |

返回值：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge` | object | 挑战单，结构见通用挑战单字段。 |
| `server_time_ms` | number | 服务端时间。 |

相关 IM：无。客户端收到 IM 后，可以用这个接口刷新挑战事实。

## 8. 腾讯 IM 字段

房间群消息使用腾讯 IM `TIMCustomElem`，`data` 是 JSON 字符串。Flutter 只需要解析下面字段。

通用字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 事件 ID，客户端用它去重。 |
| `event_type` | string | 事件类型。 |
| `room_id` | string | 房间 ID。 |
| `challenge_id` | string | 挑战单 ID。 |
| `server_time_ms` | number | 服务端时间。 |
| `challenge` | object | 挑战单快照，结构见通用挑战单字段。 |

`challenge.initiator.user` 字段：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | string | 是 | 发起人用户 ID。 |
| `display_user_id` | string | 否 | 发起人展示号。 |
| `nickname` | string | 是 | 发起人昵称。 |
| `avatar` | string | 是 | 发起人头像。 |

`challenge.challenger.user` 字段：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | string | 应战后是 | 应战人用户 ID。 |
| `display_user_id` | string | 否 | 应战人展示号。 |
| `nickname` | string | 应战后是 | 应战人昵称。 |
| `avatar` | string | 应战后是 | 应战人头像。 |

IM 事件：

| `event_type` | 触发时机 | Flutter 处理 |
| --- | --- | --- |
| `room_rps_challenge_created` | 用户发起挑战后 | 展示 `[PK]` 入口、发起人头像昵称、礼物和倒计时。此时 `challenger` 可以为空。 |
| `room_rps_challenge_accepted` | 有用户应战后 | 锁定挑战入口，展示双方头像昵称。 |
| `room_rps_reveal_countdown` | 双方出拳后 | 播放 3 秒揭晓动画，展示双方头像昵称。 |
| `room_rps_finished` | 结算完成或平局退款后 | 展示胜负、双方头像昵称、余额或失败提示。 |

IM 示例：

```json
{
  "event_id": "room_rps_finished:rps_10001",
  "event_type": "room_rps_finished",
  "room_id": "room_1001",
  "challenge_id": "rps_10001",
  "server_time_ms": 1780000003000,
  "challenge": {
    "challenge_id": "rps_10001",
    "room_id": "room_1001",
    "status": "finished",
    "stake_gift_id": "10001",
    "stake_gift_id_number": 10001,
    "stake_coin": 100,
    "initiator": {
      "user_id": "10001",
      "user_id_number": 10001,
      "gesture": "rock",
      "result": "lose",
      "balance_after": 900,
      "joined_at_ms": 1780000000000,
      "user": {
        "user_id": "10001",
        "display_user_id": "880001",
        "nickname": "Alice",
        "avatar": "https://cdn.example/alice.png"
      }
    },
    "challenger": {
      "user_id": "10002",
      "user_id_number": 10002,
      "gesture": "paper",
      "result": "win",
      "balance_after": 1200,
      "joined_at_ms": 1780000001000,
      "user": {
        "user_id": "10002",
        "display_user_id": "880002",
        "nickname": "Bob",
        "avatar": "https://cdn.example/bob.png"
      }
    },
    "winner_user_id": "10002",
    "winner_user_id_number": 10002,
    "settlement_status": "settled",
    "timeout_at_ms": 1780000600000,
    "reveal_at_ms": 1780000003000,
    "created_at_ms": 1780000000000,
    "matched_at_ms": 1780000001000,
    "settled_at_ms": 1780000003000,
    "updated_at_ms": 1780000003000
  }
}
```
