17 KiB
用户排行榜 Flutter 对接
本文描述 Flutter App 对接用户排行榜、送礼榜、收礼榜和房间榜的 HTTP 接口。榜单事实来自 wallet-service 已成功的礼物扣费流水;客户端只展示 gateway 返回的榜单投影,不在本地重算排名、礼物价值或统计窗口。
基础约定
本地开发地址:
http://127.0.0.1:13000
业务接口统一响应 envelope:
{
"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 推断时区计算。
榜单接口
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 保证稳定顺序。
送礼榜
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 表示送礼用户。
收礼榜
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 表示收礼用户。
房间榜
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 混用。
成功返回
{
"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"
}
}
}
}
房间榜返回示例:
{
"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 解析示例
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? ?? '',
);
}
}
请求封装示例:
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。
错误响应示例:
{
"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 来本地累加全局榜单。