# 用户排行榜 Flutter 对接

本文描述 Flutter App 对接用户排行榜、送礼榜、收礼榜和房间榜的 HTTP 接口。榜单事实来自 `wallet-service` 已成功的礼物扣费流水；客户端只展示 gateway 返回的榜单投影，不在本地重算排名、礼物价值或统计窗口。

## 基础约定

本地开发地址：

```text
http://127.0.0.1:13000
```

业务接口统一响应 envelope：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}
```

| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`；失败为稳定错误码。 |
| `message` | 短错误文案，不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID；客户端日志要记录。 |
| `data` | 成功业务数据；失败时通常不存在。 |

通用请求头：

| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码，默认 `lalu`；登录态接口最终以 token 内 app_code 为准。 |

所有 `*_ms` 都是 Unix epoch milliseconds。榜单统计窗口按 UTC 计算，不按用户本地时区、设备时区或 IP 推断时区计算。

## 榜单接口

```http
GET /api/v1/activities/user-leaderboards
Authorization: Bearer <access_token>
X-App-Code: lalu
```

Query 参数：

| 字段 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼榜；`received` 是收礼榜；`room` 是房间收礼榜。 |
| `period` | string | 否 | `today` | 统计周期。`today`、`week`、`month`。 |
| `page` | int32 | 否 | `1` | 页码，从 1 开始。必须大于 0。 |
| `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0；服务端最大返回 `100`。 |

`board_type` 兼容别名：

| 标准值 | 兼容传参 | 说明 |
| --- | --- | --- |
| `sent` | 空字符串、`send`、`sender`、`gift_sent`、`user_sent` | 按送礼用户聚合，展示用户送礼贡献。 |
| `received` | `receive`、`receiver`、`gift_received`、`user_received` | 按收礼用户聚合，展示用户收礼魅力。 |
| `room` | `rooms`、`room_gift`、`room_gifts` | 按房间聚合，展示房间礼物流水。 |

`period` 兼容别名：

| 标准值 | 兼容传参 | 统计窗口 |
| --- | --- | --- |
| `today` | 空字符串、`day`、`daily` | UTC 当日 00:00:00 到当前服务端时间。 |
| `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 |
| `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 |

服务端实际查询范围是 `[start_at_ms, end_at_ms)`，其中 `end_at_ms` 是本次请求的当前服务端时间。排序固定为 `gift_value DESC, last_gift_at_ms DESC, subject_id ASC`；礼物价值相同的情况下，最近送礼时间更晚的排名更靠前，再用用户 ID 或房间 ID 保证稳定顺序。

### 送礼榜

```http
GET /api/v1/activities/user-leaderboards?board_type=sent&period=today&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```

送礼榜按 `sender_user_id` 聚合。返回项里的 `user_id` 和 `user` 表示送礼用户。

### 收礼榜

```http
GET /api/v1/activities/user-leaderboards?board_type=received&period=week&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```

收礼榜按 `target_user_id` 聚合。返回项里的 `user_id` 和 `user` 表示收礼用户。

### 房间榜

```http
GET /api/v1/activities/user-leaderboards?board_type=room&period=month&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```

房间榜按 `room_id` 聚合。返回项里只有 `room_id` 和 `room`，不会返回 `user` 和 `my_rank`。

## 房间内贡献榜边界

本接口是全局活动榜，按 `board_type + period` 查询成功送礼流水。房间页里的当前房间送礼贡献榜不是通过本接口读取：

| 场景 | 接口 | 榜单字段 | 说明 |
| --- | --- | --- | --- |
| 首次进房 | `POST /api/v1/rooms/join` | `data.contribution_rank` | JoinRoom 成功后返回当前房间贡献榜前 10。 |
| 房间页重拉快照 | `GET /api/v1/rooms/snapshot?room_id={room_id}` | `data.contribution_rank` | 只读 Room Cell/snapshot，不刷新 presence。 |
| 房间详情首屏 | `GET /api/v1/rooms/{room_id}/detail` | `data.rank_top` | 房间详情聚合版，额外补 viewer 权限、IM/RTC 和资料。 |

房间内贡献榜字段是 `user_id`、`score`、`gift_value`、`updated_at_ms`，只代表当前房间内的本地贡献排名；它不等同于用户送礼榜或收礼榜，也不能和本接口的 `my_rank` 混用。

## 成功返回

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "items": [
      {
        "rank": 1,
        "user_id": "10001",
        "gift_value": 188800,
        "gift_count": 236,
        "transaction_count": 42,
        "last_gift_at_ms": 1779259000000,
        "user": {
          "user_id": "10001",
          "display_user_id": "888888",
          "username": "Luna",
          "avatar": "https://cdn.example/avatar/10001.png"
        }
      }
    ],
    "total": 168,
    "page": 1,
    "page_size": 20,
    "board_type": "sent",
    "period": "today",
    "start_at_ms": 1779206400000,
    "end_at_ms": 1779259000000,
    "server_time_ms": 1779259000000,
    "my_rank": {
      "rank": 12,
      "user_id": "10099",
      "gift_value": 9200,
      "gift_count": 18,
      "transaction_count": 6,
      "last_gift_at_ms": 1779258600000,
      "user": {
        "user_id": "10099",
        "display_user_id": "10099",
        "username": "Me",
        "avatar": "https://cdn.example/avatar/me.png"
      }
    }
  }
}
```

房间榜返回示例：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_room",
  "data": {
    "items": [
      {
        "rank": 1,
        "room_id": "lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72",
        "gift_value": 660000,
        "gift_count": 580,
        "transaction_count": 95,
        "last_gift_at_ms": 1779259000000,
        "room": {
          "room_id": "lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72"
        }
      }
    ],
    "total": 20,
    "page": 1,
    "page_size": 20,
    "board_type": "room",
    "period": "month",
    "start_at_ms": 1777564800000,
    "end_at_ms": 1779259000000,
    "server_time_ms": 1779259000000
  }
}
```

`data` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items` | array | 当前页榜单项。空数组表示当前页无数据。 |
| `total` | int64 | 当前榜单和周期下的总上榜主体数。 |
| `page` | int32 | 服务端解析后的页码。 |
| `page_size` | int32 | 服务端实际使用的每页数量；请求超过 100 时返回 100。 |
| `board_type` | string | 服务端归一化后的榜单类型：`sent`、`received`、`room`。 |
| `period` | string | 服务端归一化后的统计周期：`today`、`week`、`month`。 |
| `start_at_ms` | int64 | 本次统计窗口开始时间，UTC。 |
| `end_at_ms` | int64 | 本次统计窗口结束时间，等于请求时的当前服务端时间。 |
| `server_time_ms` | int64 | 服务端当前时间，可用于前端展示“更新时间”。 |
| `my_rank` | object? | 当前登录用户在送礼榜或收礼榜中的排名；未上榜或 `board_type=room` 时不返回。 |

`items[]` / `my_rank` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `rank` | int64 | 排名，从 1 开始。 |
| `user_id` | string | 用户榜单主体 ID；`sent`、`received` 才有。 |
| `room_id` | string | 房间榜单主体 ID；`room` 才有。 |
| `gift_value` | int64 | 统计窗口内礼物价值总和，用于排名和主数值展示。 |
| `gift_count` | int64 | 统计窗口内礼物数量总和。 |
| `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 |
| `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 |
| `user` | object? | 用户资料；正常会补充展示 ID、昵称和头像；本地未注入用户资料 client 时至少返回 `user_id`。 |
| `room` | object? | 房间基础投影；当前只有 `room_id`。 |

`user` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户长 ID，字符串格式返回，避免 Flutter/JS 精度问题。 |
| `display_user_id` | string | 展示 ID，可优先用于 UI 展示。 |
| `username` | string | 用户昵称。 |
| `avatar` | string | 头像 URL。 |

## 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.containsKey('data') ? parseData(json['data']) : null,
    );
  }
}

class UserLeaderboardPage {
  UserLeaderboardPage({
    required this.items,
    required this.total,
    required this.page,
    required this.pageSize,
    required this.boardType,
    required this.period,
    required this.startAtMs,
    required this.endAtMs,
    required this.serverTimeMs,
    this.myRank,
  });

  final List<UserLeaderboardItem> items;
  final int total;
  final int page;
  final int pageSize;
  final String boardType;
  final String period;
  final int startAtMs;
  final int endAtMs;
  final int serverTimeMs;
  final UserLeaderboardItem? myRank;

  factory UserLeaderboardPage.fromJson(Map<String, dynamic> json) {
    return UserLeaderboardPage(
      items: (json['items'] as List<dynamic>? ?? const [])
          .whereType<Map<String, dynamic>>()
          .map(UserLeaderboardItem.fromJson)
          .toList(),
      total: (json['total'] as num?)?.toInt() ?? 0,
      page: (json['page'] as num?)?.toInt() ?? 1,
      pageSize: (json['page_size'] as num?)?.toInt() ?? 20,
      boardType: json['board_type'] as String? ?? 'sent',
      period: json['period'] as String? ?? 'today',
      startAtMs: (json['start_at_ms'] as num?)?.toInt() ?? 0,
      endAtMs: (json['end_at_ms'] as num?)?.toInt() ?? 0,
      serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
      myRank: json['my_rank'] is Map<String, dynamic>
          ? UserLeaderboardItem.fromJson(json['my_rank'] as Map<String, dynamic>)
          : null,
    );
  }
}

class UserLeaderboardItem {
  UserLeaderboardItem({
    required this.rank,
    required this.giftValue,
    required this.giftCount,
    required this.transactionCount,
    required this.lastGiftAtMs,
    this.userId,
    this.roomId,
    this.user,
    this.room,
  });

  final int rank;
  final String? userId;
  final String? roomId;
  final int giftValue;
  final int giftCount;
  final int transactionCount;
  final int lastGiftAtMs;
  final LeaderboardUser? user;
  final LeaderboardRoom? room;

  factory UserLeaderboardItem.fromJson(Map<String, dynamic> json) {
    return UserLeaderboardItem(
      rank: (json['rank'] as num?)?.toInt() ?? 0,
      userId: json['user_id'] as String?,
      roomId: json['room_id'] as String?,
      giftValue: (json['gift_value'] as num?)?.toInt() ?? 0,
      giftCount: (json['gift_count'] as num?)?.toInt() ?? 0,
      transactionCount: (json['transaction_count'] as num?)?.toInt() ?? 0,
      lastGiftAtMs: (json['last_gift_at_ms'] as num?)?.toInt() ?? 0,
      user: json['user'] is Map<String, dynamic>
          ? LeaderboardUser.fromJson(json['user'] as Map<String, dynamic>)
          : null,
      room: json['room'] is Map<String, dynamic>
          ? LeaderboardRoom.fromJson(json['room'] as Map<String, dynamic>)
          : null,
    );
  }
}

class LeaderboardUser {
  LeaderboardUser({
    required this.userId,
    required this.displayUserId,
    required this.username,
    required this.avatar,
  });

  final String userId;
  final String displayUserId;
  final String username;
  final String avatar;

  factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
    return LeaderboardUser(
      userId: json['user_id'] as String? ?? '',
      displayUserId: json['display_user_id'] as String? ?? '',
      username: json['username'] as String? ?? '',
      avatar: json['avatar'] as String? ?? '',
    );
  }
}

class LeaderboardRoom {
  LeaderboardRoom({required this.roomId});

  final String roomId;

  factory LeaderboardRoom.fromJson(Map<String, dynamic> json) {
    return LeaderboardRoom(roomId: json['room_id'] as String? ?? '');
  }
}
```

请求封装示例：

```dart
enum LeaderboardType { sent, received, room }
enum LeaderboardPeriod { today, week, month }

extension on LeaderboardType {
  String get wireName => name;
}

extension on LeaderboardPeriod {
  String get wireName => name;
}

Future<UserLeaderboardPage> fetchUserLeaderboard({
  required Dio dio,
  required LeaderboardType type,
  required LeaderboardPeriod period,
  int page = 1,
  int pageSize = 20,
}) async {
  final response = await dio.get<Map<String, dynamic>>(
    '/api/v1/activities/user-leaderboards',
    queryParameters: {
      'board_type': type.wireName,
      'period': period.wireName,
      'page': page,
      'page_size': pageSize,
    },
  );
  final envelope = ApiEnvelope<UserLeaderboardPage>.fromJson(
    response.data ?? const <String, dynamic>{},
    (raw) => UserLeaderboardPage.fromJson(raw as Map<String, dynamic>),
  );
  if (response.statusCode != 200 || envelope.code != 'OK' || envelope.data == null) {
    throw ApiException(
      code: envelope.code,
      message: envelope.message,
      requestId: envelope.requestId,
    );
  }
  return envelope.data!;
}

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)';
}
```

## 错误处理

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

错误响应示例：

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

| HTTP | `code` | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `board_type`、`period` 不支持；`page` 或 `page_size` 不是正整数。 | 不重试；修正本地入参，客户端只发送枚举值。 |
| `401` | `UNAUTHORIZED` | access token 缺失、过期或非法。 | 跳登录或刷新登录态；记录 `request_id`。 |
| `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 |
| `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 |
| `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 |
| `502` | `UPSTREAM_ERROR` | 钱包库、用户资料服务或上游依赖异常。 | 展示通用失败，可保留旧榜单并允许下拉重试；记录 `request_id`。 |
| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单钱包只读库。 | 展示通用失败，记录 `request_id`。 |

## 客户端展示规则

榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页；上拉加载下一页时用返回的 `total` 判断是否还有更多：`page * page_size < total`。

`my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”，收礼榜里是“我的收礼排名”；房间榜没有个人排名。未上榜时 `my_rank` 不存在，客户端展示“未上榜”即可，不要自行用 `items` 推断。

时间展示用 `server_time_ms` 或 `end_at_ms` 标注“更新于”。`today`、`week`、`month` 的统计口径固定为 UTC，展示文案如果需要本地化，只能改时间格式，不能改变请求周期含义。

榜单数据当前没有单独 IM 推送。用户送礼后如果当前页面正在展示对应榜单，可以延迟刷新第一页或由用户手动下拉刷新；不要依赖房间公屏 IM 来本地累加全局榜单。
