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

17 KiB
Raw Permalink Blame History

用户排行榜 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 统计周期。todayweekmonth
page int32 1 页码,从 1 开始。必须大于 0。
page_size int32 20 每页数量。必须大于 0服务端最大返回 100

board_type 兼容别名:

标准值 兼容传参 说明
sent 空字符串、sendsendergift_sentuser_sent 按送礼用户聚合,展示用户送礼贡献。
received receivereceivergift_receiveduser_received 按收礼用户聚合,展示用户收礼魅力。
room roomsroom_giftroom_gifts 按房间聚合,展示房间礼物流水。

period 兼容别名:

标准值 兼容传参 统计窗口
today 空字符串、daydaily 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_iduser 表示送礼用户。

收礼榜

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_iduser 表示收礼用户。

房间榜

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 聚合。返回项里不会返回 usermy_rankroom 会补充房间短号、名称和头像字段,客户端头像优先读 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_idscoregift_valueupdated_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 服务端归一化后的榜单类型:sentreceivedroom
period string 服务端归一化后的统计周期:todayweekmonth
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 用户榜单主体 IDsentreceived 才有。
room_id string 房间榜单主体 IDroom 才有。
gift_value int64 统计窗口内礼物价值总和,用于排名和主数值展示。
gift_count int64 统计窗口内礼物数量总和。
transaction_count int64 统计窗口内成功送礼扣费流水数。
last_gift_at_ms int64 该主体最近一次成功送礼流水时间。
user object? 用户资料;正常会补充展示 ID、昵称和头像本地未注入用户资料 client 时至少返回 user_id
room object? 房间基础投影;房间榜返回 room_idroom_short_idtitlecover_urlroom_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 为 200code == "OK" 时读取 data;其他情况不要使用 data

错误响应示例:

{
  "code": "INVALID_ARGUMENT",
  "message": "invalid argument",
  "request_id": "req_bad"
}
HTTP code 场景 Flutter 处理
400 INVALID_ARGUMENT board_typeperiod 不支持;pagepage_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_msend_at_ms 标注“更新于”。todayweekmonth 的统计口径固定为 UTC展示文案如果需要本地化只能改时间格式不能改变请求周期含义。

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