437 lines
14 KiB
Markdown
437 lines
14 KiB
Markdown
# 背包接口 Flutter 对接
|
||
|
||
本文描述 Flutter App 查询当前登录用户背包资源和佩戴装扮的 HTTP 接口。背包事实由 `wallet-service` 维护,App 只通过 gateway 读取当前用户自己的有效资源权益,不传 `user_id`,也不从礼物配置、房间消息或本地缓存推算用户是否拥有某个资源。
|
||
|
||
## 接口
|
||
|
||
Base URL:
|
||
|
||
```text
|
||
https://<gateway-host>
|
||
```
|
||
|
||
本地开发:
|
||
|
||
```text
|
||
http://127.0.0.1:13000
|
||
```
|
||
|
||
| 功能 | Method | Path |
|
||
| --- | --- | --- |
|
||
| 查询我的背包资源 | `GET` | `/api/v1/users/me/resources` |
|
||
| 佩戴我的资源 | `POST` | `/api/v1/users/me/resources/{resource_id}/equip` |
|
||
|
||
## 通用请求
|
||
|
||
Headers:
|
||
|
||
| Header | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `Authorization` | 是 | `Bearer <access_token>` |
|
||
| `Content-Type` | POST 必填 | `application/json` |
|
||
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
|
||
| `X-App-Package` | 否 | 包名解析 App 时使用 |
|
||
| `X-App-Platform` | 否 | `android` / `ios` |
|
||
|
||
统一成功响应:
|
||
|
||
```json
|
||
{
|
||
"code": "OK",
|
||
"message": "ok",
|
||
"request_id": "req_abc",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
统一失败响应:
|
||
|
||
```json
|
||
{
|
||
"code": "INVALID_ARGUMENT",
|
||
"message": "invalid argument",
|
||
"request_id": "req_abc"
|
||
}
|
||
```
|
||
|
||
Flutter 只在 `code == "OK"` 时读取 `data`;失败时把 `request_id` 写入客户端日志,便于后端排查。
|
||
|
||
## 查询我的背包资源
|
||
|
||
```http
|
||
GET /api/v1/users/me/resources?resource_type=avatar_frame
|
||
Authorization: Bearer <access_token>
|
||
X-App-Code: lalu
|
||
```
|
||
|
||
Query:
|
||
|
||
| 字段 | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `resource_type` | 否 | 不传返回全部有效权益;传入时只返回该类型。 |
|
||
|
||
当前可用于背包过滤的资源类型:
|
||
|
||
| 值 | 说明 | 可佩戴 |
|
||
| --- | --- | --- |
|
||
| `avatar_frame` | 头像框 | 是 |
|
||
| `profile_card` | 资料卡 | 是 |
|
||
| `vehicle` | 座驾 | 是 |
|
||
| `chat_bubble` | 气泡 | 是 |
|
||
| `badge` | 徽章 | 是 |
|
||
| `gift` | 礼物权益 | 否 |
|
||
| `floating_screen` | 飘屏 | 否 |
|
||
| `mic_seat_icon` | 麦位图标 | 否 |
|
||
| `mic_seat_animation` | 麦位动画 | 是 |
|
||
| `emoji_pack` | 表情包 | 否 |
|
||
|
||
接口只返回当前有效权益:`status=active`、已到生效时间、未过期、`remaining_quantity > 0`。`expires_at_ms = 0` 表示永久有效。
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"code": "OK",
|
||
"message": "ok",
|
||
"request_id": "req_abc",
|
||
"data": {
|
||
"items": [
|
||
{
|
||
"entitlement_id": "ent_10001_9001_1710000000000",
|
||
"resource_id": 9001,
|
||
"resource": {
|
||
"resource_id": 9001,
|
||
"resource_code": "vip_avatar_frame_1",
|
||
"resource_type": "avatar_frame",
|
||
"name": "VIP 头像框",
|
||
"status": "active",
|
||
"grantable": true,
|
||
"grant_strategy": "extend_expiry",
|
||
"usage_scopes": ["profile", "room"],
|
||
"asset_url": "https://cdn.example/avatar-frame.png",
|
||
"preview_url": "https://cdn.example/avatar-frame-preview.png",
|
||
"animation_url": "",
|
||
"metadata_json": "{}",
|
||
"sort_order": 10,
|
||
"created_at_ms": 1710000000000,
|
||
"updated_at_ms": 1710000000000
|
||
},
|
||
"status": "active",
|
||
"quantity": 1,
|
||
"remaining_quantity": 1,
|
||
"effective_at_ms": 1710000000000,
|
||
"expires_at_ms": 1712592000000,
|
||
"source_grant_id": "grant_xxx",
|
||
"created_at_ms": 1710000000000,
|
||
"updated_at_ms": 1710000000000,
|
||
"equipped": true
|
||
}
|
||
],
|
||
"total": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `items` | 当前登录用户的有效资源权益列表。 |
|
||
| `total` | 当前响应条数,等于 `items.length`;该接口当前不分页。 |
|
||
| `entitlement_id` | 用户拥有的具体权益 ID;同一 `resource_id` 可能有多条不同期限的权益。 |
|
||
| `resource_id` | 资源 ID,用于佩戴接口路径。 |
|
||
| `resource.resource_type` | 资源类型,用于客户端分组展示。 |
|
||
| `resource.asset_url` | 主展示资源,客户端渲染优先使用。 |
|
||
| `resource.preview_url` | 预览资源,可为空。 |
|
||
| `resource.animation_url` | 动效资源,可为空。 |
|
||
| `resource.metadata_json` | 后台配置的扩展 JSON 字符串;客户端按业务需要解析,不能假设一定有字段。 |
|
||
| `quantity` | 发放时的权益数量。 |
|
||
| `remaining_quantity` | 剩余可用数量;大于 0 才会在背包列表返回。 |
|
||
| `effective_at_ms` | 生效时间,Unix epoch milliseconds。 |
|
||
| `expires_at_ms` | 过期时间,Unix epoch milliseconds;`0` 表示永久。 |
|
||
| `equipped` | 当前权益是否是该资源类型的已佩戴项。每个可佩戴 `resource_type` 同一时间最多一条为 `true`。 |
|
||
|
||
## 佩戴我的资源
|
||
|
||
```http
|
||
POST /api/v1/users/me/resources/9001/equip
|
||
Authorization: Bearer <access_token>
|
||
Content-Type: application/json
|
||
X-App-Code: lalu
|
||
```
|
||
|
||
Path:
|
||
|
||
| 字段 | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `resource_id` | 是 | 要佩戴的资源 ID,必须来自当前用户有效背包权益。 |
|
||
|
||
Body:
|
||
|
||
```json
|
||
{
|
||
"entitlement_id": "ent_10001_9001_1710000000000"
|
||
}
|
||
```
|
||
|
||
| 字段 | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `entitlement_id` | 否 | 指定佩戴哪一条权益;不传时后端自动选择该资源当前有效权益。 |
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"code": "OK",
|
||
"message": "ok",
|
||
"request_id": "req_abc",
|
||
"data": {
|
||
"entitlement_id": "ent_10001_9001_1710000000000",
|
||
"resource_id": 9001,
|
||
"resource": {
|
||
"resource_id": 9001,
|
||
"resource_code": "vip_avatar_frame_1",
|
||
"resource_type": "avatar_frame",
|
||
"name": "VIP 头像框",
|
||
"status": "active",
|
||
"asset_url": "https://cdn.example/avatar-frame.png",
|
||
"preview_url": "https://cdn.example/avatar-frame-preview.png",
|
||
"animation_url": "",
|
||
"metadata_json": "{}",
|
||
"sort_order": 10,
|
||
"created_at_ms": 1710000000000,
|
||
"updated_at_ms": 1710000000000
|
||
},
|
||
"status": "active",
|
||
"quantity": 1,
|
||
"remaining_quantity": 1,
|
||
"effective_at_ms": 1710000000000,
|
||
"expires_at_ms": 1712592000000,
|
||
"source_grant_id": "grant_xxx",
|
||
"created_at_ms": 1710000000000,
|
||
"updated_at_ms": 1710000000000,
|
||
"equipped": true
|
||
}
|
||
}
|
||
```
|
||
|
||
佩戴规则:
|
||
|
||
| 规则 | 说明 |
|
||
| --- | --- |
|
||
| 可佩戴类型 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`、`mic_seat_animation`。 |
|
||
| 不可佩戴类型 | `gift`、`floating_screen`、`mic_seat_icon`、`emoji_pack`。 |
|
||
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 |
|
||
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
|
||
| 发放默认佩戴 | `badge`、`mic_seat_animation` 发放成功后默认佩戴。 |
|
||
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me` 和 `/api/v1/badges/display`。 |
|
||
| 资料卡背景 | `profile_card` 只用于装扮资料卡;个人主页背景图使用用户资料字段 `profile_bg_img`,不走背包佩戴。 |
|
||
|
||
常见错误:
|
||
|
||
| HTTP | `code` | 场景 |
|
||
| --- | --- | --- |
|
||
| `400` | `INVALID_ARGUMENT` | `resource_id` 非法、资源类型不可佩戴、请求 JSON 无效。 |
|
||
| `401` | `UNAUTHORIZED` | 未登录或 access token 无效。 |
|
||
| `403` | `PROFILE_REQUIRED` | 当前账号未完成必填资料。 |
|
||
| `404` | `NOT_FOUND` | 当前用户没有这条有效权益,或指定 `entitlement_id` 不存在。 |
|
||
| `502` | `UPSTREAM_ERROR` | gateway 调 wallet-service 失败。 |
|
||
|
||
## Flutter 解析示例
|
||
|
||
```dart
|
||
class ApiEnvelope<T> {
|
||
ApiEnvelope({
|
||
required this.code,
|
||
required this.message,
|
||
required this.requestId,
|
||
this.data,
|
||
});
|
||
|
||
final String code;
|
||
final String message;
|
||
final String requestId;
|
||
final T? data;
|
||
|
||
factory ApiEnvelope.fromJson(
|
||
Map<String, dynamic> json,
|
||
T Function(Object? raw) parseData,
|
||
) {
|
||
return ApiEnvelope<T>(
|
||
code: json['code'] as String? ?? '',
|
||
message: json['message'] as String? ?? '',
|
||
requestId: json['request_id'] as String? ?? '',
|
||
data: json['data'] == null ? null : parseData(json['data']),
|
||
);
|
||
}
|
||
}
|
||
|
||
class BackpackResourceList {
|
||
BackpackResourceList({
|
||
required this.items,
|
||
required this.total,
|
||
});
|
||
|
||
final List<BackpackResourceEntitlement> items;
|
||
final int total;
|
||
|
||
factory BackpackResourceList.fromJson(Object? raw) {
|
||
final json = raw as Map<String, dynamic>? ?? const {};
|
||
return BackpackResourceList(
|
||
items: (json['items'] as List<dynamic>? ?? const [])
|
||
.map((item) => BackpackResourceEntitlement.fromJson(item as Map<String, dynamic>))
|
||
.toList(),
|
||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||
);
|
||
}
|
||
}
|
||
|
||
class BackpackResourceEntitlement {
|
||
BackpackResourceEntitlement({
|
||
required this.entitlementId,
|
||
required this.resourceId,
|
||
required this.resource,
|
||
required this.status,
|
||
required this.quantity,
|
||
required this.remainingQuantity,
|
||
required this.effectiveAtMs,
|
||
required this.expiresAtMs,
|
||
required this.sourceGrantId,
|
||
required this.createdAtMs,
|
||
required this.updatedAtMs,
|
||
required this.equipped,
|
||
});
|
||
|
||
final String entitlementId;
|
||
final int resourceId;
|
||
final BackpackResource resource;
|
||
final String status;
|
||
final int quantity;
|
||
final int remainingQuantity;
|
||
final int effectiveAtMs;
|
||
final int expiresAtMs;
|
||
final String sourceGrantId;
|
||
final int createdAtMs;
|
||
final int updatedAtMs;
|
||
final bool equipped;
|
||
|
||
bool get permanent => expiresAtMs == 0;
|
||
bool get equipable => const {
|
||
'avatar_frame',
|
||
'profile_card',
|
||
'vehicle',
|
||
'chat_bubble',
|
||
'badge',
|
||
}.contains(resource.resourceType);
|
||
|
||
factory BackpackResourceEntitlement.fromJson(Map<String, dynamic> json) {
|
||
return BackpackResourceEntitlement(
|
||
entitlementId: json['entitlement_id'] as String? ?? '',
|
||
resourceId: (json['resource_id'] as num?)?.toInt() ?? 0,
|
||
resource: BackpackResource.fromJson(json['resource'] as Map<String, dynamic>? ?? const {}),
|
||
status: json['status'] as String? ?? '',
|
||
quantity: (json['quantity'] as num?)?.toInt() ?? 0,
|
||
remainingQuantity: (json['remaining_quantity'] as num?)?.toInt() ?? 0,
|
||
effectiveAtMs: (json['effective_at_ms'] as num?)?.toInt() ?? 0,
|
||
expiresAtMs: (json['expires_at_ms'] as num?)?.toInt() ?? 0,
|
||
sourceGrantId: json['source_grant_id'] as String? ?? '',
|
||
createdAtMs: (json['created_at_ms'] as num?)?.toInt() ?? 0,
|
||
updatedAtMs: (json['updated_at_ms'] as num?)?.toInt() ?? 0,
|
||
equipped: json['equipped'] as bool? ?? false,
|
||
);
|
||
}
|
||
}
|
||
|
||
class BackpackResource {
|
||
BackpackResource({
|
||
required this.resourceId,
|
||
required this.resourceCode,
|
||
required this.resourceType,
|
||
required this.name,
|
||
required this.status,
|
||
required this.assetUrl,
|
||
required this.previewUrl,
|
||
required this.animationUrl,
|
||
required this.metadataJson,
|
||
required this.sortOrder,
|
||
});
|
||
|
||
final int resourceId;
|
||
final String resourceCode;
|
||
final String resourceType;
|
||
final String name;
|
||
final String status;
|
||
final String assetUrl;
|
||
final String previewUrl;
|
||
final String animationUrl;
|
||
final String metadataJson;
|
||
final int sortOrder;
|
||
|
||
factory BackpackResource.fromJson(Map<String, dynamic> json) {
|
||
return BackpackResource(
|
||
resourceId: (json['resource_id'] as num?)?.toInt() ?? 0,
|
||
resourceCode: json['resource_code'] as String? ?? '',
|
||
resourceType: json['resource_type'] as String? ?? '',
|
||
name: json['name'] as String? ?? '',
|
||
status: json['status'] as String? ?? '',
|
||
assetUrl: json['asset_url'] as String? ?? '',
|
||
previewUrl: json['preview_url'] as String? ?? '',
|
||
animationUrl: json['animation_url'] as String? ?? '',
|
||
metadataJson: json['metadata_json'] as String? ?? '{}',
|
||
sortOrder: (json['sort_order'] as num?)?.toInt() ?? 0,
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
## Flutter 调用示例
|
||
|
||
```dart
|
||
Future<BackpackResourceList> fetchBackpackResources({
|
||
String? resourceType,
|
||
}) async {
|
||
final response = await dio.get<Map<String, dynamic>>(
|
||
'/api/v1/users/me/resources',
|
||
queryParameters: {
|
||
if (resourceType != null && resourceType.isNotEmpty) 'resource_type': resourceType,
|
||
},
|
||
);
|
||
final envelope = ApiEnvelope<BackpackResourceList>.fromJson(
|
||
response.data ?? const {},
|
||
BackpackResourceList.fromJson,
|
||
);
|
||
if (envelope.code != 'OK' || envelope.data == null) {
|
||
throw ApiException(envelope.code, envelope.message, envelope.requestId);
|
||
}
|
||
return envelope.data!;
|
||
}
|
||
|
||
Future<BackpackResourceEntitlement> equipBackpackResource({
|
||
required int resourceId,
|
||
String? entitlementId,
|
||
}) async {
|
||
final response = await dio.post<Map<String, dynamic>>(
|
||
'/api/v1/users/me/resources/$resourceId/equip',
|
||
data: {
|
||
if (entitlementId != null && entitlementId.isNotEmpty) 'entitlement_id': entitlementId,
|
||
},
|
||
);
|
||
final envelope = ApiEnvelope<BackpackResourceEntitlement>.fromJson(
|
||
response.data ?? const {},
|
||
(raw) => BackpackResourceEntitlement.fromJson(raw as Map<String, dynamic>? ?? const {}),
|
||
);
|
||
if (envelope.code != 'OK' || envelope.data == null) {
|
||
throw ApiException(envelope.code, envelope.message, envelope.requestId);
|
||
}
|
||
return envelope.data!;
|
||
}
|
||
```
|
||
|
||
客户端交互建议:
|
||
|
||
1. 背包首页先请求 `/api/v1/users/me/resources`,本地按 `resource.resource_type` 分组渲染 Tab。
|
||
2. 单类型页面使用 `resource_type` 过滤,减少本地筛选和无关图片预加载。
|
||
3. 佩戴成功后可直接用响应里的 `equipped=true` 更新当前类型列表:同类型其他 item 置为 `false`,当前 item 置为 `true`。
|
||
4. 进入资料页或房间展示前,如需要最新装扮状态,重新拉背包或对应资料展示接口,不依赖旧缓存。
|
||
5. 所有 `*_ms` 都是 Unix epoch milliseconds,展示层按用户本地时区格式化;不要把本地时区传回后端做背包有效期判断。
|