diff --git a/backend/.env.example b/backend/.env.example index 496b951..bb5aa95 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 地址(自部署或云服务) diff --git a/docs/superpowers/specs/2026-06-22-activity-realtime-websocket-design.md b/docs/superpowers/specs/2026-06-22-activity-realtime-websocket-design.md index 87d5772..c226223 100644 --- a/docs/superpowers/specs/2026-06-22-activity-realtime-websocket-design.md +++ b/docs/superpowers/specs/2026-06-22-activity-realtime-websocket-design.md @@ -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 401,body `{"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 已有的共享通道语义) --- diff --git a/docs/superpowers/specs/2026-06-22-activity-top-ranking-api-design.md b/docs/superpowers/specs/2026-06-22-activity-top-ranking-api-design.md new file mode 100644 index 0000000..99bffc1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-activity-top-ranking-api-design.md @@ -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 ` + +### 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` 接口完全不变 \ No newline at end of file diff --git a/frontend/.env.development b/frontend/.env.development index fb9a78f..6bc01d9 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -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 diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index 5fdc78a..0c2692d 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -458,6 +458,7 @@ onUnmounted(() => { overflow: hidden; position: relative; z-index: 2; + margin-top: 32rpx; } .ranking-tabs {