14 KiB
14 KiB
背包接口 Flutter 对接
本文描述 Flutter App 查询当前登录用户背包资源和佩戴装扮的 HTTP 接口。背包事实由 wallet-service 维护,App 只通过 gateway 读取当前用户自己的有效资源权益,不传 user_id,也不从礼物配置、房间消息或本地缓存推算用户是否拥有某个资源。
接口
Base URL:
https://<gateway-host>
本地开发:
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 |
统一成功响应:
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {}
}
统一失败响应:
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_abc"
}
Flutter 只在 code == "OK" 时读取 data;失败时把 request_id 写入客户端日志,便于后端排查。
查询我的背包资源
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 表示永久有效。
成功响应:
{
"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。 |
佩戴我的资源
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:
{
"entitlement_id": "ent_10001_9001_1710000000000"
}
| 字段 | 必填 | 说明 |
|---|---|---|
entitlement_id |
否 | 指定佩戴哪一条权益;不传时后端自动选择该资源当前有效权益。 |
成功响应:
{
"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 解析示例
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 调用示例
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!;
}
客户端交互建议:
- 背包首页先请求
/api/v1/users/me/resources,本地按resource.resource_type分组渲染 Tab。 - 单类型页面使用
resource_type过滤,减少本地筛选和无关图片预加载。 - 佩戴成功后可直接用响应里的
equipped=true更新当前类型列表:同类型其他 item 置为false,当前 item 置为true。 - 进入资料页或房间展示前,如需要最新装扮状态,重新拉背包或对应资料展示接口,不依赖旧缓存。
- 所有
*_ms都是 Unix epoch milliseconds,展示层按用户本地时区格式化;不要把本地时区传回后端做背包有效期判断。