# 语音房宝箱 Flutter 对接

本文描述 Flutter App 对接语音房宝箱所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息 key。宝箱进度、开箱和发奖事实由 `room-service` Room Cell 持有；客户端只展示服务端返回或 IM 推送的状态，不能本地计算能量、奖励或开箱结果。

## 基础约定

本地开发地址：

```text
http://127.0.0.1:13000
```

业务接口统一响应 envelope：

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

| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`；失败为稳定错误码。 |
| `message` | 短错误文案，不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID；客户端日志要记录。 |
| `data` | 成功业务数据；失败时通常不存在。 |

通用请求头：

| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码，默认 `lalu`；登录态接口最终以 token 内 app_code 为准。 |
| `Content-Type` | POST 必填 | `application/json`。 |

所有 `*_ms` 都是 Unix epoch milliseconds。业务切日按 UTC，不按用户本地时区。

## 客户端流程

1. 用户成功 `JoinRoom` 后调用 `GET /api/v1/rooms/{room_id}/treasure` 初始化 7 级物料和当前进度。
2. 用户送礼调用 `POST /api/v1/rooms/gift/send`；成功响应里的 `data.treasure` 可立即更新当前宝箱状态。
3. 房间内监听腾讯 IM 群 `TIMCustomElem.Ext=room_system_message`，处理宝箱进度、倒计时、开箱和发奖。
4. App 全局或区域播报监听 `TIMCustomElem.Ext=im_broadcast` 且 `broadcast_type=room_treasure`。
5. 任意 IM 丢失、字段解析失败、版本倒退或页面重进时，重新调用 `GET /api/v1/rooms/{room_id}/treasure` 纠正本地状态。

客户端必须用 `event_id` 去重，用 `room_version` 丢弃旧房间消息。

## 获取宝箱信息

```http
GET /api/v1/rooms/{room_id}/treasure
Authorization: Bearer <access_token>
X-App-Code: lalu
```

路径参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前语音房 ID，例如 `lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72`。 |

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "enabled": true,
    "levels": [
      {
        "level": 1,
        "energy_threshold": 1000,
        "cover_url": "https://cdn.example/chest/lv1/cover.png",
        "animation_url": "https://cdn.example/chest/lv1/idle.svga",
        "opening_animation_url": "https://cdn.example/chest/lv1/opening.svga",
        "opened_image_url": "https://cdn.example/chest/lv1/opened.png",
        "in_room_rewards": [
          {
            "reward_item_id": "lv1_in_room_1",
            "resource_group_id": 1001,
            "weight": 10000,
            "display_name": "Gold Pack",
            "icon_url": "https://cdn.example/reward/gold.png"
          }
        ],
        "top1_rewards": [],
        "igniter_rewards": []
      }
    ],
    "state": {
      "current_level": 1,
      "current_progress": 0,
      "energy_threshold": 1000,
      "status": "idle",
      "reset_at_ms": 1779321600000,
      "top1_user_id": "",
      "igniter_user_id": "",
      "box_id": "",
      "config_version": 3,
      "last_rewards": []
    },
    "broadcast_scope": "region",
    "open_delay_ms": 30000,
    "broadcast_delay_ms": 0,
    "reward_stack_policy": "allow_stack",
    "server_time_ms": 1779259000000
  }
}
```

`data` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 宝箱总开关。`false` 时客户端隐藏或置灰宝箱入口。 |
| `levels` | array | 固定 7 个等级的物料、阈值和奖励池。 |
| `state` | object | 当前房间当前 UTC 日的宝箱状态。 |
| `broadcast_scope` | string | `none`、`region`、`global`。 |
| `open_delay_ms` | int64 | 满能量后到开箱的倒计时配置。 |
| `broadcast_delay_ms` | int64 | 后台配置字段；当前房间事件会立即进入播报链路。 |
| `reward_stack_policy` | string | `allow_stack` 或 `priority_only`。 |
| `server_time_ms` | int64 | 服务端当前时间，倒计时要以它校准本地时钟偏差。 |

`levels[]` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `level` | int32 | 等级，1 到 7。 |
| `energy_threshold` | int64 | 当前等级满能量阈值。 |
| `cover_url` | string | 静态封面图。 |
| `animation_url` | string | 未开箱/蓄能态动效。 |
| `opening_animation_url` | string | 开箱动效。 |
| `opened_image_url` | string | 开箱后展示图。 |
| `in_room_rewards` | array | 开箱时仍在房用户的奖励候选展示。 |
| `top1_rewards` | array | 倒计时开始时贡献第一用户奖励候选展示。 |
| `igniter_rewards` | array | 点火人奖励候选展示。 |

奖励候选字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `reward_item_id` | string | 后台配置的奖励项 ID。 |
| `resource_group_id` | int64 | 钱包资源组 ID。 |
| `weight` | int64 | 抽奖权重，只用于展示或调试，不参与客户端抽奖。 |
| `display_name` | string | 奖励展示名称。 |
| `icon_url` | string | 奖励图标。 |

`state` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `current_level` | int32 | 当前等级，1 到 7。 |
| `current_progress` | int64 | 当前等级已累计有效能量。 |
| `energy_threshold` | int64 | 当前等级阈值。 |
| `status` | string | 当前返回 `idle` 或 `countdown`。 |
| `countdown_started_at_ms` | int64 | 倒计时开始时间；非倒计时可为空或 0。 |
| `open_at_ms` | int64 | 预计开箱时间；`status=countdown` 时有效。 |
| `opened_at_ms` | int64 | 最近一次开箱时间。 |
| `reset_at_ms` | int64 | 下一个 UTC 0 点；到点后进度按新 UTC 日重置。 |
| `top1_user_id` | string | 倒计时开始时锁定的贡献第一；GET 接口中按字符串返回。 |
| `igniter_user_id` | string | 点火人；GET 接口中按字符串返回。 |
| `box_id` | string | 当前倒计时宝箱或最近状态的单轮幂等 ID。 |
| `config_version` | int64 | 当前状态关联的后台配置版本。 |
| `last_rewards` | array | 最近一次开箱发奖结果；客户端可用于展示开箱结果兜底。 |

`last_rewards[]` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `reward_role` | string | `in_room`、`top1`、`igniter`。 |
| `user_id` | string | 获奖用户 ID 字符串。 |
| `reward_item_id` | string | 奖励项 ID。 |
| `resource_group_id` | int64 | 资源组 ID。 |
| `display_name` | string | 奖励展示名称。 |
| `icon_url` | string | 奖励图标。 |
| `grant_id` | string | 钱包发放记录 ID。 |
| `status` | string | 当前成功发放为 `succeeded`。 |

展示规则：

- 进度条使用 `current_progress / energy_threshold`，并且最大显示 100%。
- 倒计时使用 `open_at_ms - server_time_ms` 初始化，再用本地 timer 驱动。
- 不要把送礼溢出能量展示到下一等级；服务端规则是溢出作废。
- 如果 `status=countdown`，继续送礼不会改变宝箱进度、点火人、贡献第一或 `open_at_ms`。

## 送礼并更新宝箱

```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```

请求体：

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_send_gift_lalu_xxx_12345678_1779259000000",
  "target_type": "user",
  "target_user_ids": [12345678],
  "gift_id": "rocket",
  "gift_count": 1
}
```

参数：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `command_id` | string | 是 | 本次送礼动作幂等键；同一次 HTTP 重试必须复用。 |
| `target_type` | string | 否 | 当前只支持 `user`；为空按 `user` 处理。 |
| `target_user_ids` | int64[] | 是 | 收礼用户列表；`target_type=user` 时必须且只能 1 个。 |
| `target_user_id` | int64 | 否 | 旧客户端兼容字段；新客户端优先用 `target_user_ids`。 |
| `gift_id` | string | 是 | 礼物 ID。 |
| `gift_count` | int32 | 是 | 礼物数量，必须大于 0。 |

成功响应核心字段：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "result": {
      "applied": true,
      "room_version": 7,
      "server_time_ms": 1779259502200
    },
    "billing_receipt_id": "bill_xxx",
    "room_heat": 500,
    "gift_rank": [
      {
        "user_id": 12345678,
        "score": 500,
        "gift_value": 500,
        "updated_at_ms": 1779259502104
      }
    ],
    "room": {},
    "treasure": {
      "current_level": 2,
      "current_progress": 200,
      "energy_threshold": 200,
      "status": "countdown",
      "countdown_started_at_ms": 1779259502104,
      "open_at_ms": 1779259512104,
      "reset_at_ms": 1779321600000,
      "top1_user_id": 12345678,
      "igniter_user_id": 12345678,
      "box_id": "room_treasure_box_xxx",
      "config_version": 3
    }
  }
}
```

字段说明：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data.result.applied` | bool | 命令是否产生新状态；同一 `command_id` 重试可能返回历史结果。 |
| `data.result.room_version` | int64 | 房间版本，用于和 IM 消息去重、排序。 |
| `data.billing_receipt_id` | string | 钱包扣费回执。 |
| `data.room_heat` | int64 | 送礼后的房间热度。 |
| `data.gift_rank` | array | 送礼后的本地贡献榜快照。 |
| `data.room` | object | 房间快照。 |
| `data.treasure` | object | 送礼后宝箱状态；送礼人可立即用它刷新 UI。 |

注意：`GET /treasure` 的用户 ID 字段按字符串返回，`POST /gift/send` 透出的 protobuf 状态里部分用户 ID 可能是数字。Flutter 侧建议用统一 helper 兼容 `String` 和 `num`。

```dart
String readId(dynamic value) {
  if (value == null) return '';
  if (value is String) return value;
  if (value is num) return value.toInt().toString();
  return '';
}
```

幂等规则：

| 场景 | 处理 |
| --- | --- |
| HTTP 超时后重试同一次送礼 | 复用原 `command_id`。 |
| 用户点了第二次送礼按钮 | 生成新的 `command_id`。 |
| 同一个 `command_id` 换了礼物、数量或目标 | 服务端返回冲突错误。 |

## 错误处理

失败响应：

```json
{
  "code": "PERMISSION_DENIED",
  "message": "permission denied",
  "request_id": "req_xxx"
}
```

通用错误：

| HTTP | `code` | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| 400 | `INVALID_JSON` | 请求体不是合法 JSON。 | 修正客户端请求，不重试。 |
| 400 | `INVALID_ARGUMENT` | 缺字段、数量非法、room_id 格式非法、暂不支持的 `target_type`。 | 修正参数，不自动重试。 |
| 401 | `UNAUTHORIZED` | token 缺失、过期或无效。 | 走登录刷新或重新登录。 |
| 403 | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
| 403 | `PERMISSION_DENIED` | 不在房间、被 ban、无权限读取或操作。 | 退出房间页或刷新房间状态。 |
| 404 | `NOT_FOUND` | 房间不存在、送礼人不在房、收礼人不在房、礼物不可用。 | 刷新房间状态；礼物不可用时刷新礼物面板。 |
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出房间页并刷新列表。 |
| 409 | `CONFLICT` | 状态或唯一约束冲突。 | 刷新后重试。 |
| 409 | `IDEMPOTENCY_CONFLICT` | `command_id` 复用但请求内容不同。 | 新用户动作生成新 `command_id`；重试旧动作必须保持原请求体。 |
| 409 | `INSUFFICIENT_BALANCE` | 钱包余额不足。 | 弹充值/余额不足提示。 |
| 409 | `LEDGER_CONFLICT` | 钱包并发账本冲突。 | 可短延迟重试一次，仍失败则刷新余额。 |
| 502 | `UPSTREAM_ERROR` | room/wallet 等依赖暂不可用。 | 提示稍后重试，可保留原 `command_id` 重试同一次送礼。 |
| 500 | `INTERNAL_ERROR` | 服务端内部错误。 | 提示失败并记录 `request_id`。 |

宝箱相关额外规则：

- 获取宝箱信息要求当前用户仍在房间业务 presence 内；离房后调用会返回 `PERMISSION_DENIED` 或 `NOT_FOUND`。
- 宝箱关闭时，`GET /treasure` 仍可能返回 `enabled=false` 和 7 级配置；客户端不展示进度入口即可。
- 发奖自动完成，没有 App 领取接口；客户端不要补发或重复调用钱包接口。

## 房间群 IM 消息

房间内宝箱进度、倒计时、开箱和发奖通过腾讯云 IM 房间群 `TIMCustomElem` 投递。

| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `MsgContent.Ext` | `room_system_message` |
| `MsgContent.Desc` | 等于 `Data.event_type` |
| `MsgContent.Data` | JSON 字符串 |
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 |

`Data` 外层结构：

```json
{
  "event_id": "evt_xxx",
  "room_id": "lalu_xxx",
  "event_type": "room_treasure_progress_changed",
  "actor_user_id": 12345678,
  "target_user_id": 0,
  "room_version": 8,
  "attributes": {
    "box_id": "room_treasure_box_xxx"
  }
}
```

通用字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 去重键。 |
| `room_id` | string | 房间 ID。 |
| `event_type` | string | 事件类型，见下表。 |
| `actor_user_id` | int64 | 触发用户；倒计时/开箱通常为点火人。 |
| `target_user_id` | int64 | 目标用户；倒计时/开箱通常为 top1。 |
| `room_version` | int64 | 房间版本，本地版本更高时丢弃旧消息。 |
| `attributes` | map<string,string> | 业务字段；值全部按字符串传输，Flutter 侧按需转 int/bool/JSON。 |

宝箱房间群事件：

| `event_type` | `Desc` | 用途 |
| --- | --- | --- |
| `room_treasure_progress_changed` | `room_treasure_progress_changed` | 有效宝箱能量增加。 |
| `room_treasure_countdown_started` | `room_treasure_countdown_started` | 当前等级满能量，进入倒计时。 |
| `room_treasure_opened` | `room_treasure_opened` | 宝箱已打开，包含公开奖励摘要。 |
| `room_treasure_reward_granted` | `room_treasure_reward_granted` | 发奖结果事件，包含奖励列表。 |

### room_treasure_progress_changed

示例：

```json
{
  "event_id": "evt_progress",
  "room_id": "lalu_xxx",
  "event_type": "room_treasure_progress_changed",
  "actor_user_id": 12345678,
  "room_version": 6,
  "attributes": {
    "box_id": "room_treasure_box_xxx",
    "level": "2",
    "added_energy": "300",
    "effective_added_energy": "200",
    "overflow_energy": "100",
    "current_progress": "200",
    "energy_threshold": "200",
    "status": "countdown",
    "reset_at_ms": "1779321600000",
    "gift_id": "rocket",
    "gift_count": "3",
    "command_id": "cmd_send_gift_xxx"
  }
}
```

处理：

- 更新当前等级进度为 `current_progress`。
- `effective_added_energy` 是本次实际计入宝箱的能量。
- `overflow_energy` 是作废能量，不能加到下一等级。
- 如果 `status=countdown`，立即切换倒计时态，等待 `room_treasure_countdown_started` 补齐 `open_at_ms`；如该消息丢失，调用 GET 兜底。

### room_treasure_countdown_started

示例：

```json
{
  "event_id": "evt_countdown",
  "room_id": "lalu_xxx",
  "event_type": "room_treasure_countdown_started",
  "actor_user_id": 12345678,
  "target_user_id": 12345678,
  "room_version": 6,
  "attributes": {
    "box_id": "room_treasure_box_xxx",
    "level": "2",
    "current_progress": "200",
    "energy_threshold": "200",
    "countdown_start_ms": "1779259502104",
    "open_at_ms": "1779259512104",
    "broadcast_scope": "region",
    "visible_region_id": "1001",
    "top1_user_id": "12345678",
    "igniter_user_id": "12345678",
    "command_id": "cmd_send_gift_xxx"
  }
}
```

处理：

- 按 `open_at_ms` 展示开箱倒计时。
- `top1_user_id` 和 `igniter_user_id` 在此刻锁定；后续离房或下线也不影响这两类奖励发放。
- 倒计时期间再收到普通 `room_gift_sent`，只更新礼物表现和热度，不改变宝箱倒计时。

注意：房间群 IM 字段名是 `countdown_start_ms`，HTTP state 字段名是 `countdown_started_at_ms`。

### room_treasure_opened

示例：

```json
{
  "event_id": "evt_opened",
  "room_id": "lalu_xxx",
  "event_type": "room_treasure_opened",
  "actor_user_id": 12345678,
  "target_user_id": 12345678,
  "room_version": 8,
  "attributes": {
    "box_id": "room_treasure_box_xxx",
    "level": "2",
    "next_level": "3",
    "opened_at_ms": "1779259512668",
    "reset_at_ms": "1779321600000",
    "top1_user_id": "12345678",
    "igniter_user_id": "12345678",
    "rewards_json": "[{\"reward_role\":\"in_room\",\"user_id\":12345678,\"reward_item_id\":\"lv2_in_room_1\",\"resource_group_id\":1001,\"display_name\":\"Gold Pack\",\"icon_url\":\"https://cdn.example/reward/gold.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]"
  }
}
```

处理：

- 播放当前等级 `opening_animation_url`，再展示 `opened_image_url` 或奖励弹窗。
- 解析 `rewards_json` 得到奖励列表；如果解析失败，调用 GET 获取 `state.last_rewards`。
- 本地状态进入 `next_level`，进度归 0，状态归 `idle`。

### room_treasure_reward_granted

示例：

```json
{
  "event_id": "evt_reward",
  "room_id": "lalu_xxx",
  "event_type": "room_treasure_reward_granted",
  "room_version": 8,
  "attributes": {
    "box_id": "room_treasure_box_xxx",
    "level": "2",
    "rewards_json": "[{\"reward_role\":\"top1\",\"user_id\":12345678,\"reward_item_id\":\"lv2_top1_1\",\"resource_group_id\":1002,\"display_name\":\"Top Reward\",\"icon_url\":\"https://cdn.example/reward/top.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]"
  }
}
```

处理：

- 解析 `rewards_json`。
- 当前登录用户在奖励列表中时，展示“获得奖励”提示。
- 同一用户可能同时命中 `in_room`、`top1`、`igniter`，是否叠加由 `reward_stack_policy` 决定；客户端按服务端发下来的列表展示。

当前没有单独的 C2C 宝箱中奖私信，也没有 App 消息 Tab 宝箱入箱消息。后续如新增私有通知，应以新的 C2C `Ext` 文档为准，不复用房间群消息假装私信。

## 区域/全局播报 IM

满能量进入倒计时后，如果后台配置允许播报，`activity-service` 会向区域或全局播报群发送 `TIMCustomElem`。

| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `MsgContent.Ext` | `im_broadcast` |
| `MsgContent.Desc` | `room_treasure` |
| `MsgContent.Data` | JSON 字符串 |
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 |

`Data` 示例：

```json
{
  "event_id": "room_treasure_broadcast:lalu_xxx:room_treasure_box_xxx",
  "broadcast_type": "room_treasure",
  "scope": "region",
  "app_code": "lalu",
  "region_id": 1001,
  "room_id": "lalu_xxx",
  "box_id": "room_treasure_box_xxx",
  "level": 2,
  "open_at_ms": 1779259512104,
  "top1_user_id": 12345678,
  "igniter_user_id": 12345678,
  "sent_at_ms": 1779259502200,
  "room_version": 6,
  "source_event_id": "evt_countdown",
  "current_progress": 200,
  "energy_threshold": 200,
  "countdown_started_at_ms": 1779259502104,
  "action": {
    "type": "enter_room",
    "room_id": "lalu_xxx"
  }
}
```

处理：

- `broadcast_type=room_treasure` 时展示宝箱倒计时播报。
- 点击播报按 `action.type=enter_room` 跳转或进房。
- `scope=region` 表示区域播报群；`scope=global` 表示全局播报群。
- 播报只负责引导和展示，不代替房间内宝箱状态；进房后仍调用 GET 初始化。

## 普通送礼房间 IM

送礼成功还会产生普通房间群消息：

| 字段 | 值 |
| --- | --- |
| `MsgContent.Ext` | `room_system_message` |
| `MsgContent.Desc` | `room_gift_sent` |
| `Data.event_type` | `room_gift_sent` |

宝箱 UI 不应该只靠 `room_gift_sent` 推算能量；能量以 `room_treasure_progress_changed`、`room_treasure_countdown_started`、`POST /gift/send.data.treasure` 或 GET 结果为准。

## Flutter 解析建议

IM `attributes` 的值都是字符串，建议统一封装解析：

```dart
int readInt(dynamic value, [int fallback = 0]) {
  if (value == null) return fallback;
  if (value is num) return value.toInt();
  if (value is String) return int.tryParse(value) ?? fallback;
  return fallback;
}

String readString(dynamic value) {
  if (value == null) return '';
  if (value is String) return value;
  if (value is num) return value.toInt().toString();
  return value.toString();
}

List<Map<String, dynamic>> readRewardsJson(dynamic value) {
  final raw = readString(value);
  if (raw.isEmpty) return const [];
  final decoded = jsonDecode(raw);
  if (decoded is! List) return const [];
  return decoded.whereType<Map>().map((item) {
    return item.map((key, val) => MapEntry(key.toString(), val));
  }).toList();
}
```

推荐本地状态更新策略：

| 输入 | 处理 |
| --- | --- |
| `GET /treasure` | 覆盖本地完整宝箱状态和 7 级物料。 |
| `POST /gift/send.data.treasure` | 送礼人立即刷新当前状态。 |
| `room_treasure_progress_changed` | 同房间、版本不旧时更新进度。 |
| `room_treasure_countdown_started` | 切换倒计时，并锁定 top1/点火人展示。 |
| `room_treasure_opened` | 播放开箱动效，状态切到下一等级 idle。 |
| `room_treasure_reward_granted` | 展示当前用户中奖提示；按 `event_id` 去重。 |
| `im_broadcast + room_treasure` | 展示区域/全局播报入口，不直接改当前房间状态。 |

如果客户端发现本地状态和服务端事件无法合并，例如当前 `box_id` 不一致、`level` 倒退、`room_version` 缺失，直接调用 `GET /api/v1/rooms/{room_id}/treasure` 重新同步。
