34 KiB
活动留言板(Activity MessageBoard)后端接口文档
本文档基于前端组件
frontend/pages/support-activity/components/MessageBoard.vue反推后端接口契约,作为留言板模块的接口设计依据与开发指南。
1. 背景与现状
1.1 前端组件
MessageBoard.vue 是一个纯展示组件(presentational component),通过 props.messages 接收留言列表,每条留言渲染为一条气泡,组件本身不直接发请求。
调用关系:
support-activity/index.vue
├── <MessageBoard :messages="messageList" /> // 仅展示
└── handleSendMessage(text) // 仅本地 messageList.push(...) + toast
当前 messageList 在 index.vue 中是写死的 mock 数据,并未对接任何后端接口:
// 留言板 mock 数据(index.vue 中)
const messageList = ref([
{ id: 1, user: "小星星", avatar: "", content: "生日快乐!永远支持你~", time: "刚刚", isSelf: false },
// ...
]);
1.2 前端期望的字段(来自组件 props 形态)
| 字段 | 类型 | 说明 | 备注 |
|---|---|---|---|
id |
number | 留言唯一 ID | 当前 mock 用 Date.now() |
user |
string | 留言人昵称 | 当前用户昵称(来自 fan_profiles.nickname) |
avatar |
string | 留言人头像 URL | 当前 mock 为空字符串 |
content |
string | 留言正文 | 必填 |
time |
string | 留言时间 | 当前 mock 是"刚刚"/"5分钟前"等相对时间字符串 |
isSelf |
boolean | 是否当前登录用户所发 | 当前 mock 由前端标记 |
⚠️ 后端不应直接复用 mock 字段语义:
time应返回毫秒时间戳或 ISO 字符串,由前端格式化;isSelf应由前端根据当前用户 ID 自行判断(避免后端泄露会话状态)。
1.3 后端现状
经排查(grep 全量 message|留言),当前 backend 中没有留言相关接口:
services/activityService/没有Message类方法- 数据库迁移脚本中没有
activity_messages或messages表(migrate_add_comments_v1.sql是给 laser_card 加的comments列,不是新表) pkg/errors中也没有ErrMessageNotFound等业务错误
本次为全新模块设计。
2. 设计目标
- 支持粉丝在应援活动页面对活动发表祝福留言(每用户每活动有频率/上限限制)。
- 支持活动详情页气泡墙展示历史留言(最新在上)。
- 与现有活动模块同源(路由
activities/{id}/...),共享auth + activity 校验。 - 兼容现有前端组件的 props 契约,前端只需把 mock 替换为接口数据源。
- 预留轮询扩展(
since_timestamp/since_id)以便后续做实时弹幕。
3. 数据库设计
新增表 activity_messages,主键 bigserial,自增序列起始 10000(按 CLAUDE.md 序列规范)。
-- ============================================================
-- 活动留言表
-- ============================================================
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, -- 0=正常 1=隐藏 2=已删除
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT,
CONSTRAINT fk_activity_messages_activity
FOREIGN KEY (activity_id) REFERENCES public.activities(id) ON DELETE CASCADE,
CONSTRAINT fk_activity_messages_user
FOREIGN KEY (user_id) REFERENCES public.users(id),
CONSTRAINT fk_activity_messages_star
FOREIGN KEY (star_id) REFERENCES public.stars(star_id)
);
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 '软删除时间,NULL 表示未删除';
-- 序列起始值留给测试数据硬编码 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;
-- 索引:用于实时增量拉取(since_timestamp + since_id 翻页)
CREATE INDEX IF NOT EXISTS idx_activity_messages_activity_incr
ON public.activity_messages (activity_id, id DESC)
WHERE deleted_at IS NULL;
为什么
WHERE deleted_at IS NULL? 留言是只追加、软删除的写入场景,使用部分索引(partial index)能让正常状态的数据索引更紧凑,热路径扫描更快。
3.1 与已有表的关系
activity_id→activities.id:活动被删除时级联清理留言(ON DELETE CASCADE)。user_id→users.id:硬依赖,用户被硬删时由应用层兜底(软删用户场景下留言仍可保留,展示时昵称取自 join 后的 fan_profiles/users)。star_id→stars.star_id:冗余字段,便于按星球做运营统计(如"某星球 24h 留言量")。
4. Proto 定义
新增文件 backend/proto/activity/activity_messages.proto(追加到现有 backend/proto/activity/ 下,复用 ActivityService 服务):
syntax = "proto3";
package com.topfans.activity;
option go_package = "github.com/topfans/backend/pkg/proto/activity";
option java_package = "com.topfans.activity.proto";
// ============== 留言相关 RPC ==============
service ActivityService {
// ... 现有 RPC ...
// 列出活动留言(历史/首次加载)
rpc ListActivityMessages(ListActivityMessagesRequest) returns (ListActivityMessagesResponse);
// 发送一条留言
rpc CreateActivityMessage(CreateActivityMessageRequest) returns (CreateActivityMessageResponse);
// 增量获取最新留言(轮询用,与 GetLatestContributions 同样模式)
rpc GetLatestActivityMessages(GetLatestActivityMessagesRequest) returns (GetLatestActivityMessagesResponse);
}
// 单条留言
message ActivityMessage {
int64 id = 1;
int64 activity_id = 2;
int64 user_id = 3;
int64 star_id = 4;
string nickname = 5; // 冗余带出,减少 join
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 {
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 {
BaseResponse base = 1;
ActivityMessage message = 2;
}
// 增量获取最新留言
message GetLatestActivityMessagesRequest {
int64 activity_id = 1;
int64 since_timestamp = 2; // 自该时间戳之后的新记录(毫秒)
int64 since_id = 3; // 配合 since_timestamp 在同毫秒内二次排序
int32 limit = 4; // 默认 20,最大 50
}
message GetLatestActivityMessagesResponse {
BaseResponse base = 1;
repeated ActivityMessage messages = 2;
}
生成命令:项目根目录执行
make proto或参考backend/Proto编译完成总结.md。生成后会产生activity_messages.pb.go,注册到ActivityServiceHandler接口。
5. HTTP 接口设计
5.1 通用约定
- Base URL:
https://{host}/api/v1 - 认证:所有接口必须带
Authorization: Bearer <access_token>(JWT),由middleware.AuthMiddleware()注入user_id/star_id。 - 统一响应结构(沿用
gateway/pkg/response.Response):{ "code": 0, "message": "ok", "data": { ... } } - 错误码:使用
pkg/errors中的 gRPC code 映射(codes.OK=0、InvalidArgument=3、NotFound=5、PermissionDenied=7、ResourceExhausted=8、Internal=13、Unauthenticated=16)。 - 时间字段:统一返回毫秒时间戳(
created_at),前端负责格式化。 - 驼峰/下划线:HTTP 响应使用 snake_case(与现有
GetLatestContributions保持一致),由convert*Response函数在 controller 层做转换。
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}/messages/latest |
增量拉取最新留言 | 需要 |
三个接口都登记到
gateway/controller/activity_controller.go的 Swagger 注解上,router.go的activities组内注册。
5.3 接口 1:列出活动留言
适用场景:用户进入活动详情页首次加载 / 下拉刷新(替代当前 messageList mock)。
GET /api/v1/activities/{activity_id}/messages?page=1&page_size=20
Path 参数
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
activity_id |
int64 | 是 | 活动 ID |
Query 参数
| 名称 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
page |
int | 否 | 1 | 页码,从 1 开始 |
page_size |
int | 否 | 20 | 每页条数,最大 50 |
响应示例
{
"code": 0,
"message": "ok",
"data": {
"messages": [
{
"id": 10086,
"activity_id": 42,
"user_id": 1001,
"star_id": 87,
"nickname": "小星星",
"avatar_url": "https://oss.example.com/avatars/1001.png",
"content": "生日快乐!永远支持你~",
"created_at": 1750492800000
},
{
"id": 10085,
"activity_id": 42,
"user_id": 1002,
"star_id": 87,
"nickname": "月光宝盒",
"avatar_url": "",
"content": "星光不问赶路人~",
"created_at": 1750492700000
}
],
"page": 1,
"page_size": 20,
"total": 2
}
}
错误码
| code | HTTP | message | 说明 |
|---|---|---|---|
| 5 | 404 | 活动不存在 |
activity_id 无效 |
| 5 | 404 | 活动不存在 |
活动已结束超过 30 天 |
| 16 | 401 | 未授权,请先登录 |
token 缺失 / 失效 |
5.4 接口 2:发送一条留言
适用场景:用户在 MessageInput 输入文本后点击发送箭头(当前 index.vue 的 handleSendMessage 调用点)。
POST /api/v1/activities/{activity_id}/messages
请求头:Content-Type: application/json
Path 参数
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
activity_id |
int64 | 是 | 活动 ID |
Body
{
"content": "永远支持你!生日快乐 🎂"
}
| 字段 | 类型 | 必填 | 限制 |
|---|---|---|---|
content |
string | 是 | 长度 1-500;去前后空格后非空 |
响应示例(成功)
{
"code": 0,
"message": "ok",
"data": {
"message": {
"id": 10087,
"activity_id": 42,
"user_id": 2001,
"star_id": 87,
"nickname": "爱战战",
"avatar_url": "https://oss.example.com/avatars/2001.png",
"content": "永远支持你!生日快乐 🎂",
"created_at": 1750492900000
}
}
}
业务规则校验
| 校验项 | 失败 code | message |
|---|---|---|
| 活动状态非 active | 7 | 活动未开始 / 活动已结束 |
content 长度不合法 |
3 | 留言内容长度需在 1-500 字之间 |
content 含敏感词 |
7 | 留言包含不当内容,请修改 |
| 单用户单活动 1 分钟内 > 5 条 | 8 | 留言太频繁,请稍后再试 |
| 单用户单活动累计 > 100 条 | 8 | 当前活动留言已达上限 |
上限和频控值通过配置项注入(参考
notificationService/configs/config.yaml的模式),避免硬编码。
错误码
| code | HTTP | message | 说明 |
|---|---|---|---|
| 3 | 400 | 请求参数错误 |
JSON 解析失败 / 字段缺失 |
| 5 | 404 | 活动不存在 |
activity_id 无效 |
| 7 | 403 | 活动已结束 |
状态非 active |
| 8 | 429 | 留言太频繁 |
频控触发 |
| 16 | 401 | 未授权 |
token 失效 |
5.5 接口 3:增量获取最新留言(轮询用)
适用场景:前端使用 setInterval 轮询新留言,实现"气泡墙实时滚动"效果。对标 GetLatestContributions 接口模式。
GET /api/v1/activities/{activity_id}/messages/latest?since_timestamp=1750492800000&since_id=10086&limit=20
Path 参数
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
activity_id |
int64 | 是 | 活动 ID |
Query 参数
| 名称 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
since_timestamp |
int64 | 否 | 0 | 毫秒时间戳,返回严格大于该值的新留言 |
since_id |
int64 | 否 | 0 | 同毫秒内按 id 二次排序 |
limit |
int | 否 | 20 | 最多返回多少条,最大 50 |
前端轮询时可复用
frontend/pages/support-activity/composables/useContributionPolling.js的latestTimestamp/latestId游标模式。
响应示例
{
"code": 0,
"message": "ok",
"data": {
"messages": [
{
"id": 10087,
"activity_id": 42,
"user_id": 2001,
"star_id": 87,
"nickname": "爱战战",
"avatar_url": "https://oss.example.com/avatars/2001.png",
"content": "永远支持你!生日快乐 🎂",
"created_at": 1750492900000
}
]
}
}
无新数据时 data.messages 为空数组(不返回 404,避免轮询噪声)。
6. 错误码扩展
新增到 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 函数中追加映射:
| error | gRPC code |
|---|---|
ErrActivityMessageNotFound |
NotFound (5) |
ErrActivityMessageTooFrequent |
ResourceExhausted (8) |
ErrActivityMessageLimitReached |
ResourceExhausted (8) |
ErrActivityMessageContentEmpty |
InvalidArgument (3) |
ErrActivityMessageContentTooLong |
InvalidArgument (3) |
ErrActivityMessageContentInvalid |
PermissionDenied (7) |
ErrActivityMessageActivityInactive |
PermissionDenied (7) |
并把中文错误提示加到 gateway/pkg/response/response.go 的 errorMap:
"活动留言不存在": "活动留言不存在",
"留言太频繁": "留言太频繁,请稍后再试",
"留言内容不能为空": "留言内容不能为空",
"留言内容过长": "留言内容过长,最多500字",
"留言内容包含不当内容": "留言内容包含不当内容,请修改",
"活动不在进行中": "活动未开始或已结束",
7. 分层实现(按 CLAUDE.md 接口开发规范)
HTTP 请求 (Gin)
│
▼
gateway/controller/activity_controller.go ← 新增 3 个 handler + Swagger 注解
│ - ListActivityMessages
│ - CreateActivityMessage
│ - GetLatestActivityMessages
│ - convertActivityMessage* 响应转换函数
│
│ Dubbo / gRPC (Triple)
▼
services/activityService/provider/activity_provider.go ← 3 个 RPC 入口
│
▼
services/activityService/service/activity_service.go ← 业务编排(鉴权/频控/事务)
│
▼
services/activityService/repository/activity_repository.go ← SQL 拼装(不写业务逻辑)
│
▼
PostgreSQL (activity_messages 表)
7.1 待新增 / 修改文件清单
| 类型 | 文件 | 说明 |
|---|---|---|
| 新增 | backend/scripts/migrations/migrate_create_activity_messages_table.sql |
建表 + 索引 + 注释 |
| 新增 | backend/proto/activity/activity_messages.proto |
proto 定义(追加 RPC + message) |
| 新增 | backend/services/activityService/repository/activity_messages_repository.go |
仓储层,封装 SQL 操作 |
| 修改 | backend/services/activityService/repository/activity_repository.go |
注册新的仓储接口(可选合并到同一文件) |
| 修改 | backend/services/activityService/service/activity_service.go |
新增 3 个业务方法 + 接口签名 |
| 修改 | backend/services/activityService/provider/activity_provider.go |
新增 3 个 RPC 入口 + 日志 |
| 修改 | backend/gateway/controller/activity_controller.go |
新增 3 个 HTTP handler + 转换函数 + Swagger 注解 |
| 修改 | backend/gateway/router/router.go |
注册 3 条路由到 activities 组 |
| 修改 | backend/pkg/errors/errors.go |
新增 7 个错误变量 + ToGRPCCode 映射 |
| 修改 | backend/gateway/pkg/response/response.go |
新增 6 条中文错误映射 |
| 修改 | backend/services/activityService/configs/config.yaml |
新增 message_rate_limit_per_min: 5 等频控配置 |
| 修改 | frontend/utils/api.js |
新增 listActivityMessagesApi、createActivityMessageApi、getLatestActivityMessagesApi 三个 API 函数 |
| 修改 | frontend/pages/support-activity/index.vue |
把 messageList mock 替换成接口数据源,handleSendMessage 改为调用 createActivityMessageApi |
| 修改 | frontend/pages/support-activity/components/MessageBoard.vue |
字段映射:time → 用 created_at 格式化;isSelf 由 user_id === currentUser.id 计算 |
7.2 Service 层业务逻辑要点
// CreateActivityMessage 节选伪代码
func (s *activityService) CreateActivityMessage(ctx context.Context, req *pb.CreateActivityMessageRequest) (*pb.CreateActivityMessageResponse, error) {
// 1. 入参校验
if strings.TrimSpace(req.Content) == "" { return nil, appErrors.ErrActivityMessageContentEmpty }
if utf8.RuneCountInString(req.Content) > 500 { return nil, appErrors.ErrActivityMessageContentTooLong }
// 2. 活动存在性 + 状态
activity, err := s.activityRepo.GetActivityByID(req.ActivityId)
if err != nil { return nil, err }
if activity == nil { return nil, appErrors.ErrActivityNotFound }
if activity.Status != "active" { return nil, appErrors.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, appErrors.ErrActivityMessageTooFrequent }
// 4. 累计上限
total, _ := s.activityMessagesRepo.CountByUserActivity(ctx, req.ActivityId, req.UserId)
if total >= cfg.MessageLimitPerActivity { return nil, appErrors.ErrActivityMessageLimitReached }
// 5. 敏感词校验(可后续接入内容审核服务)
if containsBannedWord(req.Content) { return nil, appErrors.ErrActivityMessageContentInvalid }
// 6. 写入 + 回查用户昵称 / 头像
now := time.Now().UnixMilli()
msgID, err := s.activityMessagesRepo.Insert(ctx, repository.ActivityMessage{
ActivityId: req.ActivityId,
UserId: req.UserId,
StarId: req.StarId,
Content: req.Content,
Status: 0,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil { return nil, err }
// 7. 返回带 nickname / avatar_url 的展示模型
profile, _ := s.userRPCClient.GetFanProfile(ctx, req.UserId, req.StarId)
return &pb.CreateActivityMessageResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK), Message: "ok"},
Message: convertToPBMessage(msgID, req, profile, now),
}, nil
}
8. 前端集成要点
8.1 字段映射表
| 后端字段 | 前端 MessageBoard props 字段 | 转换说明 |
|---|---|---|
id |
id |
直接使用 |
nickname |
user |
直接使用 |
avatar_url |
avatar |
直接使用 |
content |
content |
直接使用 |
created_at(ms) |
time |
前端用 formatRelativeTime() 工具格式化 |
| — | isSelf |
前端自己判断 user_id === currentUser.id |
8.2 API 工具函数模板(追加到 frontend/utils/api.js)
// 列出活动留言
export function listActivityMessagesApi(activityId, page = 1, pageSize = 20) {
return request({
url: `/api/v1/activities/${activityId}/messages?page=${page}&page_size=${pageSize}`,
method: 'GET',
})
}
// 发送活动留言
export function createActivityMessageApi(activityId, content) {
return request({
url: `/api/v1/activities/${activityId}/messages`,
method: 'POST',
data: { content },
})
}
// 增量获取最新留言(轮询用)
export function getLatestActivityMessagesApi(activityId, sinceTimestamp = 0, sinceId = 0, limit = 20) {
return request({
url: `/api/v1/activities/${activityId}/messages/latest?since_timestamp=${sinceTimestamp}&since_id=${sinceId}&limit=${limit}`,
method: 'GET',
})
}
8.3 index.vue 集成建议
// 替换 mock data
const messageList = ref([])
const currentUserId = ref(null) // 从 store/modules/user.js 取
async function loadMessages() {
const res = await listActivityMessagesApi(activityId.value)
if (res.code === 0) {
messageList.value = res.data.messages.map(m => ({
id: m.id,
user: m.nickname,
avatar: m.avatar_url,
content: m.content,
time: m.created_at,
isSelf: m.user_id === currentUserId.value,
}))
}
}
async function handleSendMessage(text) {
if (!text || !text.trim()) return
const res = await createActivityMessageApi(activityId.value, text.trim())
if (res.code === 0) {
const m = res.data.message
messageList.value.push({
id: m.id,
user: m.nickname,
avatar: m.avatar_url,
content: m.content,
time: m.created_at,
isSelf: true,
})
uni.showToast({ title: '留言成功', icon: 'success' })
} else {
uni.showToast({ title: res.message || '留言失败', icon: 'none' })
}
}
onMounted(loadMessages)
9. 缓存与性能
- 频控计数器:Redis Key
msg:rate:{activity_id}:{user_id},TTL 60s。 - 读取缓存:首版不上缓存,直接走 DB(留言写入是追加为主,索引已覆盖热路径);QPS 上量后再考虑 30s 缓存。
- 响应体大小:每条留言 ≤ 500 字符 + JSON 字段约 200B,每页 20 条 ≤ 4KB,无需特殊压缩。
10. 测试用例
10.1 Service 层(必须覆盖)
- 首次进入活动,能正确按
created_at DESC, id DESC拉取留言 - 发送空 content →
ErrActivityMessageContentEmpty - 发送超 500 字 content →
ErrActivityMessageContentTooLong - 活动状态 pending →
ErrActivityMessageActivityInactive - 同一用户 1 分钟内 > 5 条 →
ErrActivityMessageTooFrequent - 单用户单活动累计 > 100 条 →
ErrActivityMessageLimitReached - 增量拉取:
since_timestamp/since_id正确过滤历史
10.2 Handler 层
- Happy path:发送留言成功后响应体
data.message.id正确 - Error path:JWT 缺失 → 401
- Error path:活动 ID 不存在 → 5 + 友好提示
11. 开放问题 / 后续迭代
| 主题 | 讨论 |
|---|---|
| 敏感词审核 | 首版仅本地词表;后续接 dify 内容审核工作流(同 laser-card 模式) |
| 留言置顶 | 是否需要 is_pinned 字段?若需要,新增字段 + 列表查询加 ORDER BY is_pinned DESC, created_at DESC |
| 删除能力 | 用户是否能删除自己的留言?若是,新增 DELETE /api/v1/activities/{id}/messages/{message_id} |
| 跨活动聚合 | "我的留言"页面:GET /api/v1/me/activity-messages?page=&page_size= |
| WebSocket 推送 | 高 QPS 场景下用 WS 推送替代 HTTP 轮询;接口 3 保留作为降级方案 |
12. 变更影响面(按 CLAUDE.md 自审规范)
修复 / 新增本模块时必须回归:
- 现有
activities表上的其他接口不受影响(新表 + 新路由) activity_service.go现有方法签名不变- 新增的 3 条路由不与现有
activities组冲突 pkg/errors新增错误变量不影响现有ToGRPCCode行为gateway/pkg/response的 errorMap 是追加,不覆盖现有映射frontend/pages/support-activity/index.vue已有ContributionList、ActionBar等组件,与MessageBoard数据流独立- PostgreSQL 序列
activity_messages_id_seq起始 10000,符合 CLAUDE.md 测试数据预留规范
附录 A:Swagger 注解示例(直接复制到 controller)
// ListActivityMessages 列出活动留言
// @Summary 列出活动留言
// @Description 分页获取活动留言列表(最新在上)
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param activity_id path int64 true "活动ID"
// @Param page query int false "页码,默认1"
// @Param page_size query int false "每页数量,默认20,最大50"
// @Success 200 {object} response.Response
// @Router /api/v1/activities/{activity_id}/messages [get]
func (ctrl *ActivityController) ListActivityMessages(c *gin.Context) { /* ... */ }
// CreateActivityMessage 发送活动留言
// @Summary 发送活动留言
// @Description 用户在应援活动页面发送一条祝福留言
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param activity_id path int64 true "活动ID"
// @Param request body object{content=string} true "留言内容"
// @Success 200 {object} response.Response
// @Router /api/v1/activities/{activity_id}/messages [post]
func (ctrl *ActivityController) CreateActivityMessage(c *gin.Context) { /* ... */ }
// GetLatestActivityMessages 增量获取最新留言
// @Summary 增量获取最新留言
// @Description 用于前端轮询获取活动最新留言(since_timestamp + since_id 游标)
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param activity_id path int64 true "活动ID"
// @Param since_timestamp query int64 false "毫秒时间戳,返回严格大于此值的新记录"
// @Param since_id query int64 false "ID筛选,配合since_timestamp使用"
// @Param limit query int false "返回数量,默认20,最大50"
// @Success 200 {object} response.Response
// @Router /api/v1/activities/{activity_id}/messages/latest [get]
func (ctrl *ActivityController) GetLatestActivityMessages(c *gin.Context) { /* ... */ }
附录 B:迁移脚本完整版
-- ============================================================
-- Activity Messages Table
-- ============================================================
BEGIN;
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_activity_messages_activity
FOREIGN KEY (activity_id) REFERENCES public.activities(id) ON DELETE CASCADE,
CONSTRAINT fk_activity_messages_user
FOREIGN KEY (user_id) REFERENCES public.users(id),
CONSTRAINT fk_activity_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 '软删除时间';
-- 健康检查(部署后跑一次)
SELECT
sequencename,
last_value,
(SELECT COALESCE(MAX(id), 0) FROM public.activity_messages) AS table_max_id,
last_value >= (SELECT COALESCE(MAX(id), 0) FROM public.activity_messages) AS is_healthy
FROM pg_sequences
WHERE sequencename = 'activity_messages_id_seq';
COMMIT;