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

14 KiB
Raw Blame History

背包接口 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 > 0expires_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 milliseconds0 表示永久。
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_frameprofile_cardvehiclechat_bubblebadgemic_seat_animation
不可佩戴类型 giftfloating_screenmic_seat_iconemoji_pack
同类替换 同一个 resource_type 只有一个当前佩戴项,新佩戴会替换旧佩戴。
权益校验 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。
发放默认佩戴 badgemic_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 解析示例

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!;
}

客户端交互建议:

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