# 语音房火箭 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 `。 | | `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}/rocket` 初始化 7 级物料和当前进度。 2. 用户送礼调用 `POST /api/v1/rooms/gift/send`;成功响应里的 `data.rocket` 可立即更新当前火箭状态。 3. 房间内监听腾讯 IM 群 `TIMCustomElem.Ext=room_system_message`,处理火箭进度、倒计时、发射和发奖。 4. App 全局或区域播报监听 `TIMCustomElem.Ext=im_broadcast` 且 `broadcast_type=room_rocket`。 5. 任意 IM 丢失、字段解析失败、版本倒退或页面重进时,重新调用 `GET /api/v1/rooms/{room_id}/rocket` 纠正本地状态。 客户端必须用 `event_id` 去重,用 `room_version` 丢弃旧房间消息。 ## 获取火箭信息 ```http GET /api/v1/rooms/{room_id}/rocket Authorization: Bearer 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 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": {}, "rocket": { "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_rocket_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.rocket` | object | 送礼后火箭状态;送礼人可立即用它刷新 UI。 | 注意:`GET /rocket` 的用户 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 /rocket` 仍可能返回 `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_rocket_progress_changed", "actor_user_id": 12345678, "target_user_id": 0, "room_version": 8, "attributes": { "box_id": "room_rocket_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 | 业务字段;值全部按字符串传输,Flutter 侧按需转 int/bool/JSON。 | 火箭房间群事件: | `event_type` | `Desc` | 用途 | | --- | --- | --- | | `room_rocket_progress_changed` | `room_rocket_progress_changed` | 有效火箭燃料增加。 | | `room_rocket_countdown_started` | `room_rocket_countdown_started` | 当前等级燃料满,进入倒计时。 | | `room_rocket_opened` | `room_rocket_opened` | 火箭已打开,包含公开奖励摘要。 | | `room_rocket_reward_granted` | `room_rocket_reward_granted` | 发奖结果事件,包含奖励列表。 | ### room_rocket_progress_changed 示例: ```json { "event_id": "evt_progress", "room_id": "lalu_xxx", "event_type": "room_rocket_progress_changed", "actor_user_id": 12345678, "room_version": 6, "attributes": { "box_id": "room_rocket_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_rocket_countdown_started` 补齐 `open_at_ms`;如该消息丢失,调用 GET 兜底。 ### room_rocket_countdown_started 示例: ```json { "event_id": "evt_countdown", "room_id": "lalu_xxx", "event_type": "room_rocket_countdown_started", "actor_user_id": 12345678, "target_user_id": 12345678, "room_version": 6, "attributes": { "box_id": "room_rocket_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_rocket_opened 示例: ```json { "event_id": "evt_opened", "room_id": "lalu_xxx", "event_type": "room_rocket_opened", "actor_user_id": 12345678, "target_user_id": 12345678, "room_version": 8, "attributes": { "box_id": "room_rocket_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_rocket_reward_granted 示例: ```json { "event_id": "evt_reward", "room_id": "lalu_xxx", "event_type": "room_rocket_reward_granted", "room_version": 8, "attributes": { "box_id": "room_rocket_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_rocket` | | `MsgContent.Data` | JSON 字符串 | | `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | `Data` 示例: ```json { "event_id": "room_rocket_broadcast:lalu_xxx:room_rocket_box_xxx", "broadcast_type": "room_rocket", "scope": "region", "app_code": "lalu", "region_id": 1001, "room_id": "lalu_xxx", "box_id": "room_rocket_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_rocket` 时展示火箭倒计时播报。 - 点击播报按 `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_rocket_progress_changed`、`room_rocket_countdown_started`、`POST /gift/send.data.rocket` 或 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> 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((item) { return item.map((key, val) => MapEntry(key.toString(), val)); }).toList(); } ``` 推荐本地状态更新策略: | 输入 | 处理 | | --- | --- | | `GET /rocket` | 覆盖本地完整火箭状态和 7 级物料。 | | `POST /gift/send.data.rocket` | 送礼人立即刷新当前状态。 | | `room_rocket_progress_changed` | 同房间、版本不旧时更新进度。 | | `room_rocket_countdown_started` | 切换倒计时,并锁定 top1/点火人展示。 | | `room_rocket_opened` | 播放发射动效,状态切到下一等级 idle。 | | `room_rocket_reward_granted` | 展示当前用户中奖提示;按 `event_id` 去重。 | | `im_broadcast + room_rocket` | 展示区域/全局播报入口,不直接改当前房间状态。 | 如果客户端发现本地状态和服务端事件无法合并,例如当前 `box_id` 不一致、`level` 倒退、`room_version` 缺失,直接调用 `GET /api/v1/rooms/{room_id}/rocket` 重新同步。