diff --git a/docs/flutter对接/礼物TabFlutter对接.md b/docs/flutter对接/礼物TabFlutter对接.md new file mode 100644 index 00000000..43029e4d --- /dev/null +++ b/docs/flutter对接/礼物TabFlutter对接.md @@ -0,0 +1,529 @@ +# 礼物 Tab Flutter 对接 + +本文描述 Flutter App 在语音房内接入礼物 Tab 的接口、字段、交互和 IM 同步规则。礼物配置、价格、扣费、房间表现和统计事实都以后端为准;App 只展示 gateway 返回的数据,不在本地推算价格、余额、热度或礼物榜。 + +## 边界 + +- 礼物 Tab 基础配置入口是 `GET /api/v1/gift-tabs`,不需要 `room_id`,只返回后台启用的 Tab 配置。 +- `GET /api/v1/gift-tabs` 不按礼物数量过滤;只要后台启用了 Tab,即使该 Tab 下当前没有礼物也必须返回给 App 展示。 +- 房间送礼面板入口是 `GET /api/v1/rooms/{room_id}/gift-panel`,返回当前房间可送礼物、可收礼人、金币余额和房间内可展示 Tab。 +- `GET /api/v1/gift-tabs` 不能替代 `GET /api/v1/rooms/{room_id}/gift-panel`;App 真正送礼前必须以房间面板返回的数据为准。 +- 送礼只调用 `POST /api/v1/rooms/gift/send`。 +- 金币余额以礼物面板、送礼响应后的钱包通知或 `GET /api/v1/wallet/me/balances` 为准。 +- 房间内其他用户看到的礼物表现以腾讯云 IM 自定义消息 `room_gift_sent` 为准。 +- `command_id` 是送礼幂等键;同一次用户点击重试必须复用同一个值。 +- 当前 App 送礼主流程只按单个用户收礼处理:`target_type=user`,`target_user_ids` 只传 1 个用户。 + +## 交互流程 + +1. 用户进入房间并完成 `JoinRoom`。 +2. App 可在启动或进入房间前调用 `GET /api/v1/gift-tabs` 预加载 Tab 配置。 +3. 打开礼物 Tab 时调用 `GET /api/v1/rooms/{room_id}/gift-panel`。 +4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。 +5. 用户选择收礼人、礼物和数量。 +6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send`。 +7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI。 +8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。 +9. 监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。 + +## 获取礼物 Tab 配置 + +```http +GET /api/v1/gift-tabs +X-App-Code: lalu +``` + +成功响应: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_abc", + "data": { + "items": [ + { + "key": "normal", + "gift_type_code": "normal", + "name": "普通礼物", + "label": "Gift", + "tab_key": "Gift", + "status": "active", + "order": 10, + "sort_order": 10, + "created_at_ms": 1710000000000, + "updated_at_ms": 1710000000000 + } + ], + "total": 1 + } +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `key` | string | Tab 稳定 key,等同于礼物类型编码。 | +| `gift_type_code` | string | 礼物类型编码,用于和 `gifts[].gift_type_code` 对齐。 | +| `name` | string | 后台配置的中文显示名。 | +| `label` | string | App Tab 展示名,来源于后台 `tab_key`。 | +| `tab_key` | string | 后台配置的 Tab Name。 | +| `status` | string | 当前只返回启用中的配置,正常为 `active`。 | +| `order` | int | App 排序值,等同于 `sort_order`。 | +| `sort_order` | int | 后台配置排序值。 | +| `created_at_ms` | int64 | 创建时间,Unix epoch milliseconds。 | +| `updated_at_ms` | int64 | 更新时间,Unix epoch milliseconds。 | + +## 获取礼物面板 + +```http +GET /api/v1/rooms/{room_id}/gift-panel +Authorization: Bearer +X-App-Code: lalu +``` + +路径参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前语音房 ID。 | + +成功响应: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_abc", + "data": { + "coin_balance": 1000000, + "recipients": [ + { + "target_type": "user", + "user_id": "10002", + "seat_no": 1, + "label": "Alice", + "profile": { + "user_id": "10002", + "username": "Alice", + "avatar": "https://cdn.example/avatar.png", + "display_user_id": "10002", + "vip": {}, + "level": {}, + "badges": [], + "avatar_frame": {}, + "vehicle": {}, + "charm": 0 + } + } + ], + "tabs": [ + { + "key": "all", + "order": 0 + }, + { + "key": "normal", + "gift_type_code": "normal", + "label": "normal", + "order": 10 + } + ], + "gifts": [ + { + "gift_id": "rose", + "resource_id": 11, + "resource": { + "resource_id": 11, + "resource_code": "gift_rose", + "resource_type": "gift", + "name": "Rose", + "status": "active", + "grantable": true, + "grant_strategy": "increase_quantity", + "usage_scopes": ["gift"], + "asset_url": "https://cdn.example/rose.png", + "preview_url": "", + "animation_url": "https://cdn.example/rose.svga", + "metadata_json": "{}", + "sort_order": 10, + "created_at_ms": 1710000000000, + "updated_at_ms": 1710000000000 + }, + "status": "active", + "name": "Rose", + "sort_order": 10, + "presentation_json": "{}", + "price_version": "v1", + "gift_type_code": "normal", + "charge_asset_type": "COIN", + "coin_price": 100, + "gift_point_amount": 100, + "heat_value": 100, + "effect_types": ["static", "svga"], + "region_ids": [688], + "created_at_ms": 1710000000000, + "updated_at_ms": 1710000000000 + } + ], + "quantity_presets": [1, 9, 99, 999] + } +} +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于打开面板时展示,最终余额仍以后续钱包事实为准。 | +| `recipients` | 当前房间可收礼人。当前主流程只启用 `target_type=user` 的单个用户。 | +| `tabs` | 礼物分类。`key=all` 是全量分类;其他 tab 通常对应 `gift_type_code`。 | +| `gifts` | 当前房间国家维度可用的 active 礼物列表。 | +| `quantity_presets` | 数量快捷档位,当前固定 `[1,9,99,999]`。 | + +礼物字段: + +| 字段 | 说明 | +| --- | --- | +| `gift_id` | 送礼请求使用的礼物 ID。 | +| `name` | 展示名称。 | +| `gift_type_code` | 分类编码,例如 `normal`、`lucky`、`super_lucky`。 | +| `charge_asset_type` | 当前收费资产;普通金币礼物为 `COIN`。 | +| `coin_price` | 单个礼物金币价格。仅用于展示和按钮预估,扣费以后端为准。 | +| `gift_point_amount` | 主播收到的礼物积分增量口径。 | +| `heat_value` | 房间热度增量口径。 | +| `price_version` | 后端价格版本。App 不需要上传。 | +| `presentation_json` | 展示扩展 JSON,例如幸运礼物奖池 ID、角标、动效配置;解析失败时按空对象处理。 | +| `effect_types` | 可用动效类型。App 根据本地能力选择图片、SVGA、Lottie 或兜底静态图。 | +| `resource.asset_url` | 礼物图标。 | +| `resource.preview_url` | 预览图,可为空。 | +| `resource.animation_url` | 礼物动效资源,可为空。 | +| `region_ids` | 后端配置的可用区域;面板接口已经按房间区域过滤,App 不再二次过滤。 | + +## Tab 渲染规则 + +- `tabs` 按 `order` 升序展示。 +- `key=all` 展示全部 `gifts`。 +- 非 all tab 用 `tab.gift_type_code` 或 `tab.key` 过滤 `gift.gift_type_code`。 +- 礼物网格按 `gift.sort_order` 升序展示;相同排序保持接口返回顺序。 +- `coin_price * selected_count > coin_balance` 时发送按钮置灰或引导充值,但最终仍以送礼接口错误为准。 +- `resource.animation_url` 只在发送成功或收到 IM 后播放,不要在列表滚动中自动播放重动效。 + +## 送礼 + +```http +POST /api/v1/rooms/gift/send +Authorization: Bearer +X-App-Code: lalu +Content-Type: application/json +``` + +请求体: + +```json +{ + "room_id": "lalu_room_001", + "command_id": "gift_1779259000000_10001_rose_01", + "target_type": "user", + "target_user_ids": [10002], + "gift_id": "rose", + "gift_count": 9 +} +``` + +参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前房间 ID。 | +| `command_id` | string | 是 | 本次送礼动作幂等键。同一次失败重试必须复用。 | +| `target_type` | string | 否 | 当前传 `user`;为空后端按 `user`。 | +| `target_user_ids` | int64[] | 是 | 当前只能传 1 个收礼用户。 | +| `target_user_id` | int64 | 否 | 旧单目标兼容字段;新代码优先用 `target_user_ids`。 | +| `gift_id` | string | 是 | 礼物 ID。 | +| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 | +| `pool_id` | string | 否 | 幸运礼物奖池 ID;普通礼物不传。 | + +成功响应: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_abc", + "data": { + "result": { + "applied": true, + "room_version": 14, + "server_time_ms": 1779259000000 + }, + "billing_receipt_id": "br_abc", + "room_heat": 123456, + "gift_rank": [ + { + "user_id": "10001", + "score": 900, + "gift_value": 900, + "updated_at_ms": 1779259000000 + } + ], + "room": {}, + "treasure": {} + } +} +``` + +响应字段: + +| 字段 | 说明 | +| --- | --- | +| `result.applied` | 本次命令是否实际落地。重复提交同一个 `command_id` 时可能返回 `false`,App 不要重复播放发送者本地扣费动画。 | +| `result.room_version` | 房间版本,可用于丢弃旧房间快照。 | +| `result.server_time_ms` | 服务端时间。 | +| `billing_receipt_id` | 钱包扣费回执;非空表示扣费和房间表现已提交。 | +| `room_heat` | 最新房间热度。 | +| `gift_rank` | 最新房间礼物榜。 | +| `room` | 房间快照,字段较大;App 可按已有房间快照解析逻辑局部更新。 | +| `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 | + +发送后处理: + +- 成功后立即关闭 loading,保留礼物面板或按产品交互关闭。 +- 用 `room_heat` 和 `gift_rank` 更新房间顶部和贡献榜。 +- 播放当前用户送礼动效时按 `command_id` 去重,避免 HTTP 响应和 IM 各播放一次。 +- 不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。 + +## 房间 IM 同步 + +送礼成功后,房间群会收到腾讯云 IM 自定义消息: + +| 字段 | 值 | +| --- | --- | +| `MsgContent.Desc` | `room_gift_sent` | +| `Data.event_type` | `room_gift_sent` | + +当前消息核心字段来自 room outbox: + +```json +{ + "event_type": "room_gift_sent", + "event_id": "room_event_xxx", + "room_id": "lalu_room_001", + "room_version": 14, + "occurred_at_ms": 1779259000000, + "actor_user_id": "10001", + "target_user_id": "10002", + "gift_value": 900, + "attributes": { + "gift_id": "rose", + "gift_count": "9", + "billing_receipt_id": "br_abc" + } +} +``` + +处理规则: + +- 按 `event_id` 去重。 +- `event_type != room_gift_sent` 时交给其他房间消息处理器。 +- `actor_user_id` 是送礼人,`target_user_id` 是收礼人。 +- `attributes.gift_id` 对应面板里的礼物 ID;本地找不到配置时展示兜底礼物图标。 +- `attributes.gift_count` 当前是字符串,Flutter 解析时需要 `int.tryParse`。 +- `gift_value` 是本次房间价值/热度口径,不等同于客户端自己算的 `coin_price * count`。 +- 自己发送的礼物如果已经由 HTTP 成功响应播放过,收到同一 `command_id` 或短时间同一礼物 IM 时要做播放去重。 + +## 错误处理 + +外层 envelope: + +```json +{ + "code": "INVALID_ARGUMENT", + "message": "invalid argument", + "request_id": "req_abc", + "data": null +} +``` + +处理规则: + +| 场景 | 处理 | +| --- | --- | +| `UNAUTHENTICATED` | 重新登录或刷新 token。 | +| `INVALID_ARGUMENT` | 参数错误;记录 `request_id`,检查 `room_id`、`gift_id`、数量和收礼人。 | +| `NOT_FOUND` | 房间或礼物不存在;刷新房间和礼物面板。 | +| `FAILED_PRECONDITION` | 用户不在房间、房间不可送礼或状态不允许;刷新房间详情。 | +| `INSUFFICIENT_BALANCE` | 金币不足;跳转充值或刷新余额。 | +| `CONFLICT` | 幂等冲突或命令状态冲突;复用原 `command_id` 查询/重试,不生成新命令导致重复送礼。 | +| `UPSTREAM_ERROR` / 5xx | 保留原 `command_id` 允许用户重试。 | + +## Flutter 数据模型示例 + +```dart +class GiftPanel { + GiftPanel({ + required this.coinBalance, + required this.recipients, + required this.tabs, + required this.gifts, + required this.quantityPresets, + }); + + final int coinBalance; + final List recipients; + final List tabs; + final List gifts; + final List quantityPresets; + + factory GiftPanel.fromJson(Map json) { + return GiftPanel( + coinBalance: (json['coin_balance'] as num?)?.toInt() ?? 0, + recipients: (json['recipients'] as List? ?? const []) + .map((item) => GiftRecipient.fromJson(item as Map)) + .toList(), + tabs: (json['tabs'] as List? ?? const []) + .map((item) => GiftTab.fromJson(item as Map)) + .toList(), + gifts: (json['gifts'] as List? ?? const []) + .map((item) => GiftItem.fromJson(item as Map)) + .toList(), + quantityPresets: (json['quantity_presets'] as List? ?? const []) + .map((item) => (item as num).toInt()) + .toList(), + ); + } +} + +class GiftRecipient { + GiftRecipient({ + required this.targetType, + required this.userId, + required this.seatNo, + required this.label, + }); + + final String targetType; + final String userId; + final int seatNo; + final String label; + + factory GiftRecipient.fromJson(Map json) { + return GiftRecipient( + targetType: json['target_type'] as String? ?? '', + userId: json['user_id'] as String? ?? '', + seatNo: (json['seat_no'] as num?)?.toInt() ?? 0, + label: json['label'] as String? ?? '', + ); + } +} + +class GiftTab { + GiftTab({ + required this.key, + required this.giftTypeCode, + required this.label, + required this.order, + }); + + final String key; + final String giftTypeCode; + final String label; + final int order; + + factory GiftTab.fromJson(Map json) { + return GiftTab( + key: json['key'] as String? ?? '', + giftTypeCode: json['gift_type_code'] as String? ?? '', + label: json['label'] as String? ?? '', + order: (json['order'] as num?)?.toInt() ?? 0, + ); + } +} + +class GiftItem { + GiftItem({ + required this.giftId, + required this.name, + required this.giftTypeCode, + required this.coinPrice, + required this.assetUrl, + required this.animationUrl, + required this.presentationJson, + }); + + final String giftId; + final String name; + final String giftTypeCode; + final int coinPrice; + final String assetUrl; + final String animationUrl; + final String presentationJson; + + factory GiftItem.fromJson(Map json) { + final resource = json['resource'] as Map? ?? const {}; + return GiftItem( + giftId: json['gift_id'] as String? ?? '', + name: json['name'] as String? ?? '', + giftTypeCode: json['gift_type_code'] as String? ?? '', + coinPrice: (json['coin_price'] as num?)?.toInt() ?? 0, + assetUrl: resource['asset_url'] as String? ?? '', + animationUrl: resource['animation_url'] as String? ?? '', + presentationJson: json['presentation_json'] as String? ?? '{}', + ); + } +} +``` + +## Flutter 调用示例 + +```dart +Future fetchGiftPanel({ + required Dio dio, + required String roomId, +}) async { + final response = await dio.get('/api/v1/rooms/$roomId/gift-panel'); + final body = response.data as Map; + if (body['code'] != 'OK') { + throw StateError('${body['code']}: ${body['message']}'); + } + return GiftPanel.fromJson(body['data'] as Map); +} + +Future> sendGift({ + required Dio dio, + required String roomId, + required String commandId, + required int targetUserId, + required String giftId, + required int giftCount, + String? poolId, +}) async { + final response = await dio.post( + '/api/v1/rooms/gift/send', + data: { + 'room_id': roomId, + 'command_id': commandId, + 'target_type': 'user', + 'target_user_ids': [targetUserId], + 'gift_id': giftId, + 'gift_count': giftCount, + if (poolId != null && poolId.isNotEmpty) 'pool_id': poolId, + }, + ); + final body = response.data as Map; + if (body['code'] != 'OK') { + throw StateError('${body['code']}: ${body['message']}'); + } + return body['data'] as Map; +} +``` + +## 客户端状态建议 + +- 礼物面板缓存以 `room_id` 为粒度;切房间必须重新拉取。 +- 房间 `visible_region_id` 变化或用户重新 JoinRoom 后重新拉取礼物面板。 +- 礼物图片可以缓存;`animation_url` 建议按需预加载当前 tab 前几项。 +- 发送按钮只防重复点击当前 `command_id`,不要全局锁死礼物面板。 +- HTTP 超时后不要自动生成新 `command_id`;让用户点击重试时复用原值。 +- 礼物墙和礼物 Tab 是两个不同接口:礼物 Tab 是可发送配置,礼物墙是当前用户收到过的礼物聚合。 diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index a0398997..ff8e4f4a 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -1898,6 +1898,19 @@ paths: $ref: "#/responses/BadRequest" "502": $ref: "#/responses/UpstreamError" + /api/v1/gift-tabs: + get: + tags: + - resources + summary: App 礼物 Tab 配置列表 + operationId: listGiftTabs + responses: + "200": + description: 返回后台启用的礼物 Tab 配置;不按礼物数量过滤,即使 Tab 下当前没有礼物也返回。 + schema: + $ref: "#/definitions/GiftTabListEnvelope" + "502": + $ref: "#/responses/UpstreamError" /api/v1/users/me/resources: get: tags: @@ -3622,6 +3635,49 @@ definitions: page_size: type: integer format: int32 + GiftTabData: + type: object + properties: + key: + type: string + description: Tab 稳定 key,等同于礼物类型编码。 + gift_type_code: + type: string + description: 礼物类型编码,用于和 gifts[].gift_type_code 对齐。 + name: + type: string + description: 后台配置的中文显示名。 + label: + type: string + description: App Tab 展示名,来源于后台 tab_key。 + tab_key: + type: string + description: 后台配置的 Tab Name。 + status: + type: string + order: + type: integer + format: int32 + description: App 排序值,等同于 sort_order。 + sort_order: + type: integer + format: int32 + created_at_ms: + type: integer + format: int64 + updated_at_ms: + type: integer + format: int64 + GiftTabListData: + type: object + properties: + items: + type: array + items: + $ref: "#/definitions/GiftTabData" + total: + type: integer + format: int32 UserResourceData: type: object properties: @@ -4838,6 +4894,13 @@ definitions: properties: data: $ref: "#/definitions/GiftListData" + GiftTabListEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/GiftTabListData" UserResourceListEnvelope: allOf: - $ref: "#/definitions/GatewayOKEnvelopeBase" diff --git a/docs/接口清单.md b/docs/接口清单.md index 9c8fcd6c..92081f0e 100644 --- a/docs/接口清单.md +++ b/docs/接口清单.md @@ -84,6 +84,7 @@ | GET | `/api/v1/resources` | resources | `listResources` | App 资源列表 | | GET | `/api/v1/resource-groups/{group_id}` | resources | `getResourceGroup` | App 资源组详情 | | GET | `/api/v1/gifts` | resources | `listGifts` | App 礼物列表 | +| GET | `/api/v1/gift-tabs` | resources | `listGiftTabs` | App 礼物 Tab 配置列表,不依赖房间 | | GET | `/api/v1/users/me/resources` | resources | `listMyResources` | 我的资源权益 | | POST | `/api/v1/users/me/resources/{resource_id}/equip` | resources | `equipMyResource` | 佩戴我的资源 | | GET | `/api/v1/messages/tabs` | messages | `listMessageTabs` | 消息 tab 分区摘要 | diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 6c7c8085..23653ffe 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -47,6 +47,7 @@ type AppHandlers struct { GetAppVersion http.HandlerFunc GetResourceGroup http.HandlerFunc ListGifts http.HandlerFunc + ListGiftTabs http.HandlerFunc ListEmojiPacks http.HandlerFunc IssueTencentIMUserSig http.HandlerFunc IssueTencentRTCToken http.HandlerFunc @@ -245,6 +246,7 @@ func (r routes) registerAppRoutes() { r.public("/app/version", http.MethodGet, h.GetAppVersion) r.public("/resource-groups/{group_id}", "", h.GetResourceGroup) r.public("/gifts", "", h.ListGifts) + r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs) r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks) r.profile("/im/usersig", http.MethodGet, h.IssueTencentIMUserSig) r.profile("/rtc/token", http.MethodPost, h.IssueTencentRTCToken) diff --git a/services/gateway-service/internal/transport/http/resourceapi/handler.go b/services/gateway-service/internal/transport/http/resourceapi/handler.go index 159cc68f..09712291 100644 --- a/services/gateway-service/internal/transport/http/resourceapi/handler.go +++ b/services/gateway-service/internal/transport/http/resourceapi/handler.go @@ -32,6 +32,10 @@ func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) { h.listGifts(writer, request) } +func (h *Handler) ListGiftTabs(writer http.ResponseWriter, request *http.Request) { + h.listGiftTabs(writer, request) +} + func (h *Handler) ListEmojiPacks(writer http.ResponseWriter, request *http.Request) { h.listEmojiPacks(writer, request) } diff --git a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go index 17644157..179e65a0 100644 --- a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go +++ b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go @@ -91,6 +91,19 @@ type giftConfigData struct { RegionIDs []int64 `json:"region_ids"` } +type giftTabData struct { + Key string `json:"key"` + GiftTypeCode string `json:"gift_type_code"` + Name string `json:"name"` + Label string `json:"label"` + TabKey string `json:"tab_key"` + Status string `json:"status"` + Order int32 `json:"order"` + SortOrder int32 `json:"sort_order"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` +} + type emojiPackData struct { ResourceID int64 `json:"resource_id"` ResourceCode string `json:"resource_code"` @@ -218,6 +231,27 @@ func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) { httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize}) } +func (h *Handler) listGiftTabs(writer http.ResponseWriter, request *http.Request) { + if h.walletClient == nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + resp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{ + RequestId: httpkit.RequestIDFromContext(request.Context()), + AppCode: appcode.FromContext(request.Context()), + ActiveOnly: true, + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + items := make([]giftTabData, 0, len(resp.GetGiftTypes())) + for _, item := range resp.GetGiftTypes() { + items = append(items, giftTabFromProto(item)) + } + httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)}) +} + func (h *Handler) listEmojiPacks(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") @@ -411,6 +445,24 @@ func giftFromProto(gift *walletv1.GiftConfig) giftConfigData { } } +func giftTabFromProto(item *walletv1.GiftTypeConfig) giftTabData { + if item == nil { + return giftTabData{} + } + return giftTabData{ + Key: item.GetTypeCode(), + GiftTypeCode: item.GetTypeCode(), + Name: item.GetName(), + Label: item.GetTabKey(), + TabKey: item.GetTabKey(), + Status: item.GetStatus(), + Order: item.GetSortOrder(), + SortOrder: item.GetSortOrder(), + CreatedAtMS: item.GetCreatedAtMs(), + UpdatedAtMS: item.GetUpdatedAtMs(), + } +} + func userResourceFromProto(item *walletv1.UserResourceEntitlement) userResourceData { if item == nil { return userResourceData{} diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 2f713947..10700d83 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -368,6 +368,9 @@ type fakeWalletClient struct { resourcesByID map[int64]*walletv1.Resource lastGetResourceGroup *walletv1.GetResourceGroupRequest resourceGroupsByID map[int64]*walletv1.ResourceGroup + lastListGiftConfigs *walletv1.ListGiftConfigsRequest + lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest + listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse lastGrantResource *walletv1.GrantResourceRequest grantResourceResp *walletv1.ResourceGrantResponse grantResourceErr error @@ -1148,11 +1151,19 @@ func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.Get return &walletv1.GetResourceGroupResponse{}, nil } -func (f *fakeWalletClient) ListGiftConfigs(context.Context, *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { +func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { + f.lastListGiftConfigs = req return &walletv1.ListGiftConfigsResponse{}, nil } -func (f *fakeWalletClient) ListGiftTypeConfigs(context.Context, *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) { +func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) { + f.lastListGiftTypes = req + if f.err != nil { + return nil, f.err + } + if f.listGiftTypesResp != nil { + return f.listGiftTypesResp, nil + } return &walletv1.ListGiftTypeConfigsResponse{}, nil } @@ -3239,6 +3250,73 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) { } } +func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) { + walletClient := &fakeWalletClient{ + listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{ + GiftTypes: []*walletv1.GiftTypeConfig{ + { + TypeCode: "normal", + Name: "普通礼物", + TabKey: "Gift", + Status: "active", + SortOrder: 10, + CreatedAtMs: 1_700_000_000_000, + UpdatedAtMs: 1_700_000_000_100, + }, + { + TypeCode: "lucky", + Name: "幸运礼物", + TabKey: "Lucky", + Status: "active", + SortOrder: 30, + }, + }, + }, + } + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetWalletClient(walletClient) + router := handler.Routes(auth.NewVerifier("secret")) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/gift-tabs", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("gift tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Code string `json:"code"` + Data struct { + Items []struct { + Key string `json:"key"` + GiftTypeCode string `json:"gift_type_code"` + Name string `json:"name"` + Label string `json:"label"` + TabKey string `json:"tab_key"` + Status string `json:"status"` + Order int32 `json:"order"` + SortOrder int32 `json:"sort_order"` + UpdatedAtMS int64 `json:"updated_at_ms"` + } `json:"items"` + Total int `json:"total"` + } `json:"data"` + } + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatalf("decode gift tabs failed: %v", err) + } + if walletClient.lastListGiftTypes == nil || walletClient.lastListGiftTypes.GetAppCode() != "lalu" || !walletClient.lastListGiftTypes.GetActiveOnly() { + t.Fatalf("gift tabs request mismatch: %+v", walletClient.lastListGiftTypes) + } + if walletClient.lastListGiftConfigs != nil { + t.Fatalf("gift tabs must not filter tabs by gift configs: %+v", walletClient.lastListGiftConfigs) + } + if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 { + t.Fatalf("gift tabs envelope mismatch: %+v", envelope) + } + if envelope.Data.Items[0].Key != "normal" || envelope.Data.Items[0].GiftTypeCode != "normal" || envelope.Data.Items[0].Name != "普通礼物" || envelope.Data.Items[0].Label != "Gift" || envelope.Data.Items[0].TabKey != "Gift" || envelope.Data.Items[0].Order != 10 || envelope.Data.Items[0].SortOrder != 10 || envelope.Data.Items[0].UpdatedAtMS != 1_700_000_000_100 { + t.Fatalf("first gift tab mismatch: %+v", envelope.Data.Items[0]) + } +} + func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) { messageClient := &fakeMessageInboxClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 75f4ff4f..2501effc 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -133,6 +133,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { GetAppVersion: appAPI.GetAppVersion, GetResourceGroup: resourceAPI.GetResourceGroup, ListGifts: resourceAPI.ListGifts, + ListGiftTabs: resourceAPI.ListGiftTabs, ListEmojiPacks: resourceAPI.ListEmojiPacks, IssueTencentIMUserSig: h.issueTencentIMUserSig, IssueTencentRTCToken: roomAPI.IssueTencentRTCToken,