topfans/backend/docs/活动留言板接口文档.md

824 lines
34 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.

# 活动留言板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 数据**,并未对接任何后端接口:
```js
// 留言板 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. 设计目标
1. 支持粉丝在应援活动页面对活动发表祝福留言(每用户每活动有频率/上限限制)。
2. 支持活动详情页气泡墙展示历史留言(最新在上)。
3. 与现有活动模块同源(路由 `activities/{id}/...`),共享 `auth + activity 校验`
4. 兼容现有前端组件的 props 契约,前端只需把 mock 替换为接口数据源。
5. 预留轮询扩展(`since_timestamp` / `since_id`)以便后续做实时弹幕。
---
## 3. 数据库设计
新增表 `activity_messages`**主键 bigserial**,自增序列起始 10000`CLAUDE.md` 序列规范)。
```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, -- 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` 服务):
```protobuf
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`
```json
{
"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 |
**响应示例**
```json
{
"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**
```json
{
"content": "永远支持你!生日快乐 🎂"
}
```
| 字段 | 类型 | 必填 | 限制 |
| --------- | ------ | ---- | --------------------------------- |
| `content` | string | 是 | 长度 1-500去前后空格后非空 |
**响应示例(成功)**
```json
{
"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` 游标模式。
**响应示例**
```json
{
"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`
```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`
```go
"活动留言不存在": "活动留言不存在",
"留言太频繁": "留言太频繁,请稍后再试",
"留言内容不能为空": "留言内容不能为空",
"留言内容过长": "留言内容过长最多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 层业务逻辑要点
```go
// 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`
```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` 集成建议
```js
// 替换 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 pathJWT 缺失 → 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 自审规范)
修复 / 新增本模块时必须回归:
- [x] 现有 `activities` 表上的其他接口不受影响(新表 + 新路由)
- [x] `activity_service.go` 现有方法签名不变
- [x] 新增的 3 条路由不与现有 `activities` 组冲突
- [x] `pkg/errors` 新增错误变量不影响现有 `ToGRPCCode` 行为
- [x] `gateway/pkg/response` 的 errorMap 是追加,不覆盖现有映射
- [x] `frontend/pages/support-activity/index.vue` 已有 `ContributionList`、`ActionBar` 等组件,与 `MessageBoard` 数据流独立
- [x] PostgreSQL 序列 `activity_messages_id_seq` 起始 10000符合 CLAUDE.md 测试数据预留规范
---
## 附录 ASwagger 注解示例(直接复制到 controller
```go
// 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迁移脚本完整版
```sql
-- ============================================================
-- 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;
```