20 KiB
活动 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。存在两个问题:
- 接口冗余:
/ranking设计为通用分页榜单,返回nickname、total_crystal_spent等 TopRanking 不需要的字段。 - 计算外移: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 <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(榜首,没有上一名)- 异常 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,追加:
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 步骤
- 取 top3(带缓存):调用
s.getTop3WithCache(ctx, activityID, starID)(详见 §9.4),最多返回 3 行(按total_contribution DESC),不足返回实际数量;缓存命中时不查 DB - 取自己的统计:
myStats, _ := repo.GetUserStatsForRanking(activityID, userID, starID) - 判定 my_info:
- 若
myStats == nil→status="unranked",rank=0,gap_to_prev=0 - 否则调
repo.GetUserRank(userID, activityID, starID)拿到myRank,status="ranked"
- 若
- 计算 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:
offset =SELECT total_contribution FROM activity_user_stats WHERE activity_id = ? AND star_id = ? ORDER BY total_contribution DESC OFFSET ? LIMIT 1myRank - 2
- 若
- clamp:
gap_to_prev = max(0, gap_to_prev)(并发更新时自己的贡献值可能已被刷新) - 填充 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)
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 >= 0my_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 行附近追加:
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 可能为 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 辅助函数(同文件内):
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)→ 记 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, nilTestGetUserStatsForRanking_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=0TestGetTopRanking_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调用次数=0TestGetTop3WithCache_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 - 出问题回退时:
- 先回退前端 commit(用户侧 0 影响)
- 再回退后端 commit(旧
/ranking接口始终可用)
契约保留:本期后 /ranking 接口完全不变,所有历史客户端不受影响。
12. 未来工作(不在本期)
- Redis ZSET 缓存全量榜单:本期 top3 缓存 TTL=30s 有最长 30s 延迟。若用户体验不佳可升级到 ZSET:
ZINCRBY实时写、ZREVRANGE实时读。需要冷启动回填脚本 + 双写一致性策略。 - WebSocket 实时推送:复用
2026-06-22-activity-realtime-websocket-design.md的频道设计 - 活动结束态缓存:活动结束后榜单不再变动,可缓存到 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接口完全不变