# 表情包 Flutter 对接

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

## 接口

Base URL：

```text
https://<gateway-host>
```

本地开发：

```text
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`。 |

示例：

```http
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` 返回全部已启用表情包。 |

## 返回值

成功响应：

```json
{
  "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` | 动态资源地址，文件类型由后台上传内容决定，可能是 `webp`、`svga`、`pag` 或其他客户端已支持格式。 |
| `region_ids` | 可用区域 ID；空数组表示全局可用。 |
| `is_global` | `true` 表示全局可用。 |
| `sort_order` | 后台排序值，列表已经按服务端资源顺序返回，客户端不要重新反向排序。 |
| `updated_at_ms` | Unix epoch milliseconds，可作为本地缓存刷新依据。 |

## 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? 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,
    );
  }
}
```

调用示例：

```dart
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 为 `200` 且 `code == "OK"` 时读取 `data`；其他情况不要使用 `data`。

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

失败响应示例：

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

客户端缓存建议：

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