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

567 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 活动 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 <jwt>`
### 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=0gap_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 可能为 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`** 辅助函数(同文件内):
```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→ 记 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_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` 接口完全不变