# 活动 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` 设计为通用分页榜单,返回 `nickname`、`total_crystal_spent` 等 TopRanking 不需要的字段。 2. **计算外移**:gap 应是后端责任,前端拼装容易出错且不同客户端会重复实现。 **本期目标**:新增一个**专用轻量接口** `GET /api/v1/activities/:id/top-ranking`,仅返回 top3 + my_info(含 `gap_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 | Header:`Authorization: Bearer ` ### 2.2 响应(`code=0`) ```json { "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`,用户未上榜) ```json { "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) ```json { "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`(榜首,没有上一名) > - 异常 clamp:`gap_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`,追加: ```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.go` 和 `activity_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 == nil` → `status="unranked"`,rank=0,gap_to_prev=0 - 否则调 `repo.GetUserRank(userID, activityID, starID)` 拿到 `myRank`,`status="ranked"` 4. **计算 gap_to_prev**: - 若 `myRank <= 1` → 0 - 若 `2 <= myRank <= 3` → `gap_to_prev = top3[myRank-2].total_contribution - myStats.total_contribution`(O(1),top3 数组已就绪) - 若 `myRank > 3` → 多一次 query: ```sql 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. **clamp**:`gap_to_prev = max(0, gap_to_prev)`(并发更新时自己的贡献值可能已被刷新) 6. **填充 avatar_url**:top3 每个 user 一次 `userRPCClient.GetFanProfile(userID, starID)`;my_info **无论 ranked / unranked** 都调一次 `GetFanProfile` 拿当前用户头像;单点失败不阻塞,记录 WARN 后继续(avatar_url 留空字符串,前端有兜底) ### 5.2 Repository 新增方法 **`GetTop3(activityID, starID int64) ([]models.ActivityUserStats, error)`** ```go 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)`** ```go 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 == 0` ⇔ `status == "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 行附近追加: ```go activities.GET("/:id/top-ranking", activityCtrl.GetTopRanking) ``` `AuthMiddleware()` 已在外层 `Group` 应用,无需额外声明。 ### 6.2 Controller 方法 `backend/gateway/controller/activity_controller.go` 新增: ```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 可能为 nil(token 中无 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`** 辅助函数(同文件内): ```go 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` 之后新增: ```js // 获取活动 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 关键代码路径(伪代码) ```go // 在 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)→ 记 WARN,fallback 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_FanProfileFailure` — `GetFanProfile` 抛错 → 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 延迟。若用户体验不佳可升级到 ZSET:`ZINCRBY` 实时写、`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` 接口完全不变