# 活动实时推送(留言 + 贡献)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;增量获取贡献走 WebSocket(HTTP 保留为降级) |
| 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,留言实时也走 WS,HTTP 增量接口不必要。
---
## 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 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 固定间隔持续重试**(不停止),确保弱网下也能恢复
### 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
```
---
## 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 前端
- [ ] useContributionRealtime:WS connected 时不轮询
- [ ] WS disconnect → 切到轮询
- [ ] WS reconnect + subscribe → 切回 WS
- [ ] useMessageRealtime:loadHistory 失败时降级为空列表
- [ ] 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` 列表卡片暂不实时刷新 |