hyapp-server/docs/flutter对接/语音房宝箱Flutter对接.md
2026-05-20 18:07:33 +08:00

612 lines
21 KiB
Markdown
Raw 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.

# 语音房宝箱 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` 重新同步。