567 lines
20 KiB
Markdown
567 lines
20 KiB
Markdown
# 活动 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=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` 接口完全不变 |