hyapp-server/docs/flutter对接/背包接口Flutter对接.md

436 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 背包接口 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`。 |
常见错误:
| 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展示层按用户本地时区格式化不要把本地时区传回后端做背包有效期判断。