diff --git a/docs/flutter对接/语音房红包Flutter对接.md b/docs/flutter对接/语音房红包Flutter对接.md new file mode 100644 index 00000000..baf133d3 --- /dev/null +++ b/docs/flutter对接/语音房红包Flutter对接.md @@ -0,0 +1,607 @@ +# 语音房红包 Flutter 对接 + +本文按当前 gateway、wallet-service 和 activity-service 实现描述 Flutter 需要对接的房内红包 HTTP 接口和腾讯 IM 自定义消息。红包金额、份额、可抢时间、过期退款、领取幂等和房间在线态校验全部以后端为准;Flutter 只负责展示、提交用户动作和处理实时刷新。 + +## 1. 基本约定 + +- 所有 HTTP 路径都有 `/api/v1` 前缀。 +- 所有接口都需要 `Authorization: Bearer `,并且用户必须已完成资料。 +- 成功响应统一是 gateway envelope: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": {} +} +``` + +- 失败响应也使用同一个 envelope;Flutter 只按 HTTP 状态和 `code` 分支,`message` 只做兜底展示或日志。 +- 所有时间字段都是 Unix epoch milliseconds。Flutter 不用本地时区参与业务判断,只做展示格式化。 +- 红包主键统一使用 `packetNo`。IM 里兼容带 `packetId`,值和 `packetNo` 相同。 +- Go 返回的部分 ID 是 number,Flutter 模型里建议统一转成 `String` 保存,避免大整数精度和 UI 拼接问题。 + +## 2. 接入流程 + +进入语音房后: + +1. 走现有进房流程,加入房间 IM 群。 +2. 调用 `GET /api/v1/app/voice-room/red-packet/active?roomId=` 初始化侧边栏红包列表。 +3. 监听腾讯 IM 房间群 `TIMCustomElem`,解析 `ROOM_RED_PACKET`。 +4. 如果需要接收延时红包同语区飘窗,调用 `GET /api/v1/app/voice-room/red-packet/im-group` 获取当前语区群并加入。 +5. 用户点击 IM 红包弹窗或侧边栏红包时,直接用 payload 里的 `packetNo` 调领取或详情接口。 + +收到 `CREATED` IM 后,payload 已带发送者头像昵称、金额、份数和剩余份数。Flutter 可以直接弹窗或显示飘窗,不需要为了基础展示再立刻拉 active/detail。断线重连、进房初始化、详情页打开和二级领取记录页仍然走 HTTP 查询。 + +## 3. 当前语区 IM 群 + +```http +GET /api/v1/app/voice-room/red-packet/im-group +``` + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "regionId": 310, + "regionCode": "AR", + "regionName": "Arabic", + "groupId": "hy_lalu_bc_r_310", + "groupName": "hy_lalu_bc_r_310" + } +} +``` + +Flutter 用途: + +| 字段 | 用途 | +| --- | --- | +| `groupId` | 腾讯 IM 区域广播群 ID,加入后接收延时红包同语区飘窗。 | +| `regionId` | 当前用户所在语区 ID。 | +| `regionCode` | UI 或埋点使用。 | + +如果返回 `voice_room_region_missing`,说明用户资料没有有效语区,隐藏区域红包飘窗入口。 + +## 4. 红包配置 + +```http +GET /api/v1/app/voice-room/red-packet/config +``` + +当前该接口沿用 wallet 配置 DTO,字段是 snake_case: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "config": { + "app_code": "lalu", + "enabled": true, + "count_tiers": [5, 10], + "amount_tiers": [15000, 30000], + "delayed_open_seconds": 300, + "expire_seconds": 86400, + "daily_send_limit": 10, + "rule_url": "https://example.com/red-packet-rule", + "created_at_ms": 1780000000000, + "updated_at_ms": 1780000000000 + }, + "server_time_ms": 1780000000000 + } +} +``` + +Flutter 用途: + +| 字段 | 用途 | +| --- | --- | +| `enabled` | false 时隐藏发送入口,已收到的历史红包仍可按接口结果展示。 | +| `count_tiers` | 发送红包份数选项。 | +| `amount_tiers` | 发送红包总金币金额选项。 | +| `delayed_open_seconds` | 延时红包等待秒数;Flutter 不提交该值。 | +| `expire_seconds` | 过期秒数;当前为 86400。 | +| `daily_send_limit` | 每人每日发送上限提示。 | +| `rule_url` | 规则说明 H5。 | + +## 5. 发送红包 + +```http +POST /api/v1/app/voice-room/red-packet/send +Content-Type: application/json +``` + +请求: + +```json +{ + "roomId": "9001", + "packetMode": "DELAYED", + "totalAmount": 15000, + "totalCount": 5, + "requestId": "client-uuid-or-action-id" +} +``` + +字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `roomId` | 是 | 当前语音房 ID。 | +| `packetMode` | 是 | `IMMEDIATE` 或 `DELAYED`。后端也兼容 `normal/delayed`。 | +| `totalAmount` | 是 | 红包总金币,必须命中 `amount_tiers`。 | +| `totalCount` | 是 | 红包份数,必须命中 `count_tiers`。 | +| `requestId` | 是 | 客户端动作幂等 ID。同一用户同一 `requestId` 重试返回同一红包。 | + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "packet": { + "packetNo": "VRRP2026052712340001", + "roomId": "9001", + "regionId": 310, + "senderUserId": 10001, + "packetMode": "DELAYED", + "totalAmount": 15000, + "totalCount": 5, + "remainingAmount": 15000, + "remainingCount": 5, + "status": "WAITING_OPEN", + "claimStartTime": 1780000000000, + "expireTime": 1780086400000, + "createdAt": 1779999700000, + "claimedByMe": false, + "myClaimAmount": 0, + "refundAmount": 0, + "refundStatus": "", + "refundedAt": 0, + "claims": [] + }, + "balanceAfter": 985000, + "serverTime": 1779999700000 + } +} +``` + +发送成功后服务端会异步发送 IM: + +- 当前房间群:`ROOM_RED_PACKET / CREATED / scope=ROOM` +- 延时红包额外发送当前语区广播群:`ROOM_RED_PACKET / CREATED / scope=REGION` +- 延时红包到点后当前房间群:`ROOM_RED_PACKET / OPEN_DUE / scope=ROOM` + +Flutter 发送成功后可以立即把返回的 `packet` 放入本地红包列表;后续 IM 可能也会收到同一个红包,需要按 `packetNo + event + scope` 做去重或覆盖更新。 + +## 6. 房间有效红包列表 + +```http +GET /api/v1/app/voice-room/red-packet/active?roomId=9001 +``` + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "packets": [ + { + "packetNo": "VRRP2026052712340001", + "roomId": "9001", + "regionId": 310, + "senderUserId": 10001, + "packetMode": "DELAYED", + "totalAmount": 15000, + "totalCount": 5, + "remainingAmount": 15000, + "remainingCount": 5, + "status": "WAITING_OPEN", + "claimStartTime": 1780000000000, + "expireTime": 1780086400000, + "createdAt": 1779999700000, + "claimedByMe": false, + "myClaimAmount": 0 + } + ], + "serverTime": 1779999700000 + } +} +``` + +Flutter 用途: + +- 进房或重连后初始化侧边栏列表。 +- 侧边栏未领取数量可以用 `packets.where((p) => !p.claimedByMe).length`。 +- 该接口只返回仍处于 `ACTIVE` 或 `WAITING_OPEN` 的红包。抢完、已退款或过期后的最终状态通过详情或 IM `STATUS_CHANGED` 刷新。 + +## 7. 抢红包 + +```http +POST /api/v1/app/voice-room/red-packet/claim +Content-Type: application/json +``` + +请求: + +```json +{ + "packetNo": "VRRP2026052712340001" +} +``` + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "claim": { + "claimId": "claim_xxx", + "packetNo": "VRRP2026052712340001", + "userId": 10002, + "account": "10002", + "nickname": "Jerry", + "avatar": "https://example.com/avatar2.png", + "amount": 3000, + "status": "claimed", + "createdAt": 1780000010000 + }, + "packet": { + "packetNo": "VRRP2026052712340001", + "remainingAmount": 12000, + "remainingCount": 4, + "status": "ACTIVE", + "claimedByMe": true, + "myClaimAmount": 3000 + }, + "balanceAfter": 1003000, + "serverTime": 1780000010000 + } +} +``` + +处理规则: + +- Flutter 只提交 `packetNo`。当前服务端按 `packetNo + 当前用户` 保证同一用户重复领取返回同一结果。 +- 如果返回 `voice_room_red_packet_room_presence_required`,提示用户需要在房间内操作,并刷新房间在线态。 +- 如果返回 `voice_room_red_packet_not_started`,展示倒计时,不要重试抢。 +- 如果返回 `voice_room_red_packet_finished` 或 `voice_room_red_packet_expired`,刷新侧边栏和详情。 +- 如果返回 `claim_processing`,展示处理中,可让用户稍后重试同一个红包或打开详情。 + +## 8. 红包详情 + +```http +GET /api/v1/app/voice-room/red-packet/detail?packetNo=VRRP2026052712340001 +``` + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "packet": { + "packetNo": "VRRP2026052712340001", + "roomId": "9001", + "regionId": 310, + "senderUserId": 10001, + "packetMode": "DELAYED", + "totalAmount": 15000, + "totalCount": 5, + "remainingAmount": 12000, + "remainingCount": 4, + "status": "ACTIVE", + "claimStartTime": 1780000000000, + "expireTime": 1780086400000, + "createdAt": 1779999700000, + "claimedByMe": true, + "myClaimAmount": 3000, + "refundAmount": 0, + "refundStatus": "", + "refundedAt": 0, + "claims": [] + }, + "serverTime": 1780000010000 + } +} +``` + +详情接口里的 `claims` 不作为二级领取记录页的资料来源。需要头像昵称账号时使用 `claim-records`,因为该接口会按当前页批量补用户资料。 + +## 9. 领取记录 + +```http +GET /api/v1/app/voice-room/red-packet/claim-records?packetNo=VRRP2026052712340001&page=1&pageSize=20 +``` + +当前实现使用 `page/pageSize`,不要按旧文档使用 `cursor/limit`。 + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "items": [ + { + "claimId": "claim_xxx", + "packetNo": "VRRP2026052712340001", + "userId": 10002, + "account": "10002", + "nickname": "Jerry", + "avatar": "https://example.com/avatar2.png", + "amount": 3000, + "status": "claimed", + "createdAt": 1780000010000 + } + ], + "total": 1, + "page": 1, + "pageSize": 20, + "serverTime": 1780000010000 + } +} +``` + +Flutter 用途: + +- 二级领取记录页使用 `items` 渲染头像、昵称、账号、领取金额和领取时间。 +- `account/nickname/avatar` 已由 gateway 从 user-service 补齐。 +- 分页加载下一页时递增 `page`。 + +## 10. 我发出的红包记录 + +```http +GET /api/v1/app/voice-room/red-packet/sent-records?page=1&pageSize=20 +``` + +返回: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "items": [ + { + "packetNo": "VRRP2026052712340001", + "roomId": "9001", + "packetMode": "DELAYED", + "totalAmount": 15000, + "totalCount": 5, + "remainingAmount": 12000, + "remainingCount": 4, + "status": "ACTIVE", + "claimStartTime": 1780000000000, + "expireTime": 1780086400000, + "createdAt": 1779999700000, + "refundAmount": 0, + "refundStatus": "", + "refundedAt": 0 + } + ], + "total": 1, + "page": 1, + "pageSize": 20, + "serverTime": 1780000010000 + } +} +``` + +## 11. IM 消息 + +腾讯 IM 自定义消息的 `TIMCustomElem.Data` 是 JSON 字符串。Flutter 先解析外层: + +```json +{ + "type": "ROOM_RED_PACKET", + "data": {} +} +``` + +只有 `type == "ROOM_RED_PACKET"` 才进入红包处理。 + +### 11.1 CREATED + +当前房间弹窗和同语区飘窗都使用 `CREATED`,通过 `scope` 区分。修正后的 CREATED payload 已带足够的首屏展示字段。 + +```json +{ + "type": "ROOM_RED_PACKET", + "data": { + "event": "CREATED", + "scope": "ROOM", + "packetNo": "VRRP2026052712340001", + "packetId": "VRRP2026052712340001", + "roomId": "9001", + "regionId": 310, + "regionCode": "AR", + "packetMode": "DELAYED", + "senderUserId": "10001", + "actualAccount": "10001", + "userNickname": "Tom", + "userAvatar": "https://example.com/avatar.png", + "totalAmount": 15000, + "totalCount": 5, + "remainAmount": 15000, + "remainCount": 5, + "status": "WAITING_OPEN", + "delaySeconds": 300, + "refundedAmount": 0, + "claimStartTime": 1780000000000, + "expireTime": 1780086400000, + "serverTime": 1779999700000 + } +} +``` + +Flutter 行为: + +| `scope` | 行为 | +| --- | --- | +| `ROOM` | 如果当前正在该房间,直接弹抢红包窗口,并更新侧边栏。 | +| `REGION` | 作为同语区飘窗展示;点击后进入 `roomId` 对应房间并打开红包弹窗或详情。 | + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `packetNo` / `packetId` | string | 红包 ID,接口参数使用 `packetNo`。 | +| `packetMode` | string | `IMMEDIATE` 或 `DELAYED`。 | +| `senderUserId` | string | 发送者长 ID。 | +| `actualAccount` | string | 发送者展示账号。 | +| `userNickname` | string | 发送者昵称。 | +| `userAvatar` | string | 发送者头像 URL。 | +| `totalAmount` | int64 | 红包总金币。 | +| `totalCount` | int | 红包总份数。 | +| `remainAmount` | int64 | 剩余总金币。CREATED 时通常等于 `totalAmount`。 | +| `remainCount` | int | 剩余可领份数。CREATED 时通常等于 `totalCount`。 | +| `status` | string | `ACTIVE`、`WAITING_OPEN`、`FINISHED`、`REFUNDED`。 | +| `delaySeconds` | int64 | `DELAYED` 红包的等待秒数,普通红包为 0。 | +| `claimStartTime` | int64 | 可领取开始时间。 | +| `expireTime` | int64 | 过期时间。 | +| `serverTime` | int64 | 该红包事件生成时间。 | + +### 11.2 OPEN_DUE + +延时红包到可领取时间后,服务端向房间群发 `OPEN_DUE`。当前 payload 使用同一结构,Flutter 只需要关心 `event/scope/packetNo/status/claimStartTime/expireTime/remainCount`。 + +```json +{ + "type": "ROOM_RED_PACKET", + "data": { + "event": "OPEN_DUE", + "scope": "ROOM", + "packetNo": "VRRP2026052712340001", + "packetId": "VRRP2026052712340001", + "roomId": "9001", + "packetMode": "DELAYED", + "senderUserId": "10001", + "actualAccount": "10001", + "userNickname": "Tom", + "userAvatar": "https://example.com/avatar.png", + "totalAmount": 15000, + "totalCount": 5, + "remainAmount": 15000, + "remainCount": 5, + "status": "WAITING_OPEN", + "claimStartTime": 1780000000000, + "expireTime": 1780086400000, + "serverTime": 1779999700000 + } +} +``` + +Flutter 收到后: + +- 如果用户仍在 `roomId` 房间,强制弹出该红包或把已展示红包切换为可抢。 +- 不要在客户端发放金额;仍然调用领取接口。 +- 如果本地已经显示该红包,按 `packetNo` 覆盖更新。 + +### 11.3 STATUS_CHANGED + +红包抢完、退款完成或状态变化时,服务端向房间群发 `STATUS_CHANGED`。 + +```json +{ + "type": "ROOM_RED_PACKET", + "data": { + "event": "STATUS_CHANGED", + "scope": "ROOM", + "packetNo": "VRRP2026052712340001", + "packetId": "VRRP2026052712340001", + "roomId": "9001", + "status": "FINISHED", + "remainCount": 0, + "refundedAmount": 0, + "serverTime": 1780000020000 + } +} +``` + +Flutter 收到后: + +- 更新侧边栏数量。 +- 如果当前弹窗或详情页是同一个 `packetNo`,同步刷新状态。 +- `remainCount == 0` 或 `status == FINISHED/REFUNDED` 时不再展示抢按钮。 + +## 12. Flutter 状态模型建议 + +建议本地以 `packetNo` 为 key 存一份红包 read model: + +```dart +class VoiceRoomRedPacket { + final String packetNo; + final String roomId; + final String packetMode; + final String status; + final String senderUserId; + final String account; + final String nickname; + final String avatar; + final int totalAmount; + final int totalCount; + final int remainingAmount; + final int remainingCount; + final int claimStartTimeMs; + final int expireTimeMs; + final bool claimedByMe; + final int myClaimAmount; +} +``` + +解析建议: + +- 对 `senderUserId/userId` 这类 ID,兼容 number 和 string,统一转 string。 +- 对金额和时间,兼容 number 和 string,统一转 int。 +- `CREATED` IM 里的 `remainAmount/remainCount` 对应 HTTP 里的 `remainingAmount/remainingCount`。 +- 如果 IM 和 HTTP 字段冲突,以 HTTP 最新查询结果为准。 + +## 13. 错误码处理 + +| HTTP code | `code` | Flutter 处理 | +| --- | --- | --- | +| 409 | `voice_room_red_packet_disabled` | 隐藏发送入口,提示暂不可用。 | +| 400 | `voice_room_red_packet_amount_invalid` | 刷新配置,提示金额档位已变化。 | +| 400 | `voice_room_red_packet_count_invalid` | 刷新配置,提示份数档位已变化。 | +| 409 | `voice_room_red_packet_daily_limit_exceeded` | 提示今日发送次数已达上限。 | +| 409 | `voice_room_red_packet_insufficient_balance` | 拉起充值或余额不足提示。 | +| 404 | `voice_room_red_packet_not_found` | 关闭弹窗并刷新 active 列表。 | +| 409 | `voice_room_red_packet_not_started` | 展示倒计时,不重试领取。 | +| 409 | `voice_room_red_packet_expired` | 提示已过期,刷新列表和详情。 | +| 409 | `voice_room_red_packet_finished` | 提示已抢完,刷新列表和详情。 | +| 409 | `voice_room_red_packet_room_presence_required` | 提示需在房间内操作,重新走进房或刷新在线态。 | +| 403 | `voice_room_region_not_match` | 用户语区和红包语区不一致,禁止领取。 | +| 409 | `voice_room_region_missing` | 用户资料缺少语区,提示完善资料或重登刷新。 | +| 409 | `claim_processing` | 展示处理中,可稍后重试或打开详情。 | + +## 14. 联调检查清单 + +- 发送普通红包后,当前房间收到 `CREATED scope=ROOM`,payload 带 sender 头像昵称、金额、总份数和剩余份数。 +- 发送延时红包后,当前房间收到 `CREATED scope=ROOM`,当前语区群收到 `CREATED scope=REGION`。 +- 延时红包未到 `claimStartTime` 调领取返回 `voice_room_red_packet_not_started`。 +- 到点后房间收到 `OPEN_DUE`,Flutter 可以强弹并允许点击领取。 +- 用户不在红包所属房间时领取返回 `voice_room_red_packet_room_presence_required`。 +- 同一用户重复领取同一 `packetNo` 返回同一领取结果。 +- 抢完后房间收到 `STATUS_CHANGED`,侧边栏数量减少,抢按钮隐藏。 +- 领取记录页使用 `claim-records?page=&pageSize=`,每条记录有 `account/nickname/avatar`。 +- 重连或重新进房后调用 `active` 恢复侧边栏,不依赖历史 IM 补全状态。