topfans/docs/superpowers/specs/2026-06-22-activity-top-ranking-api-design.md
2026-06-22 20:02:00 +08:00

20 KiB
Raw Blame History

活动 TOP3 + 我的排名 接口设计

日期2026-06-22 关联组件frontend/pages/support-activity/components/TopRanking.vue 关联接口GET /api/v1/activities/:id/ranking(现有,保留不动) 目标读者后端开发、前端开发、Code Reviewer


1. 背景与目标

TopRanking.vue 在活动主页展示"前 3 名头像组"和"我的排名卡片(当前排名 + 距离上一名贡献值)"。当前实现是直接调用 GET /api/v1/activities/:id/ranking?page=1&page_size=3,并在前端计算 gapToPrev。存在两个问题:

  1. 接口冗余/ranking 设计为通用分页榜单,返回 nicknametotal_crystal_spent 等 TopRanking 不需要的字段。
  2. 计算外移gap 应是后端责任,前端拼装容易出错且不同客户端会重复实现。

本期目标:新增一个专用轻量接口 GET /api/v1/activities/:id/top-ranking,仅返回 top3 + my_infogap_to_prev),前端切换到新接口。

非目标

  • 不替代 /ranking 通用榜单接口
  • 不引入 Redis 完整榜单缓存(仅 top3 短 TTL 缓存,见 §9
  • 不做实时推送WebSocket 推送见 2026-06-22-activity-realtime-websocket-design.md

2. 接口契约

2.1 请求

Method GET
Path /api/v1/activities/:id/top-ranking
Auth 必填,沿用 AuthMiddleware()
Path 参数 id (int64) 活动 ID
Query 参数 star_id (int64, optional) — 明星作用域;缺省用 token 中的 star_id

HeaderAuthorization: Bearer <jwt>

2.2 响应(code=0

{
  "code": 0,
  "message": "ok",
  "data": {
    "top3": [
      { "rank": 1, "user_id": 1001, "avatar_url": "https://cdn.example.com/avatar/1001.jpg" },
      { "rank": 2, "user_id": 1002, "avatar_url": "https://cdn.example.com/avatar/1002.jpg" },
      { "rank": 3, "user_id": 1003, "avatar_url": "https://cdn.example.com/avatar/1003.jpg" }
    ],
    "my_info": {
      "rank": 7,
      "avatar_url": "https://cdn.example.com/avatar/2001.jpg",
      "gap_to_prev": 320,
      "status": "ranked"
    }
  }
}

2.3 响应(code=0,用户未上榜)

{
  "code": 0,
  "message": "ok",
  "data": {
    "top3": [ ... ],
    "my_info": {
      "rank": 0,
      "avatar_url": "https://cdn.example.com/avatar/2001.jpg",
      "gap_to_prev": 0,
      "status": "unranked"
    }
  }
}

2.4 响应(code=0,用户已上榜且 rank=1

{
  "code": 0,
  "message": "ok",
  "data": {
    "top3": [
      { "rank": 1, "user_id": 2001, "avatar_url": "https://cdn.example.com/avatar/2001.jpg" }
    ],
    "my_info": {
      "rank": 1,
      "avatar_url": "https://cdn.example.com/avatar/2001.jpg",
      "gap_to_prev": 0,
      "status": "ranked"
    }
  }
}

my_info.avatar_url 在 ranked / unranked 两种状态下都需要返回当前用户的头像(用于将来"暂未上榜"卡片展示用户身份)。

gap_to_prev = 0 的全部触发条件

  • status == "unranked"rank=0未参与活动
  • rank == 1(榜首,没有上一名)
  • 异常 clampgap_to_prev = max(0, 算出的差值)(并发更新导致自己贡献值被刷新)

2.5 错误码

code 含义 触发条件
401 未登录 token 缺失 / 无效 / 黑名单
400 请求参数非法 activity_id <= 0 或类型错误
404 活动不存在 activities.id 不存在
500 内部错误 DB / 下游 RPC 失败

3. Proto 定义

新增文件 backend/proto/activity.proto,追加:

message TopRankingRequest {
  int64 activity_id = 1;
  int64 star_id = 2;     // 0 表示不按明星过滤
  int64 user_id = 3;     // 来自 token
}

message TopRankingItem {
  int32 rank = 1;
  int64 user_id = 2;
  string avatar_url = 3;
}

message MyTopRankingInfo {
  int32 rank = 1;                  // 0 表示未上榜
  string avatar_url = 2;
  int64 gap_to_prev = 3;
  string status = 4;               // "ranked" | "unranked"
}

message TopRankingResponse {
  BaseResponse base = 1;
  repeated TopRankingItem top3 = 2;
  MyTopRankingInfo my_info = 3;
}

service ActivityService {
  // ... 现有方法 ...
  rpc GetTopRanking(TopRankingRequest) returns (TopRankingResponse);
}

执行 protoc 重新生成 backend/pkg/proto/activity/activity.pb.goactivity_grpc.pb.go


4. 架构与数据流

调用链(与现有 /ranking 完全一致):

Client (TopRanking.vue)
   ↓ HTTP GET + JWT
gateway/controller/activity_controller.go (新方法 GetTopRanking)
   ↓ Dubbo gRPC (constant.AttachmentKey 传 user_id/star_id)
activityService/provider/activity_provider.go (新 RPC GetTopRanking)
   ↓
activityService/service/activity_service.go (新方法 GetTopRanking)
   ↓
activityService/repository/activity_repository.go (新方法 GetTop3, GetUserStatsForRanking)
   ↓
MySQL (GORM) + userRPCClient.GetFanProfile (头像补全)

5. 核心算法

5.1 Service GetTopRanking 步骤

  1. 取 top3带缓存:调用 s.getTop3WithCache(ctx, activityID, starID)(详见 §9.4),最多返回 3 行(按 total_contribution DESC),不足返回实际数量;缓存命中时不查 DB
  2. 取自己的统计myStats, _ := repo.GetUserStatsForRanking(activityID, userID, starID)
  3. 判定 my_info
    • myStats == nilstatus="unranked"rank=0gap_to_prev=0
    • 否则调 repo.GetUserRank(userID, activityID, starID) 拿到 myRankstatus="ranked"
  4. 计算 gap_to_prev
    • myRank <= 1 → 0
    • 2 <= myRank <= 3gap_to_prev = top3[myRank-2].total_contribution - myStats.total_contributionO(1)top3 数组已就绪)
    • myRank > 3 → 多一次 query
      SELECT total_contribution FROM activity_user_stats
      WHERE activity_id = ? AND star_id = ?
      ORDER BY total_contribution DESC
      OFFSET ? LIMIT 1
      
      offset = myRank - 2
  5. clampgap_to_prev = max(0, gap_to_prev)(并发更新时自己的贡献值可能已被刷新)
  6. 填充 avatar_urltop3 每个 user 一次 userRPCClient.GetFanProfile(userID, starID)my_info 无论 ranked / unranked 都调一次 GetFanProfile 拿当前用户头像;单点失败不阻塞,记录 WARN 后继续avatar_url 留空字符串,前端有兜底)

5.2 Repository 新增方法

GetTop3(activityID, starID int64) ([]models.ActivityUserStats, error)

query := r.db.Model(&models.ActivityUserStats{}).
    Where("activity_id = ?", activityID).
    Order("total_contribution DESC, id ASC").
    Limit(3)
if starID > 0 {
    query = query.Where("star_id = ?", starID)
}
var stats []models.ActivityUserStats
if err := query.Find(&stats).Error; err != nil {
    return nil, err
}
return stats, nil

GetUserStatsForRanking(activityID, userID, starID int64) (*models.ActivityUserStats, error)

query := r.db.Where("activity_id = ? AND user_id = ?", activityID, userID)
if starID > 0 {
    query = query.Where("star_id = ?", starID)
}
var stats models.ActivityUserStats
err := query.First(&stats).Error
if err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return nil, err
}
return &stats, nil

GetUserRank 复用现有方法activity_repository.go:267),不改。

5.3 关键不变量

  • top3 数组按 rank 升序1, 2, 3即使 DB 返回的 stats 不带 rank 字段,由 service 按数组下标+1 注入
  • gap_to_prev >= 0
  • my_info.rank == 0status == "unranked" ⇔ 该用户在 activity_user_stats 无对应行
  • my_info.avatar_url 无论 ranked / unranked 都填充(取自 fan profile调用失败时为空字符串
  • top3 不存在时 top3: [](不是 null保持 JSON 数组语义

6. Gateway 层

6.1 路由注册

backend/gateway/router/router.go 第 382 行附近追加:

activities.GET("/:id/top-ranking", activityCtrl.GetTopRanking)

AuthMiddleware() 已在外层 Group 应用,无需额外声明。

6.2 Controller 方法

backend/gateway/controller/activity_controller.go 新增:

// GetTopRanking 获取活动 TOP3 + 我的排名
func (c *ActivityController) GetTopRanking(ctx *gin.Context) {
    userID, _ := ctx.Get("user_id")
    starID, _ := ctx.Get("star_id")
    if userID == nil {
        apiError.Unauthorized(ctx, "user not authenticated")
        return
    }
    activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
    if err != nil || activityID <= 0 {
        apiError.BadRequest(ctx, "invalid activity_id")
        return
    }

    // star_id 可能为 niltoken 中无 star_id此时按 0 处理(不过滤)
    var reqStarID int64
    if starID != nil {
        reqStarID = starID.(int64)
    }
    if qs := ctx.Query("star_id"); qs != "" {
        if v, err := strconv.ParseInt(qs, 10, 64); err == nil && v > 0 {
            reqStarID = v
        }
    }

    resp, err := c.activityService.GetTopRanking(ctx, &pb.TopRankingRequest{
        ActivityId: activityID,
        StarId:     reqStarID,
        UserId:     userID.(int64),
    })
    if err != nil {
        apiError.InternalError(ctx, err)
        return
    }
    ctx.JSON(http.StatusOK, gin.H{
        "code":    0,
        "message": "ok",
        "data":    convertTopRankingResponse(resp),
    })
}

convertTopRankingResponse 辅助函数(同文件内):

func convertTopRankingResponse(resp *pb.TopRankingResponse) gin.H {
    top3 := make([]gin.H, 0, len(resp.Top3))
    for _, it := range resp.Top3 {
        top3 = append(top3, gin.H{
            "rank":       it.Rank,
            "user_id":    it.UserId,
            "avatar_url": it.AvatarUrl,
        })
    }
    var myInfo gin.H
    if resp.MyInfo != nil {
        myInfo = gin.H{
            "rank":         resp.MyInfo.Rank,
            "avatar_url":   resp.MyInfo.AvatarUrl,
            "gap_to_prev":  resp.MyInfo.GapToPrev,
            "status":       resp.MyInfo.Status,
        }
    }
    return gin.H{
        "top3":    top3,
        "my_info": myInfo,
    }
}

6.3 Swagger 注释

按现有 GetContributionRanking 模式补 swag 注释块,跑 bash backend/update-swagger.sh 重新生成 docs/swagger.json


7. 前端切换

7.1 frontend/utils/api.js

getActivityRankingApi 之后新增:

// 获取活动 TOP3 + 我的排名(专用轻量接口)
export function getActivityTopRankingApi(activityId, starId = null) {
    let url = `/api/v1/activities/${activityId}/top-ranking`
    if (starId) {
        url += `?star_id=${starId}`
    }
    return request({ url, method: 'GET' })
}

7.2 frontend/pages/support-activity/components/TopRanking.vue

  • loadRanking 改用 getActivityTopRankingApi
  • 删除客户端 calcGapToPrev(后端已下发 gap_to_prev
  • 解析新响应:res.data.top3 / res.data.my_info,字段映射 gap_to_prev → gapToPrev
  • 旧的 getActivityRankingApi 调用保留(其他组件 ActivityRankingModal.vue 仍在用),不动

切换在单独 commit,出问题可一行回滚前端。


8. 错误处理与日志

层级 错误处理 日志级别
Handler 400 / 401 / 500 走 apiError 入口 INFO异常 ERROR含 err cause
Service GetFanProfile 单点失败 → WARN继续Redis Get/Set 故障 → WARN继续 DEBUG 关键节点top3_size, my_rank, gap_to_prev, cache_hit
Repository 底层错误 wrap 上抛 异常 ERROR

所有日志带 request_id / user_id / activity_id


9. 性能与缓存

9.1 top3 缓存(本期采用)

activity:top3 是热点数据(活动页首屏必加载),使用 Redis 做短 TTL 缓存。

Key 模式 activity:top3:{activity_id}:{star_id}star_id=0 时用 all 占位
Value JSON 字符串([{rank, user_id, avatar_url}, ...],数组长度 ∈ [0, 3]
TTL 30 秒
一致性策略 仅 TTL 过期回源,不主动失效30s 内的贡献不会立即体现给其他用户,符合榜单短延迟语义)
Miss 策略 Redis 返回 nil / 反序列化失败 → 走 DB 查 top3 → setex 写入

9.2 my_info 不缓存

my_info 与 top3 不同,是用户维度的个性化数据(每人 rank / gap_to_prev 不同),且 activity_user_stats 表已带 (activity_id, user_id, star_id) 复合唯一索引DB 查询 P95 < 10ms不上缓存

9.3 Redis 客户端

复用现有 activityService 已有的 *redis.Client(见 main.go:84-91),无需新建连接池。

9.4 关键代码路径(伪代码)

// 在 service.GetTopRanking 步骤 1 处改为:
top3, hit, err := s.getTop3WithCache(ctx, activityID, starID)
if err != nil {
    return nil, err
}
if !hit {
    // log DEBUG: cache miss, falling back to DB
}

// 新增私有方法
func (s *activityService) getTop3WithCache(ctx context.Context, activityID, starID int64) ([]TopRankingItem, bool, error) {
    key := fmt.Sprintf("activity:top3:%d:%s", activityID, starIDOrAll(starID))

    // 1. 读 Redis
    cached, err := s.redis.Get(ctx, key).Result()
    if err == nil && cached != "" {
        var items []TopRankingItem
        if json.Unmarshal([]byte(cached), &items) == nil {
            return items, true, nil
        }
        // 反序列化失败当作 miss 处理
    } else if err != redis.Nil {
        // Redis 故障(非 nil→ 记 WARNfallback DB不阻塞接口
        log.Warn("top3 cache get failed", "key", key, "err", err)
    }

    // 2. 回源 DB
    stats, err := s.activityRepo.GetTop3(activityID, starID)
    if err != nil {
        return nil, false, err
    }
    items := statsToItems(stats)  // 数组下标+1 注入 rank

    // 3. 写回 Redis异步或同步均可本期同步写失败仅 WARN
    if data, err := json.Marshal(items); err == nil {
        if err := s.redis.Set(ctx, key, data, 30*time.Second).Err(); err != nil {
            log.Warn("top3 cache set failed", "key", key, "err", err)
        }
    }

    return items, false, nil
}

func starIDOrAll(starID int64) string {
    if starID <= 0 {
        return "all"
    }
    return strconv.FormatInt(starID, 10)
}

9.5 容量与淘汰

  • 单 key < 200B单活动最多产生 明星数+1 个 key正常活动 < 10 个明星)
  • 30s TTL + 不主动失效 → Redis 自然淘汰,无内存压力
  • 无需配置 maxmemory 策略变更

10. 测试

项目此前 activityService 无任何 _test.go,本期作为起点写最小集:

10.1 Repository 测试

文件:backend/services/activityService/repository/activity_repository_test.go(新建)

  • TestGetTop3_Empty — activity 无任何 stats → 返回空切片
  • TestGetTop3_LessThan3 — 只有 1 行 → 返回 1 行
  • TestGetTop3_FullWithStar — 3 行且带 star_id 过滤
  • TestGetUserStatsForRanking_NotFound — 返回 nil, nil
  • TestGetUserStatsForRanking_Found — 返回正确 stats

参考模式:backend/services/assetService/repository/ranking_repository_test.go

10.2 Service 测试

文件:backend/services/activityService/service/activity_service_test.go(新建)

  • TestGetTopRanking_UnrankedUser — my_stats 不存在 → status=unranked, rank=0, gap_to_prev=0但 avatar_url 仍从 fan profile 填充
  • TestGetTopRanking_Rank1 — my_rank=1 → gap_to_prev=0
  • TestGetTopRanking_RankInTop3 — my_rank=2 或 3 → gap_to_prev 从 top3 数组算
  • TestGetTopRanking_RankBeyondTop3 — my_rank>3 → 多一次 OFFSET 查询
  • TestGetTopRanking_FanProfileFailureGetFanProfile 抛错 → avatar_url 为空字符串其他字段正常返回WARN 日志)

10.3 缓存测试

文件:backend/services/activityService/service/activity_service_cache_test.go(新建)

  • TestGetTop3WithCache_Hit — Redis 有合法 JSON → 直接返回,activityRepo.GetTop3 调用次数=0
  • TestGetTop3WithCache_Miss — Redis nil → 回源 DB + setex 写入
  • TestGetTop3WithCache_CorruptedJSON — Redis 有脏数据 → 当 miss 处理,回源 DB 覆盖写入
  • TestGetTop3WithCache_RedisDown — Redis Get 返回非 nil 错误 → WARN 日志 + 回源 DB不阻塞
  • TestGetTop3WithCache_SetFailure — DB 查成功但 Redis Set 失败 → 仍返回 DB 结果WARN
  • TestStarIDOrAll — star_id=0 → "all"star_id>0 → 字符串

mock *redis.Client(用 miniredis 或接口 mock+ activityRepo,不连真实 Redis / DB。

10.4 Controller 验证

手动 curl 验证 code=0 / 401 / 400 / 404 四类响应,不写 httptest。


11. 回滚策略

  • Proto / handler / service / repo 一次性提交到 feature 分支
  • 前端 TopRanking.vue 切换到新接口作为单独 commit
  • 出问题回退时:
    1. 先回退前端 commit用户侧 0 影响)
    2. 再回退后端 commit/ranking 接口始终可用)

契约保留:本期后 /ranking 接口完全不变,所有历史客户端不受影响。


12. 未来工作(不在本期)

  1. Redis ZSET 缓存全量榜单:本期 top3 缓存 TTL=30s 有最长 30s 延迟。若用户体验不佳可升级到 ZSETZINCRBY 实时写、ZREVRANGE 实时读。需要冷启动回填脚本 + 双写一致性策略。
  2. WebSocket 实时推送:复用 2026-06-22-activity-realtime-websocket-design.md 的频道设计
  3. 活动结束态缓存:活动结束后榜单不再变动,可缓存到 Redis 永久 key

13. 变更文件清单

文件 类型 说明
backend/proto/activity.proto 新增 TopRankingRequest / TopRankingItem / MyTopRankingInfo / TopRankingResponse + RPC
backend/pkg/proto/activity/activity.pb.go protoc 自动生成
backend/pkg/proto/activity/activity_grpc.pb.go protoc 自动生成
backend/services/activityService/provider/activity_provider.go 新增 GetTopRanking 实现
backend/services/activityService/service/activity_service.go 新增 GetTopRanking 方法 + getTop3WithCache 私有方法 + starIDOrAll 工具函数
backend/services/activityService/service/activity_service_test.go 新建 单元测试
backend/services/activityService/service/activity_service_cache_test.go 新建 缓存相关单元测试hit / miss / 脏数据 / Redis 故障 / set 失败)
backend/services/activityService/repository/activity_repository.go 新增 GetTop3 / GetUserStatsForRanking
backend/services/activityService/repository/activity_repository_test.go 新建 单元测试
backend/gateway/controller/activity_controller.go 新增 GetTopRanking handler + convertTopRankingResponse
backend/gateway/router/router.go 注册新路由
backend/gateway/docs/swagger.json update-swagger.sh 自动生成
frontend/utils/api.js 新增 getActivityTopRankingApi
frontend/pages/support-activity/components/TopRanking.vue 切换到新接口,删除前端 gap 计算

合计 14 个文件(含 3 个新建、11 个修改)。


14. 验收清单

  • 接口 GET /api/v1/activities/:id/top-ranking 返回符合 §2.2 / §2.3 / §2.4 的 JSON
  • top3 始终按 rank 升序、数组长度 ∈ [0, 3]
  • gap_to_prev >= 0
  • 未上榜用户 status="unranked"rank=0,但 avatar_url 仍填充
  • rank=1 用户 gap_to_prev=0
  • rank=2 / rank=3 用户的 gap_to_prev 从 top3 数组算(无额外 DB 查询)
  • rank>=4 用户的 gap_to_prev 通过 OFFSET 查询拿到
  • GetFanProfile 失败不阻塞接口
  • JWT 缺失返回 401
  • activity_id <= 0 返回 400
  • top3 缓存命中时不查 DB命中失败/Redis 故障 fallback DB 不阻塞接口
  • Redis 缓存写入失败仅 WARN接口返回 DB 数据
  • 单元测试全部通过(含 §10.3 缓存测试 6 个 case
  • Swagger 文档更新
  • 旧的 /ranking 接口完全不变