hyapp-server/docs/flutter对接/表情包Flutter对接.md
2026-05-16 08:12:42 +08:00

8.6 KiB
Raw Blame History

表情包 Flutter 对接

本文描述 Flutter App 拉取表情包列表的 HTTP 接口。表情包由后台管理维护App 只读取 gateway 返回的已启用资源;免费/付费字段用于展示和后续业务分流,不在本接口内完成购买或授权判断。

接口

Base URL

https://<gateway-host>

本地开发:

http://127.0.0.1:13000
功能 Method Path 鉴权
表情包列表 GET /api/v1/emoji-packs 不需要登录

请求头:

Header 必填 说明
X-App-Code App 编码,默认 lalu
X-App-Package 包名解析 App 时使用。
X-App-Platform android / ios

示例:

GET /api/v1/emoji-packs?page=1&page_size=20&region_id=1001
X-App-Code: lalu
X-App-Platform: ios

请求参数

Query

字段 必填 默认 说明
page 1 页码,从 1 开始;必须是正整数。
page_size 20 每页数量必须是正整数。Flutter 端统一使用该字段。
keyword 按表情包名称或资源编码检索。
region_id 不过滤 用户当前区域 ID传入后返回全局表情包和该区域可用表情包。缺省或传 0 返回全部已启用表情包。

返回值

成功响应:

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "items": [
      {
        "resource_id": 162,
        "resource_code": "emoji_pack_1778828784753000000",
        "name": "Rocket",
        "category": "热门",
        "pricing_type": "paid",
        "cover_url": "https://cdn.example/emoji/rocket.png",
        "animation_url": "https://cdn.example/emoji/rocket.svga",
        "region_ids": [1001],
        "is_global": false,
        "sort_order": 10,
        "updated_at_ms": 1778828784753
      }
    ],
    "total": 1,
    "page": 1,
    "page_size": 20
  }
}

字段规则:

字段 说明
items 当前页表情包列表,只包含后台启用状态的表情包。
total 当前 query 条件下的总数,用于分页。
resource_id 表情包资源 ID后续发送、展示或授权判断应使用该 ID。
resource_code 资源编码,主要用于日志和排查,不建议作为业务主键。
name 表情包名称。
category 表情包分类,用于列表分组或筛选;历史数据缺省时返回 默认
pricing_type free / paid;客户端展示为免费/付费标签。
cover_url 静态封面图,优先用于列表和面板入口。
animation_url 动态资源地址,文件类型由后台上传内容决定,可能是 webpsvgapag 或其他客户端已支持格式。
region_ids 可用区域 ID空数组表示全局可用。
is_global true 表示全局可用。
sort_order 后台排序值,列表已经按服务端资源顺序返回,客户端不要重新反向排序。
updated_at_ms Unix epoch milliseconds可作为本地缓存刷新依据。

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? value) parseData,
  ) {
    return ApiEnvelope<T>(
      code: json['code'] as String? ?? '',
      message: json['message'] as String? ?? '',
      requestId: json['request_id'] as String? ?? '',
      data: json.containsKey('data') ? parseData(json['data']) : null,
    );
  }
}

class EmojiPackPage {
  EmojiPackPage({
    required this.items,
    required this.total,
    required this.page,
    required this.pageSize,
  });

  final List<EmojiPackItem> items;
  final int total;
  final int page;
  final int pageSize;

  factory EmojiPackPage.fromJson(Map<String, dynamic> json) {
    return EmojiPackPage(
      items: (json['items'] as List<dynamic>? ?? const [])
          .whereType<Map<String, dynamic>>()
          .map(EmojiPackItem.fromJson)
          .toList(),
      total: (json['total'] as num?)?.toInt() ?? 0,
      page: (json['page'] as num?)?.toInt() ?? 1,
      pageSize: (json['page_size'] as num?)?.toInt() ?? 20,
    );
  }
}

class EmojiPackItem {
  EmojiPackItem({
    required this.resourceId,
    required this.resourceCode,
    required this.name,
    required this.category,
    required this.pricingType,
    required this.coverUrl,
    required this.animationUrl,
    required this.regionIds,
    required this.isGlobal,
    required this.sortOrder,
    required this.updatedAtMs,
  });

  final int resourceId;
  final String resourceCode;
  final String name;
  final String category;
  final String pricingType;
  final String coverUrl;
  final String animationUrl;
  final List<int> regionIds;
  final bool isGlobal;
  final int sortOrder;
  final int updatedAtMs;

  bool get isPaid => pricingType == 'paid';
  String get pricingLabel => isPaid ? '付费' : '免费';

  factory EmojiPackItem.fromJson(Map<String, dynamic> json) {
    return EmojiPackItem(
      resourceId: (json['resource_id'] as num?)?.toInt() ?? 0,
      resourceCode: json['resource_code'] as String? ?? '',
      name: json['name'] as String? ?? '',
      category: json['category'] as String? ?? '默认',
      pricingType: json['pricing_type'] as String? ?? 'free',
      coverUrl: json['cover_url'] as String? ?? '',
      animationUrl: json['animation_url'] as String? ?? '',
      regionIds: (json['region_ids'] as List<dynamic>? ?? const [])
          .whereType<num>()
          .map((value) => value.toInt())
          .toList(),
      isGlobal: json['is_global'] as bool? ?? false,
      sortOrder: (json['sort_order'] as num?)?.toInt() ?? 0,
      updatedAtMs: (json['updated_at_ms'] as num?)?.toInt() ?? 0,
    );
  }
}

调用示例:

class ApiException implements Exception {
  ApiException({
    required this.code,
    required this.message,
    required this.requestId,
  });

  final String code;
  final String message;
  final String requestId;

  @override
  String toString() => 'ApiException($code, requestId: $requestId)';
}

Future<EmojiPackPage> fetchEmojiPacks({
  required Dio dio,
  String appCode = 'lalu',
  int page = 1,
  int pageSize = 20,
  int? regionId,
  String keyword = '',
}) async {
  final response = await dio.get<Map<String, dynamic>>(
    '/api/v1/emoji-packs',
    queryParameters: <String, dynamic>{
      'page': page,
      'page_size': pageSize,
      if (regionId != null) 'region_id': regionId,
      if (keyword.trim().isNotEmpty) 'keyword': keyword.trim(),
    },
    options: Options(headers: <String, String>{'X-App-Code': appCode}),
  );

  final envelope = ApiEnvelope<EmojiPackPage>.fromJson(
    response.data ?? const <String, dynamic>{},
    (value) => EmojiPackPage.fromJson(value as Map<String, dynamic>),
  );
  if (envelope.code != 'OK' || envelope.data == null) {
    throw ApiException(
      code: envelope.code,
      message: envelope.message,
      requestId: envelope.requestId,
    );
  }
  return envelope.data!;
}

错误处理

所有响应先解析外层 envelope。只有 HTTP status 为 200code == "OK" 时读取 data;其他情况不要使用 data

HTTP status code 处理方式
400 INVALID_ARGUMENT 请求参数非法,例如 pagepage_sizeregion_id 不是合法数字;客户端修正参数后再请求,不做自动重试。
502 UPSTREAM_ERROR 后端依赖服务不可用;展示上次缓存或空态,允许用户手动重试,可做指数退避。
500 INTERNAL_ERROR 服务端未知错误;展示通用错误,把 request_id 写入客户端日志用于排查。
网络错误 无 envelope 超时、断网或 TLS 失败;优先展示缓存,恢复网络后重新拉取当前页。

失败响应示例:

{
  "code": "INVALID_ARGUMENT",
  "message": "invalid argument",
  "request_id": "req_bad"
}

客户端缓存建议:

场景 建议
首次进入表情面板 请求 page=1&page_size=20,有用户区域时带 region_id
本地缓存 key 至少包含 app_coderegion_idkeywordpagepage_size
刷新判断 同一个 resource_idupdated_at_ms 变大时刷新本地资源和封面缓存。
付费展示 pricing_type == "paid" 只表示付费标签;能否发送该表情包仍以发送表情或用户资源授权接口的服务端结果为准。