# 背包接口 Flutter 对接 本文描述 Flutter App 查询当前登录用户背包资源和佩戴装扮的 HTTP 接口。背包事实由 `wallet-service` 维护,App 只通过 gateway 读取当前用户自己的有效资源权益,不传 `user_id`,也不从礼物配置、房间消息或本地缓存推算用户是否拥有某个资源。 ## 接口 Base URL: ```text https:// ``` 本地开发: ```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 ` | | `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 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 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`。 | | 不可佩戴类型 | `gift`、`floating_screen`、`mic_seat_icon`、`mic_seat_animation`、`emoji_pack`。 | | 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 | | 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 | | 徽章展示 | 背包佩戴的 `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 { 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 json, T Function(Object? raw) parseData, ) { return ApiEnvelope( 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 items; final int total; factory BackpackResourceList.fromJson(Object? raw) { final json = raw as Map? ?? const {}; return BackpackResourceList( items: (json['items'] as List? ?? const []) .map((item) => BackpackResourceEntitlement.fromJson(item as Map)) .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 json) { return BackpackResourceEntitlement( entitlementId: json['entitlement_id'] as String? ?? '', resourceId: (json['resource_id'] as num?)?.toInt() ?? 0, resource: BackpackResource.fromJson(json['resource'] as Map? ?? 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 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 fetchBackpackResources({ String? resourceType, }) async { final response = await dio.get>( '/api/v1/users/me/resources', queryParameters: { if (resourceType != null && resourceType.isNotEmpty) 'resource_type': resourceType, }, ); final envelope = ApiEnvelope.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 equipBackpackResource({ required int resourceId, String? entitlementId, }) async { final response = await dio.post>( '/api/v1/users/me/resources/$resourceId/equip', data: { if (entitlementId != null && entitlementId.isNotEmpty) 'entitlement_id': entitlementId, }, ); final envelope = ApiEnvelope.fromJson( response.data ?? const {}, (raw) => BackpackResourceEntitlement.fromJson(raw as Map? ?? 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,展示层按用户本地时区格式化;不要把本地时区传回后端做背包有效期判断。