topfans/docs/superpowers/specs/2026-06-22-activity-realtime-websocket-design.md
2026-06-22 20:02:00 +08:00

667 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 活动实时推送(留言 + 贡献WebSocket 实施规格
> 本文档**替代** `backend/docs/活动留言板接口文档.md` 中关于 HTTP 接口与轮询的部分,作为留言板与贡献(购买道具)实时推送的**实施规格**。
>
> 范围:单一活动页(`pages/support-activity/index.vue`)的留言板(`MessageBoard`)与贡献列表(`ContributionList`)统一走 WebSocket 推送,长轮询仅作断线降级。
---
## 0. 与原文档的关系
| 章节 | 状态 | 原因 |
|------|------|------|
| 1. 背景与现状 | 保留并更新 | MessageBoard 仍为纯展示组件;贡献列表已从 mock 切到 `useContributionPolling` |
| 3. 数据库设计 | **保留并新增 activity_messages 表** | 留言需要持久化;`activity_contributions` 表已存在,无须新建 |
| 4. Proto 定义 | **保留并精简** | 删除 GetLatestActivityMessages 轮询接口;新增 messages 相关 RPC |
| 5. HTTP 接口设计 | **改造** | 留言列表 / 发送走 HTTP增量获取贡献走 WebSocketHTTP 保留为降级) |
| 6. 错误码扩展 | 保留 | 复用 |
| 7. 分层实现 | 改造 | 新增 WebSocket Hub 层 |
| 8. 前端集成要点 | **重写** | 改用 ActivitySocket + useContributionRealtime + useMessageRealtime |
| 9. 缓存与性能 | 保留并补充 | 新增 Redis Pub/Sub channel |
| 10. 测试用例 | 改造 | 增加 WS 相关测试 |
| 11. 开放问题 | 收口 | 留言敏感词 / WebSocket 推送方案由本文档确定 |
| 12. 变更影响面 | 保留 | |
---
## 1. 背景与现状
### 1.1 前端组件
`MessageBoard.vue` 是**纯展示组件**presentational component通过 props.messages 接收留言列表。`ContributionList.vue` 通过 `useContributionPolling.js` 每 1 秒轮询一次 `getActivityContributionsLatestApi`,按 `highest_id` 增量取最新 5 条。
两个组件都挂在 `pages/support-activity/index.vue` 同一页面,期望**实时**显示:
- 用户 A 发出留言 → 同一页面下其他用户能立刻看到
- 用户 B 购买道具 → 同一页面下所有用户的 `ContributionList` 立刻新增一条气泡
### 1.2 后端现状
- `services/activityService` 已有 `PurchaseItem` / `BatchPurchaseItem` 写入 `activity_contributions`
- `activity_contributions` 表已存在(`migrate_create_activity_contributions_table.sql`),但**没有 Pub/Sub 广播**
- 数据库**没有** `activity_messages` 表(需要新建)
- Redis 已用于连击计数 `combo:{user_id}:{item_type}`**未**用于 Pub/Sub
- `backend/gateway/socket/ai_chat_socket.go` 已有 WebSocket Hub 实现,可参照其模式新增活动 Hub
### 1.3 前端 WebSocket 现状
- `frontend/utils/socket/SocketManager.js` 基础类
- `frontend/utils/socket/AiChatSocket.js` AI Chat WS 实现
- `frontend/utils/socket/GlobalSocketManager.js` 统一管理多服务连接
- 需要新增 `ActivitySocket.js``/activity`
---
## 2. 设计目标
1. 留言发送走 HTTP POST可重试保留接口文档
2. 留言接收 / 贡献接收统一走 WebSocket 推送
3. 单一连接 + topic 频道(`messages` / `contributions`),不增加额外 TCP 连接
4. WS 断线时前端自动降级为 `getActivityContributionsLatestApi` 轮询
5. WS 重连后用 `highest_id` 拉差量,补齐漏推
6. 不破坏现有 `MessageBoard.vue` props 契约,前端只换数据源
---
## 3. 数据库设计
### 3.1 新增表 `activity_messages`
```sql
-- 2026_06_22_012_activity_messages.sql
CREATE TABLE IF NOT EXISTS public.activity_messages (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
content VARCHAR(500) NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT,
CONSTRAINT fk_messages_activity
FOREIGN KEY (activity_id) REFERENCES public.activities(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_user
FOREIGN KEY (user_id) REFERENCES public.users(id),
CONSTRAINT fk_messages_star
FOREIGN KEY (star_id) REFERENCES public.stars(star_id)
);
CREATE SEQUENCE IF NOT EXISTS activity_messages_id_seq START WITH 10000;
CREATE INDEX IF NOT EXISTS idx_activity_messages_activity_created
ON public.activity_messages (activity_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_activity_messages_user_created
ON public.activity_messages (user_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_activity_messages_activity_incr
ON public.activity_messages (activity_id, id DESC)
WHERE deleted_at IS NULL;
COMMENT ON TABLE public.activity_messages IS '活动留言表';
COMMENT ON COLUMN public.activity_messages.id IS '主键,自增';
COMMENT ON COLUMN public.activity_messages.activity_id IS 'FK -> activities.id';
COMMENT ON COLUMN public.activity_messages.user_id IS '留言用户 ID';
COMMENT ON COLUMN public.activity_messages.star_id IS '所属明星/星球 ID';
COMMENT ON COLUMN public.activity_messages.content IS '留言正文1-500 字';
COMMENT ON COLUMN public.activity_messages.status IS '0=正常|1=隐藏|2=已删除';
COMMENT ON COLUMN public.activity_messages.created_at IS '留言时间,毫秒时间戳';
COMMENT ON COLUMN public.activity_messages.updated_at IS '更新时间,毫秒时间戳';
COMMENT ON COLUMN public.activity_messages.deleted_at IS '软删除时间';
```
> 序列起始 10000 遵循 `CLAUDE.md` 规范。序列健康检查由 `assets_id_seq` 模式扩展为 `pg_sequences WHERE sequencename = 'activity_messages_id_seq'`。
### 3.2 现有 `activity_contributions` 表(沿用)
字段已包含 `id, activity_id, user_id, star_id, item_id, item_type, item_name, item_icon, quantity, combo_count, created_at`,详见 `migrate_create_activity_contributions_table.sql`。无结构改动。
---
## 4. Proto 定义
`backend/proto/activity.proto` 末尾追加:
```protobuf
// ============== 留言相关 RPC ==============
message ActivityMessage {
int64 id = 1;
int64 activity_id = 2;
int64 user_id = 3;
int64 star_id = 4;
string nickname = 5;
string avatar_url = 6;
string content = 7;
int64 created_at = 8;
}
message ListActivityMessagesRequest {
int64 activity_id = 1;
int32 page = 2; // 默认 1
int32 page_size = 3; // 默认 20最大 50
}
message ListActivityMessagesResponse {
topfans.common.BaseResponse base = 1;
repeated ActivityMessage messages = 2;
int32 page = 3;
int32 page_size = 4;
int32 total = 5;
}
message CreateActivityMessageRequest {
int64 activity_id = 1;
int64 user_id = 2; // 从 JWT 解析gateway 注入
int64 star_id = 3;
string content = 4;
}
message CreateActivityMessageResponse {
topfans.common.BaseResponse base = 1;
ActivityMessage message = 2;
}
service ActivityService {
// ... 现有 RPC ...
// 列出活动留言(首次/下拉加载用)
rpc ListActivityMessages(ListActivityMessagesRequest) returns (ListActivityMessagesResponse) {
option (google.api.http) = {
get: "/api/v1/activities/{activity_id}/messages"
};
}
// 发送一条留言
rpc CreateActivityMessage(CreateActivityMessageRequest) returns (CreateActivityMessageResponse) {
option (google.api.http) = {
post: "/api/v1/activities/{activity_id}/messages"
body: "*"
};
}
}
```
> **删除** 原文档中的 `GetLatestActivityMessages` 增量接口,理由:贡献实时走 WS留言实时也走 WSHTTP 增量接口不必要。
---
## 5. HTTP 接口设计
### 5.1 通用约定
- Base URL`/api/v1`
- 鉴权JWT`middleware.AuthMiddleware()`
- 响应结构:沿用 `pkg/response.Response``{code, message, data}`
- 错误码:沿用 `pkg/errors` 的 gRPC code 映射
### 5.2 接口清单
| # | 方法 | 路径 | 说明 | 鉴权 |
|-----|------|------|------|------|
| 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 是已有的 `GetLatestContributions`proto `activity.proto:296`**不需新写**,只在前端降级路径调用。
>
> 注:原 `ListActivityMessages` / `CreateActivityMessage` 的请求/响应字段、错误码、业务规则沿用 `活动留言板接口文档.md §5.3 ~ §5.4`,不在本文档重复。差异点:
> - 列表查询去掉"分页 20",首版固定 page=1, page_size=20 即可
> - 错误码增加 `ErrActivityMessageActivityInactive` 映射(活动状态非 active
---
## 6. WebSocket 协议
### 6.1 连接
- 路径:`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 固定间隔持续重试**(不停止),确保弱网下也能恢复
### 6.2 客户端 → 服务端
| action | body | 说明 |
|--------|------|------|
| `ping` | `{}` | 心跳 |
| `subscribe` | `{activity_id, topics:["messages","contributions"]}` | 订阅活动主题 |
| `unsubscribe` | `{activity_id, topics:[...]}` | 取消订阅 |
> subscribe 是**幂等**的:同一 (activity_id, topic) 多次订阅只算一次。unsubscribe 同理。
### 6.3 服务端 → 客户端
| type | body | 触发时机 |
|------|------|---------|
| `auth_response` | `{success, user_id, star_id}` | 连接成功 |
| `pong` | `{}` | 收到 `ping` |
| `subscribe_response` | `{activity_id, topics:[...]}` | subscribe 成功 |
| `unsubscribe_response` | `{activity_id, topics:[...]}` | unsubscribe 成功 |
| `messages_response` | `{activity_id, message:{ActivityMessage}}` | activity_messages 表新写入 |
| `contributions_response` | `{activity_id, record:{ContributionRecord}}` | activity_contributions 表新写入 |
| `error` | `{code, message}` | 业务错误(如订阅非法 topic |
### 6.4 Pub/Sub 频道定义
| Channel | Publish 端 | Subscribe 端 |
|---------|-----------|--------------|
| `act:{activity_id}:messages` | `activityService.CreateActivityMessage` | `gateway/socket/activity_socket.go` |
| `act:{activity_id}:contributions` | `activityService.PurchaseItem` / `BatchPurchaseItem` | `gateway/socket/activity_socket.go` |
发布 payload 格式(与 push 协议一致):
```json
{"activity_id": 42, "type": "messages_response", "message": {...}}
{"activity_id": 42, "type": "contributions_response", "record": {...}}
```
---
## 7. 后端实现
### 7.1 分层结构
```
HTTP POST /messages
gateway/controller/activity_controller.go ← ListActivityMessages / CreateActivityMessage
│ Dubbo / gRPC (Triple)
services/activityService/provider/activity_provider.go ← RPC 入口
services/activityService/service/activity_service.go ← 业务编排
│ 1. 写 PG
│ 2. Redis Publish "act:{id}:messages"
services/activityService/repository/ ← SQL 封装
WebSocket /activity
gateway/socket/activity_socket.go ← ActivityHub
│ 1. 启动时 psubscribe "act:*:messages" / "act:*:contributions"
│ 2. 收到 Publish → 按 (activity_id, topic) 路由到本地连接
│ 3. writeJSON push 给客户端
前端
```
### 7.2 待新增 / 修改文件清单
| 类型 | 文件 | 说明 |
|------|------|------|
| 新增 | `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 个业务方法 + 在 `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/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 |
| 修改 | `frontend/utils/socket/GlobalSocketManager.js` | 加 `_initActivity()` |
| 新增 | `frontend/utils/socket/activityHandlers.js` | 注册 messages / contributions 处理器 |
| 新增 | `frontend/pages/support-activity/composables/useContributionRealtime.js` | 替代 useContributionPolling |
| 新增 | `frontend/pages/support-activity/composables/useMessageRealtime.js` | 替代 mock |
| 修改 | `frontend/utils/api.js` | 加 `listActivityMessagesApi` / `createActivityMessageApi` |
| 修改 | `frontend/pages/support-activity/index.vue` | 接入 useMessageRealtime / useContributionRealtime |
| 修改 | `frontend/pages/support-activity/components/MessageBoard.vue` | props 不变(仅消费端) |
### 7.3 service 层要点
`CreateActivityMessage` 业务逻辑:
```go
func (s *activityService) CreateActivityMessage(ctx context.Context, req *pb.CreateActivityMessageRequest) (*pb.CreateActivityMessageResponse, error) {
// 1. 入参校验
if strings.TrimSpace(req.Content) == "" { return nil, ErrActivityMessageContentEmpty }
if utf8.RuneCountInString(req.Content) > 500 { return nil, ErrActivityMessageContentTooLong }
// 2. 活动状态
activity, _ := s.activityRepo.GetActivityByID(req.ActivityId)
if activity == nil { return nil, ErrActivityNotFound }
if activity.Status != "active" { return nil, ErrActivityMessageActivityInactive }
// 3. 频控Redis INCR + EXPIRE
rateKey := fmt.Sprintf("msg:rate:%d:%d", req.ActivityId, req.UserId)
count, _ := s.redisClient.Incr(ctx, rateKey).Result()
if count == 1 { s.redisClient.Expire(ctx, rateKey, 60*time.Second) }
if count > cfg.MessageRateLimitPerMin { return nil, ErrActivityMessageTooFrequent }
// 4. 累计上限
total, _ := s.messagesRepo.CountByUserActivity(ctx, req.ActivityId, req.UserId)
if total >= cfg.MessageLimitPerActivity { return nil, ErrActivityMessageLimitReached }
// 5. 敏感词(首版本地词表,后续接 dify
if containsBannedWord(req.Content) { return nil, ErrActivityMessageContentInvalid }
// 6. 写入 + 回查昵称头像
now := time.Now().UnixMilli()
msgID, _ := s.messagesRepo.Insert(ctx, repository.ActivityMessage{...})
profile, _ := s.userRPCClient.GetFanProfile(ctx, req.UserId, req.StarId)
msg := buildActivityMessage(msgID, req, profile, now)
// 7. Redis Publish不论客户端是否订阅都要发方便后续接入审计/分析)
payload, _ := json.Marshal(map[string]interface{}{
"activity_id": req.ActivityId,
"type": "messages_response",
"message": msg,
})
s.redisClient.Publish(ctx, fmt.Sprintf("act:%d:messages", req.ActivityId), payload)
return &pb.CreateActivityMessageResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK), Message: "ok"},
Message: msg,
}, nil
}
```
`PurchaseItem` 末尾追加:
```go
// 在 PurchaseItem / BatchPurchaseItem 写库成功后
payload, _ := json.Marshal(map[string]interface{}{
"activity_id": req.ActivityId,
"type": "contributions_response",
"record": buildContributionRecord(inserted),
})
s.redisClient.Publish(ctx, fmt.Sprintf("act:%d:contributions", req.ActivityId), payload)
```
### 7.4 ActivityHub 关键代码骨架
```go
// gateway/socket/activity_socket.go
type ActivityHub struct {
clients map[int64]*ActivityConn // userId -> conn
subscriptions map[string]map[*ActivityConn]struct{} // "act:42:messages" -> conns
redisClient *redis.Client
activityPath string
mu sync.RWMutex
}
type ActivityConn struct {
UserID int64
StarID int64
Conn *websocket.Conn
Send chan []byte
Hub *ActivityHub
}
func (h *ActivityHub) Run(ctx context.Context) {
// 启动时 psubscribe 全部 act:*:messages / act:*:contributions
sub := h.redisClient.PSubscribe(ctx, "act:*:messages", "act:*:contributions")
ch := sub.Channel()
for msg := range ch {
var payload map[string]interface{}
json.Unmarshal([]byte(msg.Payload), &payload)
h.fanout(msg.Channel, payload)
}
}
func (h *ActivityHub) fanout(channel string, payload map[string]interface{}) {
h.mu.RLock()
conns := h.subscriptions[channel]
h.mu.RUnlock()
for c := range conns {
c.writeJSON(payload)
}
}
```
---
## 8. 前端实现
### 8.1 字段映射MessageBoard
| 后端字段 | 前端 props | 转换 |
|---------|-----------|------|
| `id` | `id` | 直接 |
| `nickname` | `user` | 直接 |
| `avatar_url` | `avatar` | 直接 |
| `content` | `content` | 直接 |
| `created_at`(ms) | `time` | `formatRelativeTime(ms)` |
| — | `isSelf` | `user_id === currentUserId` |
### 8.2 ActivitySocket API
```js
// frontend/utils/socket/ActivitySocket.js
class ActivitySocket extends SocketManager {
connect(token) { /* 同 AiChat */ }
subscribe(activityId, topics)
unsubscribe(activityId, topics)
// 注册回调
onMessagesResponse(cb) // cb({activity_id, message})
onContributionsResponse(cb) // cb({activity_id, record})
}
```
### 8.3 useContributionRealtime 钩子
> 设计要点:**WS 优先**,轮询仅在 WS 断线时启动;轮询与 WS **互斥**,不会同时跑(避免重复记录)。
```js
// composables/useContributionRealtime.js
export function useContributionRealtime(activityId, isPageActive) {
// 复用 useContributionPolling 的 records / highestId / 增量合并逻辑
// 但不直接调 start(),由本钩子按 WS 状态决定何时调 start/stop
const { records, start, stop, reset, highestIdRef } = useContributionPolling(
activityId, isPageActive
)
const socket = getActivitySocket()
let usingWS = false // 默认 false,等 WS onConnect 后才置 true
function onWsMessage(payload) {
if (payload.activity_id !== activityId.value) return
// 与轮询同款 highest_id 增量逻辑
if (payload.record.id > highestIdRef.value) {
records.value = [payload.record, ...records.value].slice(0, MAX_RECORDS)
highestIdRef.value = payload.record.id
}
}
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()
})
// 卸载清理
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
// composables/useMessageRealtime.js
export function useMessageRealtime(activityId) {
const messages = ref([])
const currentUserId = computed(() => store.state.user?.userInfo?.uid)
const socket = getActivitySocket()
async function loadHistory() {
const res = await listActivityMessagesApi(activityId.value)
if (res.code === 0) {
messages.value = res.data.messages.map(toComponentShape)
}
}
function onMessage(payload) {
if (payload.activity_id !== activityId.value) return
messages.value.push(toComponentShape(payload.message))
if (messages.value.length > 50) messages.value.shift() // 上限保护
}
async function sendMessage(content) {
const res = await createActivityMessageApi(activityId.value, content)
if (res.code === 0) {
// 成功时不本地 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' })
}
}
onMounted(() => {
loadHistory()
socket.subscribe(activityId.value, ['messages'])
socket.onMessagesResponse(onMessage)
})
onUnmounted(() => {
socket.unsubscribe(activityId.value, ['messages'])
})
return { messages, sendMessage }
}
```
### 8.5 index.vue 集成
```vue
<script setup>
const { messages: messageList, sendMessage } = useMessageRealtime(activityId)
const { records } = useContributionRealtime(activityId, isPageActive)
function handleSendMessage(text) {
sendMessage(text)
}
</script>
<template>
<MessageBoard :messages="messageList" />
<ContributionList :records="records" />
</template>
```
---
## 9. 缓存与 Pub/Sub
- **频控**`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
---
## 10. 错误码扩展
`backend/pkg/errors/errors.go` 追加:
```go
var (
ErrActivityMessageNotFound = errors.New("活动留言不存在")
ErrActivityMessageTooFrequent = errors.New("留言太频繁,请稍后再试")
ErrActivityMessageLimitReached = errors.New("当前活动留言已达上限")
ErrActivityMessageContentEmpty = errors.New("留言内容不能为空")
ErrActivityMessageContentTooLong = errors.New("留言内容过长最多500字")
ErrActivityMessageContentInvalid = errors.New("留言内容包含不当内容")
ErrActivityMessageActivityInactive = errors.New("活动不在进行中")
)
```
ToGRPCCode 映射 + 中文 errorMap 与 `活动留言板接口文档.md §6` 一致。
---
## 11. 测试用例
### 11.1 service 层
- [ ] CreateActivityMessage合法 content → 写库 + Publish `act:{id}:messages`
- [ ] 空 content → ErrActivityMessageContentEmpty
- [ ] 超 500 字 → ErrActivityMessageContentTooLong
- [ ] 活动 status=pending → ErrActivityMessageActivityInactive
- [ ] 1 分钟内 > 5 条 → ErrActivityMessageTooFrequent
- [ ] 累计 > 100 条 → ErrActivityMessageLimitReached
- [ ] PurchaseItem写库后 Publish `act:{id}:contributions`
- [ ] BatchPurchaseItem每个 item 写库后 Publish `act:{id}:contributions`(或批量发布一次,节省 Redis 流量;首版每条一次便于前端叠加动画)
### 11.2 gateway ActivityHub
- [ ] 启动时 psubscribe 模式订阅 `act:*:messages` `act:*:contributions`
- [ ] 收到 `act:42:messages` 频道 → 只 fanout 到订阅了 (42, messages) 的连接
- [ ] 收到 `act:42:contributions` 频道 → 不影响 messages 订阅者
- [ ] 断开连接 → 清理 subscriptions 与 clients
- [ ] 鉴权失败 → 401
### 11.3 前端
- [ ] useContributionRealtimeWS connected 时不轮询
- [ ] WS disconnect → 切到轮询
- [ ] WS reconnect + subscribe → 切回 WS
- [ ] useMessageRealtimeloadHistory 失败时降级为空列表
- [ ] sendMessage 失败 → toast列表不变化
---
## 12. 变更影响面(按 CLAUDE.md 自审)
- [x] `activities` 表结构不变
- [x] `activity_contributions` 表结构不变(仅新增 Publish 副作用)
- [x] `activityService` 现有 RPC 签名不变(仅 PurchaseItem 末尾增加 Publish
- [x] `pkg/errors` 追加 7 个错误变量,不影响 ToGRPCCode 现有行为
- [x] `frontend/pages/support-activity/components/MessageBoard.vue` props 不变
- [x] `activity_messages_id_seq` 起始 10000符合 CLAUDE.md 测试数据预留规范
- [x] WebSocket 路径 `/activity` 与 AI Chat `/ai-chat` 独立,无端口/路径冲突
- [x] 多 gateway 实例:所有实例均订阅同一组 Pub/Sub每实例只 fanout 本地连接Redis 已有的共享通道语义)
---
## 13. 后续迭代(已收口)
| 主题 | 决策 |
|------|------|
| 留言敏感词 | 首版本地词表,错误码 `ErrActivityMessageContentInvalid` 预留,后续接 dify |
| 留言置顶 | 不在本期范围 |
| 留言删除 | 不在本期范围;软删除字段已就位 |
| 跨活动聚合 | 不在本期范围HTTP GET 路径 `/api/v1/me/activity-messages` 留作下期 |
| 中心页订阅 | 本期只在 `index.vue` 接入;`center.vue` 列表卡片暂不实时刷新 |