# 背包接口 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` | 当前权益是否在现有权限下有效佩戴。VIP 失效后历史权益仍可返回，但该字段为 `false`，客户端不得继续渲染；每个可佩戴 `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_icon`、`mic_seat_animation`。 |
| 不可佩戴类型 | `gift`、`floating_screen`、`emoji_pack`。 |
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项，新佩戴会替换旧佩戴。 |
| 权益校验 | 必须是当前登录用户自己的有效权益；过期、禁用、剩余数量为 0 都不能佩戴。 |
| 发放默认佩戴 | `badge`、`mic_seat_animation` 发放成功后默认佩戴。 |
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源；资料页多徽章展示仍使用 `/api/v1/badges/me` 和 `/api/v1/badges/display`。 |
| 资料卡背景 | `profile_card` 只用于装扮资料卡；个人主页背景图使用用户资料字段 `profile_bg_img`，不走背包佩戴。 |

`mic_seat_icon` 和 `mic_seat_animation` 的 `resource_type` 不同，因此分别单选、分别取消。前者只替换麦位中心图标，后者只控制 Voice Wave；佩戴或取消任一类型都不能修改另一类型的装备状态。

常见错误：

| 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，展示层按用户本地时区格式化；不要把本地时区传回后端做背包有效期判断。
