# 用户排行榜 Flutter 对接 本文描述 Flutter App 对接财富榜、魅力榜、游戏榜和房间榜的 HTTP 接口。`sent/received` 来自钱包已提交的礼物事实,`game` 来自游戏成功扣币的 `game_spend_coin` 事实,`room` 来自 room-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 `。 | | `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 | 所有 `*_ms` 都是 Unix epoch milliseconds。榜单统计窗口按 UTC 计算,不按用户本地时区、设备时区或 IP 推断时区计算。 ## 榜单接口 ```http GET /api/v1/activities/user-leaderboards Authorization: Bearer X-App-Code: lalu ``` Query 参数: | 字段 | 类型 | 必填 | 默认 | 说明 | | --- | --- | --- | --- | --- | | `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼财富榜;`received` 是收礼魅力榜;`game` 是游戏消费榜;`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` | 按收礼用户聚合,展示用户收礼魅力。 | | `game` | `games`、`gaming` | 按用户聚合成功游戏扣币额;不累计派奖、退款或净输赢。 | | `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` 是本次请求的当前服务端时间。用户榜按窗口累计值倒序;`game` 的累计值是 `game_spend_coin`,为兼容既有 JSON 暂时仍通过 `gift_value` 返回。房间榜由 room-service 按同一周期返回。 ### 送礼榜 ```http GET /api/v1/activities/user-leaderboards?board_type=sent&period=today&page=1&page_size=20 Authorization: Bearer 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 X-App-Code: lalu ``` 收礼榜按 `target_user_id` 聚合。返回项里的 `user_id` 和 `user` 表示收礼用户。 ### 游戏榜 ```http GET /api/v1/activities/user-leaderboards?board_type=game&period=today&page=1&page_size=20 Authorization: Bearer X-App-Code: huwaa ``` 游戏榜按成功扣币订单的 `user_id` 聚合 `game_spend_coin`。只有 `op_type=debit` 且扣币成功、金额大于 0 的订单进入榜单;派奖、退款和净输赢不会改变游戏榜积分。返回项中的 `gift_value` 是兼容字段,游戏榜场景表示累计游戏扣币额。 ### 房间榜 ```http GET /api/v1/activities/user-leaderboards?board_type=room&period=month&page=1&page_size=20 Authorization: Bearer X-App-Code: lalu ``` 房间榜按 `room_id` 聚合。返回项里不会返回 `user` 和 `my_rank`;`room` 会补充房间短号、名称和头像字段,客户端头像优先读 `cover_url`,兼容字段为 `room_avatar`。 ## 房间内贡献榜边界 本接口是全局活动榜,按 `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", "short_badge_cover_urls": [ "https://cdn.example/badges/short-1.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" } } } } ``` `user.short_badge_cover_urls` 只包含用户当前佩戴的普通短徽章静态封面,长徽章和等级徽章不会混入。用户没有短徽章时字段省略,客户端按空数组处理即可;房间榜没有用户徽章字段。 房间榜返回示例: ```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`、`game`、`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`、`game` 才有。 | | `room_id` | string | 房间榜单主体 ID;`room` 才有。 | | `gift_value` | int64 | 排名累计值。`sent/received/room` 表示礼物价值;`game` 表示成功游戏扣币额。 | | `gift_count` | int64 | 礼物榜的礼物数量总和;`game` 固定为 0。 | | `transaction_count` | int64 | 用户榜的成功事实数;`game` 表示成功扣币订单数。 | | `last_gift_at_ms` | int64 | 最近一次计榜事实时间;`game` 表示最近一次成功游戏扣币时间。字段名为兼容既有协议保留。 | | `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像;本地未注入用户资料 client 时至少返回 `user_id`。 | | `room` | object? | 房间基础投影;房间榜返回 `room_id`、`room_short_id`、`title`、`cover_url`、`room_avatar`。 | `user` 字段: | 字段 | 类型 | 说明 | | --- | --- | --- | | `user_id` | string | 用户长 ID,字符串格式返回,避免 Flutter/JS 精度问题。 | | `display_user_id` | string | 展示 ID,可优先用于 UI 展示。 | | `username` | string | 用户昵称。 | | `avatar` | string | 头像 URL。 | ## Flutter 解析示例 ```dart class ApiEnvelope { 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 json, T Function(Object? raw) parseData, ) { return ApiEnvelope( 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 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 json) { return UserLeaderboardPage( items: (json['items'] as List? ?? const []) .whereType>() .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 ? UserLeaderboardItem.fromJson(json['my_rank'] as Map) : 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 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 ? LeaderboardUser.fromJson(json['user'] as Map) : null, room: json['room'] is Map ? LeaderboardRoom.fromJson(json['room'] as Map) : null, ); } } class LeaderboardUser { LeaderboardUser({ required this.userId, required this.displayUserId, required this.username, required this.avatar, required this.shortBadgeCoverUrls, }); final String userId; final String displayUserId; final String username; final String avatar; final List shortBadgeCoverUrls; factory LeaderboardUser.fromJson(Map 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? ?? '', shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List? ?? const []) .whereType() .toList(), ); } } class LeaderboardRoom { LeaderboardRoom({ required this.roomId, required this.roomShortId, required this.title, required this.coverUrl, required this.roomAvatar, }); final String roomId; final String roomShortId; final String title; final String coverUrl; final String roomAvatar; factory LeaderboardRoom.fromJson(Map json) { return LeaderboardRoom( roomId: json['room_id'] as String? ?? '', roomShortId: json['room_short_id'] as String? ?? '', title: json['title'] as String? ?? '', coverUrl: json['cover_url'] as String? ?? '', roomAvatar: json['room_avatar'] as String? ?? '', ); } } ``` 请求封装示例: ```dart enum LeaderboardType { sent, received, game, room } enum LeaderboardPeriod { today, week, month } extension on LeaderboardType { String get wireName => name; } extension on LeaderboardPeriod { String get wireName => name; } Future fetchUserLeaderboard({ required Dio dio, required LeaderboardType type, required LeaderboardPeriod period, int page = 1, int pageSize = 20, }) async { final response = await dio.get>( '/api/v1/activities/user-leaderboards', queryParameters: { 'board_type': type.wireName, 'period': period.wireName, 'page': page, 'page_size': pageSize, }, ); final envelope = ApiEnvelope.fromJson( response.data ?? const {}, (raw) => UserLeaderboardPage.fromJson(raw as Map), ); 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` | 榜单 Redis、用户资料、钱包装扮或房间上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 | | `503` | `UPSTREAM_ERROR` | gateway 未配置榜单 Redis 读模型。 | 展示通用失败,记录 `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 来本地累加全局榜单。