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

34 KiB
Raw Blame History

活动留言板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

当前 messageListindex.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_messagesmessages 表(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,自增序列起始 10000CLAUDE.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_idactivities.id:活动被删除时级联清理留言(ON DELETE CASCADE)。
  • user_idusers.id:硬依赖,用户被硬删时由应用层兜底(软删用户场景下留言仍可保留,展示时昵称取自 join 后的 fan_profiles/users
  • star_idstars.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 URLhttps://{host}/api/v1
  • 认证:所有接口必须带 Authorization: Bearer <access_token>JWTmiddleware.AuthMiddleware() 注入 user_id / star_id
  • 统一响应结构(沿用 gateway/pkg/response.Response
    {
      "code": 0,
      "message": "ok",
      "data": { ... }
    }
    
  • 错误码:使用 pkg/errors 中的 gRPC code 映射(codes.OK=0InvalidArgument=3NotFound=5PermissionDenied=7ResourceExhausted=8Internal=13Unauthenticated=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.goactivities 组内注册。


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.vuehandleSendMessage 调用点)。

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.jslatestTimestamp / 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.goerrorMap

"活动留言不存在":                       "活动留言不存在",
"留言太频繁":                          "留言太频繁,请稍后再试",
"留言内容不能为空":                    "留言内容不能为空",
"留言内容过长":                        "留言内容过长最多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 新增 listActivityMessagesApicreateActivityMessageApigetLatestActivityMessagesApi 三个 API 函数
修改 frontend/pages/support-activity/index.vue messageList mock 替换成接口数据源,handleSendMessage 改为调用 createActivityMessageApi
修改 frontend/pages/support-activity/components/MessageBoard.vue 字段映射:time → 用 created_at 格式化;isSelfuser_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 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 自审规范)

修复 / 新增本模块时必须回归:

  • 现有 activities 表上的其他接口不受影响(新表 + 新路由)
  • activity_service.go 现有方法签名不变
  • 新增的 3 条路由不与现有 activities 组冲突
  • pkg/errors 新增错误变量不影响现有 ToGRPCCode 行为
  • gateway/pkg/response 的 errorMap 是追加,不覆盖现有映射
  • frontend/pages/support-activity/index.vue 已有 ContributionListActionBar 等组件,与 MessageBoard 数据流独立
  • PostgreSQL 序列 activity_messages_id_seq 起始 10000符合 CLAUDE.md 测试数据预留规范

附录 ASwagger 注解示例(直接复制到 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;