增加礼物tab接口

This commit is contained in:
zhx 2026-05-25 20:58:15 +08:00
parent ccd1967242
commit 9040b4b161
8 changed files with 732 additions and 2 deletions

View File

@ -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 <access_token>
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 <access_token>
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<GiftRecipient> recipients;
final List<GiftTab> tabs;
final List<GiftItem> gifts;
final List<int> quantityPresets;
factory GiftPanel.fromJson(Map<String, dynamic> json) {
return GiftPanel(
coinBalance: (json['coin_balance'] as num?)?.toInt() ?? 0,
recipients: (json['recipients'] as List<dynamic>? ?? const [])
.map((item) => GiftRecipient.fromJson(item as Map<String, dynamic>))
.toList(),
tabs: (json['tabs'] as List<dynamic>? ?? const [])
.map((item) => GiftTab.fromJson(item as Map<String, dynamic>))
.toList(),
gifts: (json['gifts'] as List<dynamic>? ?? const [])
.map((item) => GiftItem.fromJson(item as Map<String, dynamic>))
.toList(),
quantityPresets: (json['quantity_presets'] as List<dynamic>? ?? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
final resource = json['resource'] as Map<String, dynamic>? ?? 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<GiftPanel> 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<String, dynamic>;
if (body['code'] != 'OK') {
throw StateError('${body['code']}: ${body['message']}');
}
return GiftPanel.fromJson(body['data'] as Map<String, dynamic>);
}
Future<Map<String, dynamic>> 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<String, dynamic>;
if (body['code'] != 'OK') {
throw StateError('${body['code']}: ${body['message']}');
}
return body['data'] as Map<String, dynamic>;
}
```
## 客户端状态建议
- 礼物面板缓存以 `room_id` 为粒度;切房间必须重新拉取。
- 房间 `visible_region_id` 变化或用户重新 JoinRoom 后重新拉取礼物面板。
- 礼物图片可以缓存;`animation_url` 建议按需预加载当前 tab 前几项。
- 发送按钮只防重复点击当前 `command_id`,不要全局锁死礼物面板。
- HTTP 超时后不要自动生成新 `command_id`;让用户点击重试时复用原值。
- 礼物墙和礼物 Tab 是两个不同接口:礼物 Tab 是可发送配置,礼物墙是当前用户收到过的礼物聚合。

View File

@ -1898,6 +1898,19 @@ paths:
$ref: "#/responses/BadRequest" $ref: "#/responses/BadRequest"
"502": "502":
$ref: "#/responses/UpstreamError" $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: /api/v1/users/me/resources:
get: get:
tags: tags:
@ -3622,6 +3635,49 @@ definitions:
page_size: page_size:
type: integer type: integer
format: int32 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: UserResourceData:
type: object type: object
properties: properties:
@ -4838,6 +4894,13 @@ definitions:
properties: properties:
data: data:
$ref: "#/definitions/GiftListData" $ref: "#/definitions/GiftListData"
GiftTabListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/GiftTabListData"
UserResourceListEnvelope: UserResourceListEnvelope:
allOf: allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase" - $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -84,6 +84,7 @@
| GET | `/api/v1/resources` | resources | `listResources` | App 资源列表 | | GET | `/api/v1/resources` | resources | `listResources` | App 资源列表 |
| GET | `/api/v1/resource-groups/{group_id}` | resources | `getResourceGroup` | App 资源组详情 | | GET | `/api/v1/resource-groups/{group_id}` | resources | `getResourceGroup` | App 资源组详情 |
| GET | `/api/v1/gifts` | resources | `listGifts` | 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` | 我的资源权益 | | GET | `/api/v1/users/me/resources` | resources | `listMyResources` | 我的资源权益 |
| POST | `/api/v1/users/me/resources/{resource_id}/equip` | resources | `equipMyResource` | 佩戴我的资源 | | POST | `/api/v1/users/me/resources/{resource_id}/equip` | resources | `equipMyResource` | 佩戴我的资源 |
| GET | `/api/v1/messages/tabs` | messages | `listMessageTabs` | 消息 tab 分区摘要 | | GET | `/api/v1/messages/tabs` | messages | `listMessageTabs` | 消息 tab 分区摘要 |

View File

@ -47,6 +47,7 @@ type AppHandlers struct {
GetAppVersion http.HandlerFunc GetAppVersion http.HandlerFunc
GetResourceGroup http.HandlerFunc GetResourceGroup http.HandlerFunc
ListGifts http.HandlerFunc ListGifts http.HandlerFunc
ListGiftTabs http.HandlerFunc
ListEmojiPacks http.HandlerFunc ListEmojiPacks http.HandlerFunc
IssueTencentIMUserSig http.HandlerFunc IssueTencentIMUserSig http.HandlerFunc
IssueTencentRTCToken http.HandlerFunc IssueTencentRTCToken http.HandlerFunc
@ -245,6 +246,7 @@ func (r routes) registerAppRoutes() {
r.public("/app/version", http.MethodGet, h.GetAppVersion) r.public("/app/version", http.MethodGet, h.GetAppVersion)
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup) r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
r.public("/gifts", "", h.ListGifts) r.public("/gifts", "", h.ListGifts)
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks) r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
r.profile("/im/usersig", http.MethodGet, h.IssueTencentIMUserSig) r.profile("/im/usersig", http.MethodGet, h.IssueTencentIMUserSig)
r.profile("/rtc/token", http.MethodPost, h.IssueTencentRTCToken) r.profile("/rtc/token", http.MethodPost, h.IssueTencentRTCToken)

View File

@ -32,6 +32,10 @@ func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
h.listGifts(writer, 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) { func (h *Handler) ListEmojiPacks(writer http.ResponseWriter, request *http.Request) {
h.listEmojiPacks(writer, request) h.listEmojiPacks(writer, request)
} }

View File

@ -91,6 +91,19 @@ type giftConfigData struct {
RegionIDs []int64 `json:"region_ids"` 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 { type emojiPackData struct {
ResourceID int64 `json:"resource_id"` ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"` 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}) 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) { func (h *Handler) listEmojiPacks(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil { if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") 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 { func userResourceFromProto(item *walletv1.UserResourceEntitlement) userResourceData {
if item == nil { if item == nil {
return userResourceData{} return userResourceData{}

View File

@ -368,6 +368,9 @@ type fakeWalletClient struct {
resourcesByID map[int64]*walletv1.Resource resourcesByID map[int64]*walletv1.Resource
lastGetResourceGroup *walletv1.GetResourceGroupRequest lastGetResourceGroup *walletv1.GetResourceGroupRequest
resourceGroupsByID map[int64]*walletv1.ResourceGroup resourceGroupsByID map[int64]*walletv1.ResourceGroup
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
lastGrantResource *walletv1.GrantResourceRequest lastGrantResource *walletv1.GrantResourceRequest
grantResourceResp *walletv1.ResourceGrantResponse grantResourceResp *walletv1.ResourceGrantResponse
grantResourceErr error grantResourceErr error
@ -1148,11 +1151,19 @@ func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.Get
return &walletv1.GetResourceGroupResponse{}, nil 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 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 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) { func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
messageClient := &fakeMessageInboxClient{} messageClient := &fakeMessageInboxClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})

View File

@ -133,6 +133,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
GetAppVersion: appAPI.GetAppVersion, GetAppVersion: appAPI.GetAppVersion,
GetResourceGroup: resourceAPI.GetResourceGroup, GetResourceGroup: resourceAPI.GetResourceGroup,
ListGifts: resourceAPI.ListGifts, ListGifts: resourceAPI.ListGifts,
ListGiftTabs: resourceAPI.ListGiftTabs,
ListEmojiPacks: resourceAPI.ListEmojiPacks, ListEmojiPacks: resourceAPI.ListEmojiPacks,
IssueTencentIMUserSig: h.issueTencentIMUserSig, IssueTencentIMUserSig: h.issueTencentIMUserSig,
IssueTencentRTCToken: roomAPI.IssueTencentRTCToken, IssueTencentRTCToken: roomAPI.IssueTencentRTCToken,