26 KiB
活动实时推送(留言 + 贡献)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.jsAI Chat WS 实现frontend/utils/socket/GlobalSocketManager.js统一管理多服务连接- 需要新增
ActivitySocket.js走/activity
2. 设计目标
- 留言发送走 HTTP POST(可重试,保留接口文档)
- 留言接收 / 贡献接收统一走 WebSocket 推送
- 单一连接 + topic 频道(
messages/contributions),不增加额外 TCP 连接 - WS 断线时前端自动降级为
getActivityContributionsLatestApi轮询 - WS 重连后用
highest_id拉差量,补齐漏推 - 不破坏现有
MessageBoard.vueprops 契约,前端只换数据源
3. 数据库设计
3.1 新增表 activity_messages
-- 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 末尾追加:
// ============== 留言相关 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(protoactivity.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 协议一致):
{"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 业务逻辑:
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 末尾追加:
// 在 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 关键代码骨架
// 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
// 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 互斥,不会同时跑(避免重复记录)。
// 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 钩子
// 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 集成
<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 追加:
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:*:messagesact:*: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 自审)
activities表结构不变activity_contributions表结构不变(仅新增 Publish 副作用)activityService现有 RPC 签名不变(仅 PurchaseItem 末尾增加 Publish)pkg/errors追加 7 个错误变量,不影响 ToGRPCCode 现有行为frontend/pages/support-activity/components/MessageBoard.vueprops 不变activity_messages_id_seq起始 10000,符合 CLAUDE.md 测试数据预留规范- WebSocket 路径
/activity与 AI Chat/ai-chat独立,无端口/路径冲突 - 多 gateway 实例:所有实例均订阅同一组 Pub/Sub,每实例只 fanout 本地连接(Redis 已有的共享通道语义)
13. 后续迭代(已收口)
| 主题 | 决策 |
|---|---|
| 留言敏感词 | 首版本地词表,错误码 ErrActivityMessageContentInvalid 预留,后续接 dify |
| 留言置顶 | 不在本期范围 |
| 留言删除 | 不在本期范围;软删除字段已就位 |
| 跨活动聚合 | 不在本期范围;HTTP GET 路径 /api/v1/me/activity-messages 留作下期 |
| 中心页订阅 | 本期只在 index.vue 接入;center.vue 列表卡片暂不实时刷新 |