docs:增加文档

This commit is contained in:
zerosaturation 2026-06-22 20:02:00 +08:00
parent 7eb6dad434
commit b4d34f9ec1
5 changed files with 638 additions and 32 deletions

View File

@ -77,7 +77,7 @@ SEGMENT_INFERENCE_URL=
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# }
WS_AI_CHAT_PATH=/ai-chat
# WS_AI_CHAT_PATH=/ai-chat
# ==================== Dify AI Workflow ====================
# Dify API 地址(自部署或云服务)

View File

@ -48,7 +48,7 @@
- `frontend/utils/socket/SocketManager.js` 基础类
- `frontend/utils/socket/AiChatSocket.js` AI Chat WS 实现
- `frontend/utils/socket/GlobalSocketManager.js` 统一管理多服务连接
- 需要新增 `ActivitySocket.js``/ws/activity`
- 需要新增 `ActivitySocket.js``/activity`
---
@ -202,8 +202,10 @@ service ActivityService {
|-----|------|------|------|------|
| 1 | GET | `/api/v1/activities/{activity_id}/messages` | 列出活动留言(首次/下拉) | 需要 |
| 2 | POST | `/api/v1/activities/{activity_id}/messages` | 发送一条留言 | 需要 |
| 3 | GET | `/api/v1/activities/{activity_id}/contributions/latest` | 增量获取最新贡献(**仅作 WS 断线降级** | 需要 |
| 3 | GET | `/api/v1/activities/{activity_id}/contributions/latest` | 增量获取最新贡献(**已存在,仅作 WS 断线降级** | 需要 |
> 接口 #3 是已有的 `GetLatestContributions`proto `activity.proto:296`**不需新写**,只在前端降级路径调用。
>
> 注:原 `ListActivityMessages` / `CreateActivityMessage` 的请求/响应字段、错误码、业务规则沿用 `活动留言板接口文档.md §5.3 ~ §5.4`,不在本文档重复。差异点:
> - 列表查询去掉"分页 20",首版固定 page=1, page_size=20 即可
> - 错误码增加 `ErrActivityMessageActivityInactive` 映射(活动状态非 active
@ -214,12 +216,12 @@ service ActivityService {
### 6.1 连接
- 路径:`ws://{host}/ws/activity?token={JWT}`
- 路径:`ws://{host}/activity?token={JWT}`
- 鉴权URL token 参数(参考 `ai_chat_socket.go``validateToken`
- 失败:返回 HTTP 401body `{"type":"auth_response","success":false,"error":"invalid_token"}`
- 成功:服务端立即 push 一条 `auth_response``{"type":"auth_response","success":true,"user_id":..,"star_id":..}`
- 心跳30s 客户端 `ping` / 服务端 `pong`(与 ai-chat 一致)
- 重连:客户端使用 1s, 2s, 4s, 8s, 16s, 30s 指数退避,最多 5 次后保持 30s
- 重连:客户端使用 **1s, 2s, 4s, 8s, 16s, 30s 指数退避****前 5 次按退避重试;之后以 30s 固定间隔持续重试**(不停止),确保弱网下也能恢复
### 6.2 客户端 → 服务端
@ -279,7 +281,7 @@ services/activityService/service/activity_service.go ← 业务编排
services/activityService/repository/ ← SQL 封装
WebSocket /ws/activity
WebSocket /activity
gateway/socket/activity_socket.go ← ActivityHub
@ -297,14 +299,14 @@ gateway/socket/activity_socket.go ← ActivityHub
| 新增 | `backend/migrations/2026_06_22_012_activity_messages.sql` | 建表 + 索引 + 注释 |
| 修改 | `backend/proto/activity.proto` | 追加 3 个 message + 2 个 RPC |
| 新增 | `backend/services/activityService/repository/activity_messages_repository.go` | 仓储层 |
| 修改 | `backend/services/activityService/service/activity_service.go` | 追加 2 个业务方法 + 末尾 Redis Publish |
| 修改 | `backend/services/activityService/provider/activity_provider.go` | 追加 2 个 RPC 入口 + Dubbo Publish 钩子 |
| 修改 | `backend/services/activityService/repository/activity_repository.go` | 注册仓储 |
| 新增 | `backend/gateway/socket/activity_socket.go` | ActivityHub仿 ai_chat_socket.go |
| 修改 | `backend/gateway/router/router.go` | 注册 `r.GET("/ws/activity", ...)` |
| 修改 | `backend/services/activityService/service/activity_service.go` | 追加 2 个业务方法 + `PurchaseItem` / `BatchPurchaseItem` / `CreateActivityMessage` 末尾 Redis Publish |
| 修改 | `backend/services/activityService/provider/activity_provider.go` | 追加 2 个 RPC 入口(仅做参数透传,业务在 service 层) |
| 修改 | `backend/services/activityService/repository/activity_repository.go` | 不动;新建 `activity_messages_repository.go` 与之并列 |
| 新增 | `backend/gateway/socket/activity_socket.go` | ActivityHub仿 `ai_chat_socket.go` 模式,但**不修改** ai-chat 现有代码 |
| 修改 | `backend/gateway/router/router.go` | 注册 `r.GET("/activity", ...)` |
| 修改 | `backend/gateway/main.go` | 创建 ActivityHub 并注入 |
| 修改 | `backend/gateway/config/config.go` | 加 `WebSocket.ActivityPath` 字段 |
| 修改 | `backend/gateway/socket/ai_chat_socket.go` | 不变(参考模式) |
| 修改 | `backend/services/activityService/configs/config.yaml` | 加 `message_rate_limit_per_min: 5` / `message_limit_per_activity: 100` |
| 修改 | `backend/pkg/errors/errors.go` | 加 7 个留言错误变量 + ToGRPCCode 映射 |
| 修改 | `backend/gateway/pkg/response/response.go` | 加 6 条中文错误映射 |
| 新增 | `frontend/utils/socket/ActivitySocket.js` | 继承 SocketManager |
@ -450,36 +452,66 @@ class ActivitySocket extends SocketManager {
### 8.3 useContributionRealtime 钩子
> 设计要点:**WS 优先**,轮询仅在 WS 断线时启动;轮询与 WS **互斥**,不会同时跑(避免重复记录)。
```js
// composables/useContributionRealtime.js
export function useContributionRealtime(activityId, isPageActive) {
const { records, ... } = useContributionPolling(activityId, isPageActive) // 复用轮询逻辑
// 复用 useContributionPolling 的 records / highestId / 增量合并逻辑
// 但不直接调 start(),由本钩子按 WS 状态决定何时调 start/stop
const { records, start, stop, reset, highestIdRef } = useContributionPolling(
activityId, isPageActive
)
const socket = getActivitySocket()
let usingWS = true
let usingWS = false // 默认 false,等 WS onConnect 后才置 true
function onMessage(payload) {
function onWsMessage(payload) {
if (payload.activity_id !== activityId.value) return
// 与轮询同款 highest_id 增量逻辑
if (payload.record.id > highestId) {
records.value = [payload.record, ...records.value].slice(0, MAX)
highestId = payload.record.id
if (payload.record.id > highestIdRef.value) {
records.value = [payload.record, ...records.value].slice(0, MAX_RECORDS)
highestIdRef.value = payload.record.id
}
}
socket.onContributionsResponse(onMessage)
socket.on('disconnect', () => {
usingWS = false
startPolling() // 降级
})
socket.on('connect', () => {
if (!usingWS) { stopPolling(); usingWS = true }
function onWsConnect() {
if (usingWS) return
usingWS = true
stop() // 停掉可能的轮询
socket.subscribe(activityId.value, ['contributions'])
}
function onWsDisconnect() {
if (!usingWS) return
usingWS = false
// 降级开始轮询highestId 已保留,避免重拉)
start()
}
socket.onContributionsResponse(onWsMessage)
socket.on('connect', onWsConnect)
socket.on('disconnect', onWsDisconnect)
// 组件挂载时若已连上立即订阅
onMounted(() => {
if (socket.isConnected) onWsConnect()
})
return { records, ... }
// 卸载清理
onUnmounted(() => {
if (usingWS) socket.unsubscribe(activityId.value, ['contributions'])
socket.off('connect', onWsConnect)
socket.off('disconnect', onWsDisconnect)
socket.offContributionsResponse(onWsMessage)
stop()
})
return { records }
}
```
> **注意**`useContributionPolling` 当前不暴露 `highestIdRef`,需要小幅重构让它返回 `{records, start, stop, reset, highestIdRef, latestTimestampRef}`,或在本钩子内自己维护 `highestIdRef`。实施时二选一。
### 8.4 useMessageRealtime 钩子
```js
@ -505,10 +537,14 @@ export function useMessageRealtime(activityId) {
async function sendMessage(content) {
const res = await createActivityMessageApi(activityId.value, content)
if (res.code === 0) {
// 不本地 push,等 WS 推回来(避免重复)
// 如果 WS 断线,这里降级 push
// 成功时不本地 push等 WS 推回来(避免重复)
// 若 WS 断线:服务端不会推送,由前端 fallback 把 res.data.message 插入本地
if (!socket.isConnected) {
messages.value.push(toComponentShape(res.data.message))
if (messages.value.length > 50) messages.value.shift()
}
} else {
uni.showToast({ title: res.message, icon: 'none' })
uni.showToast({ title: res.message || '留言失败', icon: 'none' })
}
}
@ -547,7 +583,8 @@ function handleSendMessage(text) {
## 9. 缓存与 Pub/Sub
- **频控**`msg:rate:{activity_id}:{user_id}` INCR + EXPIRE 60s
- **频控**`msg:rate:{activity_id}:{user_id}` INCR + EXPIRE 60s上限由 `config.yaml``message_rate_limit_per_min` 控制(默认 5
- **累计上限**`message_limit_per_activity`(默认 100超过时拒绝发送
- **连击**`combo:{user_id}:{item_type}` 沿用现有
- **Pub/Sub channel**`act:{activity_id}:messages` / `act:{activity_id}:contributions`
- **不缓存历史**:实时数据不适合缓存;留言 / 贡献查询走 DB
@ -585,6 +622,7 @@ ToGRPCCode 映射 + 中文 errorMap 与 `活动留言板接口文档.md §6` 一
- [ ] 1 分钟内 > 5 条 → ErrActivityMessageTooFrequent
- [ ] 累计 > 100 条 → ErrActivityMessageLimitReached
- [ ] PurchaseItem写库后 Publish `act:{id}:contributions`
- [ ] BatchPurchaseItem每个 item 写库后 Publish `act:{id}:contributions`(或批量发布一次,节省 Redis 流量;首版每条一次便于前端叠加动画)
### 11.2 gateway ActivityHub
@ -612,7 +650,7 @@ ToGRPCCode 映射 + 中文 errorMap 与 `活动留言板接口文档.md §6` 一
- [x] `pkg/errors` 追加 7 个错误变量,不影响 ToGRPCCode 现有行为
- [x] `frontend/pages/support-activity/components/MessageBoard.vue` props 不变
- [x] `activity_messages_id_seq` 起始 10000符合 CLAUDE.md 测试数据预留规范
- [x] WebSocket 路径 `/ws/activity` 与 AI Chat `/ai-chat` 独立,无端口/路径冲突
- [x] WebSocket 路径 `/activity` 与 AI Chat `/ai-chat` 独立,无端口/路径冲突
- [x] 多 gateway 实例:所有实例均订阅同一组 Pub/Sub每实例只 fanout 本地连接Redis 已有的共享通道语义)
---

View File

@ -0,0 +1,567 @@
# 活动 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` 接口完全不变

View File

@ -9,6 +9,6 @@ VITE_WS_BASE_URL=ws://192.168.110.60:8080
# WebSocket 路径:用于 Nginx 反向代理(前端连接的完整 URL = VITE_WS_BASE_URL + VITE_WS_AI_CHAT_PATH
# 需与后端 backend/.env 的 WS_AI_CHAT_PATH 保持一致
# Nginx 示例location /ai-chat { proxy_pass http://gateway:8080; ... }
VITE_WS_AI_CHAT_PATH=/ai-chat
# VITE_WS_AI_CHAT_PATH=/ai-chat
VITE_USE_MOCK_API=false
# VITE_ENV_NAME=development

View File

@ -458,6 +458,7 @@ onUnmounted(() => {
overflow: hidden;
position: relative;
z-index: 2;
margin-top: 32rpx;
}
.ranking-tabs {