docs:增加文档
This commit is contained in:
parent
7eb6dad434
commit
b4d34f9ec1
@ -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 地址(自部署或云服务)
|
||||
|
||||
@ -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 已有的共享通道语义)
|
||||
|
||||
---
|
||||
|
||||
@ -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=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` 接口完全不变
|
||||
@ -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
|
||||
|
||||
@ -458,6 +458,7 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.ranking-tabs {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user