feat:新增头像框显示
This commit is contained in:
parent
a6ce0f5865
commit
b1fe1aea50
824
backend/docs/活动留言板接口文档.md
Normal file
824
backend/docs/活动留言板接口文档.md
Normal file
@ -0,0 +1,824 @@
|
||||
# 活动留言板(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 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 自审规范)
|
||||
|
||||
修复 / 新增本模块时必须回归:
|
||||
|
||||
- [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 测试数据预留规范
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:Swagger 注解示例(直接复制到 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;
|
||||
```
|
||||
@ -20,6 +20,11 @@
|
||||
:src="avatar || defaultAvatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image
|
||||
class="message-row__avatar-fallback"
|
||||
src="/static/square/gerentouxiangkuang.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 骰子表情 (17x17):点击按顺序循环替换 text -->
|
||||
@ -47,6 +52,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
/**
|
||||
* 点击骰子 emoji 时按顺序循环选用的祝福语。
|
||||
@ -83,7 +89,7 @@ const props = defineProps({
|
||||
default: "",
|
||||
},
|
||||
/**
|
||||
* 头像 URL,未传时回退到默认头像。
|
||||
* 头像 URL,未传时回退到当前登录用户的 avatar_url。
|
||||
*/
|
||||
avatar: {
|
||||
type: String,
|
||||
@ -100,8 +106,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["send", "tap", "update:text"]);
|
||||
|
||||
const defaultAvatar =
|
||||
"/static/rank/activity-support-icon/message-row/avatar.png";
|
||||
// 兜底默认头像:不再硬编码,直接读取 Vuex user 模块中的 avatar_url
|
||||
// (登录时已通过 SET_USER_INFO 写入 store 与本地缓存,头像更新后也会触发 userInfoUpdated 通知)
|
||||
const store = useStore();
|
||||
const userInfo = computed(() => store.state.user?.userInfo || null);
|
||||
const defaultAvatar = computed(() => userInfo.value?.avatar_url || "");
|
||||
|
||||
// 按下反馈:触摸 / 按下时短暂为 true,松开恢复
|
||||
const pressing = ref(false);
|
||||
@ -211,6 +220,15 @@ function handleSend() {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-row__avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 骰子表情 */
|
||||
.message-row__emoji {
|
||||
position: absolute;
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
/>
|
||||
<image
|
||||
class="top3-medal"
|
||||
:src="`/static/rank/rank-icon${item.rank}.png`"
|
||||
:src="`/static/rank/activity-support-icon/pm${item.rank}.png`"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
@ -172,8 +172,9 @@ defineExpose({
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.6);
|
||||
background: #fff;
|
||||
/* border: 2rpx solid rgba(255, 255, 255, 0.6); */
|
||||
/* background: #fff; */
|
||||
box-shadow: 2px 2px 4px 0 rgba(174, 17, 17, 0.53);
|
||||
}
|
||||
|
||||
.top3-medal {
|
||||
|
||||
BIN
frontend/static/rank/activity-support-icon/pm1.png
Normal file
BIN
frontend/static/rank/activity-support-icon/pm1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/static/rank/activity-support-icon/pm2.png
Normal file
BIN
frontend/static/rank/activity-support-icon/pm2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/static/rank/activity-support-icon/pm3.png
Normal file
BIN
frontend/static/rank/activity-support-icon/pm3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
Loading…
Reference in New Issue
Block a user