hyapp-server/docs/flutter对接/用户排行榜Flutter对接.md
2026-05-25 22:43:54 +08:00

498 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 用户排行榜 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` 聚合。返回项里不会返回 `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"
}
}
],
"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``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<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,
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<String, dynamic> 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, 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 来本地累加全局榜单。