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 Upgrade $http_upgrade;
|
||||||
# proxy_set_header Connection "upgrade";
|
# proxy_set_header Connection "upgrade";
|
||||||
# }
|
# }
|
||||||
WS_AI_CHAT_PATH=/ai-chat
|
# WS_AI_CHAT_PATH=/ai-chat
|
||||||
|
|
||||||
# ==================== Dify AI Workflow ====================
|
# ==================== Dify AI Workflow ====================
|
||||||
# Dify API 地址(自部署或云服务)
|
# Dify API 地址(自部署或云服务)
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
- `frontend/utils/socket/SocketManager.js` 基础类
|
- `frontend/utils/socket/SocketManager.js` 基础类
|
||||||
- `frontend/utils/socket/AiChatSocket.js` AI Chat WS 实现
|
- `frontend/utils/socket/AiChatSocket.js` AI Chat WS 实现
|
||||||
- `frontend/utils/socket/GlobalSocketManager.js` 统一管理多服务连接
|
- `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` | 列出活动留言(首次/下拉) | 需要 |
|
| 1 | GET | `/api/v1/activities/{activity_id}/messages` | 列出活动留言(首次/下拉) | 需要 |
|
||||||
| 2 | POST | `/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`,不在本文档重复。差异点:
|
> 注:原 `ListActivityMessages` / `CreateActivityMessage` 的请求/响应字段、错误码、业务规则沿用 `活动留言板接口文档.md §5.3 ~ §5.4`,不在本文档重复。差异点:
|
||||||
> - 列表查询去掉"分页 20",首版固定 page=1, page_size=20 即可
|
> - 列表查询去掉"分页 20",首版固定 page=1, page_size=20 即可
|
||||||
> - 错误码增加 `ErrActivityMessageActivityInactive` 映射(活动状态非 active)
|
> - 错误码增加 `ErrActivityMessageActivityInactive` 映射(活动状态非 active)
|
||||||
@ -214,12 +216,12 @@ service ActivityService {
|
|||||||
|
|
||||||
### 6.1 连接
|
### 6.1 连接
|
||||||
|
|
||||||
- 路径:`ws://{host}/ws/activity?token={JWT}`
|
- 路径:`ws://{host}/activity?token={JWT}`
|
||||||
- 鉴权:URL token 参数(参考 `ai_chat_socket.go` 的 `validateToken`)
|
- 鉴权:URL token 参数(参考 `ai_chat_socket.go` 的 `validateToken`)
|
||||||
- 失败:返回 HTTP 401,body `{"type":"auth_response","success":false,"error":"invalid_token"}`
|
- 失败:返回 HTTP 401,body `{"type":"auth_response","success":false,"error":"invalid_token"}`
|
||||||
- 成功:服务端立即 push 一条 `auth_response`:`{"type":"auth_response","success":true,"user_id":..,"star_id":..}`
|
- 成功:服务端立即 push 一条 `auth_response`:`{"type":"auth_response","success":true,"user_id":..,"star_id":..}`
|
||||||
- 心跳:30s 客户端 `ping` / 服务端 `pong`(与 ai-chat 一致)
|
- 心跳:30s 客户端 `ping` / 服务端 `pong`(与 ai-chat 一致)
|
||||||
- 重连:客户端使用 1s, 2s, 4s, 8s, 16s, 30s 指数退避,最多 5 次后保持 30s
|
- 重连:客户端使用 **1s, 2s, 4s, 8s, 16s, 30s 指数退避**,**前 5 次按退避重试;之后以 30s 固定间隔持续重试**(不停止),确保弱网下也能恢复
|
||||||
|
|
||||||
### 6.2 客户端 → 服务端
|
### 6.2 客户端 → 服务端
|
||||||
|
|
||||||
@ -279,7 +281,7 @@ services/activityService/service/activity_service.go ← 业务编排
|
|||||||
▼
|
▼
|
||||||
services/activityService/repository/ ← SQL 封装
|
services/activityService/repository/ ← SQL 封装
|
||||||
|
|
||||||
WebSocket /ws/activity
|
WebSocket /activity
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
gateway/socket/activity_socket.go ← ActivityHub
|
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/migrations/2026_06_22_012_activity_messages.sql` | 建表 + 索引 + 注释 |
|
||||||
| 修改 | `backend/proto/activity.proto` | 追加 3 个 message + 2 个 RPC |
|
| 修改 | `backend/proto/activity.proto` | 追加 3 个 message + 2 个 RPC |
|
||||||
| 新增 | `backend/services/activityService/repository/activity_messages_repository.go` | 仓储层 |
|
| 新增 | `backend/services/activityService/repository/activity_messages_repository.go` | 仓储层 |
|
||||||
| 修改 | `backend/services/activityService/service/activity_service.go` | 追加 2 个业务方法 + 末尾 Redis Publish |
|
| 修改 | `backend/services/activityService/service/activity_service.go` | 追加 2 个业务方法 + 在 `PurchaseItem` / `BatchPurchaseItem` / `CreateActivityMessage` 末尾 Redis Publish |
|
||||||
| 修改 | `backend/services/activityService/provider/activity_provider.go` | 追加 2 个 RPC 入口 + Dubbo Publish 钩子 |
|
| 修改 | `backend/services/activityService/provider/activity_provider.go` | 追加 2 个 RPC 入口(仅做参数透传,业务在 service 层) |
|
||||||
| 修改 | `backend/services/activityService/repository/activity_repository.go` | 注册仓储 |
|
| 修改 | `backend/services/activityService/repository/activity_repository.go` | 不动;新建 `activity_messages_repository.go` 与之并列 |
|
||||||
| 新增 | `backend/gateway/socket/activity_socket.go` | ActivityHub(仿 ai_chat_socket.go) |
|
| 新增 | `backend/gateway/socket/activity_socket.go` | ActivityHub(仿 `ai_chat_socket.go` 模式,但**不修改** ai-chat 现有代码) |
|
||||||
| 修改 | `backend/gateway/router/router.go` | 注册 `r.GET("/ws/activity", ...)` |
|
| 修改 | `backend/gateway/router/router.go` | 注册 `r.GET("/activity", ...)` |
|
||||||
| 修改 | `backend/gateway/main.go` | 创建 ActivityHub 并注入 |
|
| 修改 | `backend/gateway/main.go` | 创建 ActivityHub 并注入 |
|
||||||
| 修改 | `backend/gateway/config/config.go` | 加 `WebSocket.ActivityPath` 字段 |
|
| 修改 | `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/pkg/errors/errors.go` | 加 7 个留言错误变量 + ToGRPCCode 映射 |
|
||||||
| 修改 | `backend/gateway/pkg/response/response.go` | 加 6 条中文错误映射 |
|
| 修改 | `backend/gateway/pkg/response/response.go` | 加 6 条中文错误映射 |
|
||||||
| 新增 | `frontend/utils/socket/ActivitySocket.js` | 继承 SocketManager |
|
| 新增 | `frontend/utils/socket/ActivitySocket.js` | 继承 SocketManager |
|
||||||
@ -450,36 +452,66 @@ class ActivitySocket extends SocketManager {
|
|||||||
|
|
||||||
### 8.3 useContributionRealtime 钩子
|
### 8.3 useContributionRealtime 钩子
|
||||||
|
|
||||||
|
> 设计要点:**WS 优先**,轮询仅在 WS 断线时启动;轮询与 WS **互斥**,不会同时跑(避免重复记录)。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// composables/useContributionRealtime.js
|
// composables/useContributionRealtime.js
|
||||||
export function useContributionRealtime(activityId, isPageActive) {
|
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()
|
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
|
if (payload.activity_id !== activityId.value) return
|
||||||
// 与轮询同款 highest_id 增量逻辑
|
// 与轮询同款 highest_id 增量逻辑
|
||||||
if (payload.record.id > highestId) {
|
if (payload.record.id > highestIdRef.value) {
|
||||||
records.value = [payload.record, ...records.value].slice(0, MAX)
|
records.value = [payload.record, ...records.value].slice(0, MAX_RECORDS)
|
||||||
highestId = payload.record.id
|
highestIdRef.value = payload.record.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
socket.onContributionsResponse(onMessage)
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
function onWsConnect() {
|
||||||
usingWS = false
|
if (usingWS) return
|
||||||
startPolling() // 降级
|
usingWS = true
|
||||||
})
|
stop() // 停掉可能的轮询
|
||||||
socket.on('connect', () => {
|
|
||||||
if (!usingWS) { stopPolling(); usingWS = true }
|
|
||||||
socket.subscribe(activityId.value, ['contributions'])
|
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 钩子
|
### 8.4 useMessageRealtime 钩子
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@ -505,10 +537,14 @@ export function useMessageRealtime(activityId) {
|
|||||||
async function sendMessage(content) {
|
async function sendMessage(content) {
|
||||||
const res = await createActivityMessageApi(activityId.value, content)
|
const res = await createActivityMessageApi(activityId.value, content)
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
// 不本地 push,等 WS 推回来(避免重复)
|
// 成功时不本地 push,等 WS 推回来(避免重复)
|
||||||
// 如果 WS 断线,这里降级 push
|
// 若 WS 断线:服务端不会推送,由前端 fallback 把 res.data.message 插入本地
|
||||||
|
if (!socket.isConnected) {
|
||||||
|
messages.value.push(toComponentShape(res.data.message))
|
||||||
|
if (messages.value.length > 50) messages.value.shift()
|
||||||
|
}
|
||||||
} else {
|
} 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
|
## 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}` 沿用现有
|
- **连击**:`combo:{user_id}:{item_type}` 沿用现有
|
||||||
- **Pub/Sub channel**:`act:{activity_id}:messages` / `act:{activity_id}:contributions`
|
- **Pub/Sub channel**:`act:{activity_id}:messages` / `act:{activity_id}:contributions`
|
||||||
- **不缓存历史**:实时数据不适合缓存;留言 / 贡献查询走 DB
|
- **不缓存历史**:实时数据不适合缓存;留言 / 贡献查询走 DB
|
||||||
@ -585,6 +622,7 @@ ToGRPCCode 映射 + 中文 errorMap 与 `活动留言板接口文档.md §6` 一
|
|||||||
- [ ] 1 分钟内 > 5 条 → ErrActivityMessageTooFrequent
|
- [ ] 1 分钟内 > 5 条 → ErrActivityMessageTooFrequent
|
||||||
- [ ] 累计 > 100 条 → ErrActivityMessageLimitReached
|
- [ ] 累计 > 100 条 → ErrActivityMessageLimitReached
|
||||||
- [ ] PurchaseItem:写库后 Publish `act:{id}:contributions`
|
- [ ] PurchaseItem:写库后 Publish `act:{id}:contributions`
|
||||||
|
- [ ] BatchPurchaseItem:每个 item 写库后 Publish `act:{id}:contributions`(或批量发布一次,节省 Redis 流量;首版每条一次便于前端叠加动画)
|
||||||
|
|
||||||
### 11.2 gateway ActivityHub
|
### 11.2 gateway ActivityHub
|
||||||
|
|
||||||
@ -612,7 +650,7 @@ ToGRPCCode 映射 + 中文 errorMap 与 `活动留言板接口文档.md §6` 一
|
|||||||
- [x] `pkg/errors` 追加 7 个错误变量,不影响 ToGRPCCode 现有行为
|
- [x] `pkg/errors` 追加 7 个错误变量,不影响 ToGRPCCode 现有行为
|
||||||
- [x] `frontend/pages/support-activity/components/MessageBoard.vue` props 不变
|
- [x] `frontend/pages/support-activity/components/MessageBoard.vue` props 不变
|
||||||
- [x] `activity_messages_id_seq` 起始 10000,符合 CLAUDE.md 测试数据预留规范
|
- [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 已有的共享通道语义)
|
- [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)
|
# WebSocket 路径:用于 Nginx 反向代理(前端连接的完整 URL = VITE_WS_BASE_URL + VITE_WS_AI_CHAT_PATH)
|
||||||
# 需与后端 backend/.env 的 WS_AI_CHAT_PATH 保持一致
|
# 需与后端 backend/.env 的 WS_AI_CHAT_PATH 保持一致
|
||||||
# Nginx 示例:location /ai-chat { proxy_pass http://gateway:8080; ... }
|
# 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_USE_MOCK_API=false
|
||||||
# VITE_ENV_NAME=development
|
# VITE_ENV_NAME=development
|
||||||
|
|||||||
@ -458,6 +458,7 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
margin-top: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-tabs {
|
.ranking-tabs {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user