增加礼物tab接口
This commit is contained in:
parent
ccd1967242
commit
9040b4b161
529
docs/flutter对接/礼物TabFlutter对接.md
Normal file
529
docs/flutter对接/礼物TabFlutter对接.md
Normal 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 是可发送配置,礼物墙是当前用户收到过的礼物聚合。
|
||||
@ -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"
|
||||
|
||||
@ -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 分区摘要 |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user