2125 lines
59 KiB
Markdown
2125 lines
59 KiB
Markdown
# 活动实时推送(留言 + 贡献)WebSocket 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 单一活动页(`pages/support-activity/index.vue`)的留言板与贡献列表统一走 WebSocket 推送;HTTP 仅保留留言历史/发送 + 增量贡献(断线降级)。
|
||
|
||
**Architecture:** 后端 `activityService` 在写 `activity_messages`/`activity_contributions` 后 Redis Publish;Gateway 新增 `ActivityHub` PSubscribe 全部活动频道并按 (activity_id, topic) fanout 到本地连接。前端新增 `ActivitySocket` 单一连接 + topic 订阅,留言发送走 HTTP(成功后由 WS 推回)。
|
||
|
||
**Tech Stack:** Go (Gin + Dubbo-go + GORM + gorilla/websocket + go-redis), Vue 3 + uni-app + uniapp WebSocket API, PostgreSQL, Redis Pub/Sub
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Backend (新增)
|
||
|
||
```
|
||
backend/
|
||
├── migrations/
|
||
│ └── 2026_06_22_012_activity_messages.sql [新增]
|
||
├── services/activityService/
|
||
│ ├── repository/activity_messages_repository.go [新增]
|
||
│ ├── config/config.go [新增]
|
||
│ └── service/activity_service.go [修改 - 追加 CreateActivityMessage/ListActivityMessages + Redis Publish]
|
||
├── proto/activity.proto [修改 - 追加 3 个 message + 2 个 RPC]
|
||
├── pkg/
|
||
│ ├── models/activity_message.go [新增]
|
||
│ └── errors/errors.go [修改 - 追加 7 个错误变量 + ToGRPCCode 映射]
|
||
└── gateway/
|
||
├── socket/activity_socket.go [新增]
|
||
├── router/router.go [修改 - 注册 /activity WS 路由 + ListActivityMessages/CreateActivityMessage HTTP 路由]
|
||
├── controller/activity_controller.go [修改 - 追加 ListActivityMessages/CreateActivityMessage handler + 转换函数]
|
||
├── main.go [修改 - 创建 ActivityHub 并注入]
|
||
├── config/config.go [修改 - 加 WebSocket.ActivityPath]
|
||
└── pkg/response/response.go [修改 - 加 6 条中文错误映射]
|
||
```
|
||
|
||
### Frontend (新增/修改)
|
||
|
||
```
|
||
frontend/
|
||
├── utils/
|
||
│ ├── api.js [修改 - 加 listActivityMessagesApi/createActivityMessageApi]
|
||
│ └── socket/
|
||
│ ├── ActivitySocket.js [新增]
|
||
│ ├── GlobalSocketManager.js [修改 - 加 _initActivity()]
|
||
│ └── index.js [修改 - 导出 ActivitySocket]
|
||
├── pages/support-activity/
|
||
│ ├── composables/
|
||
│ │ ├── useContributionPolling.js [修改 - 暴露 highestIdRef]
|
||
│ │ ├── useContributionRealtime.js [新增]
|
||
│ │ └── useMessageRealtime.js [新增]
|
||
│ ├── components/MessageBoard.vue [无修改 - props 契约不变]
|
||
│ └── index.vue [修改 - 用 useMessageRealtime 替换 mock + handleSendMessage 调 API]
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: 创建数据库迁移脚本
|
||
|
||
**Files:**
|
||
- Create: `backend/migrations/2026_06_22_012_activity_messages.sql`
|
||
|
||
- [ ] **Step 1: 创建迁移文件**
|
||
|
||
```sql
|
||
-- 2026_06_22_012_activity_messages.sql
|
||
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_messages_activity
|
||
FOREIGN KEY (activity_id) REFERENCES public.activities(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_messages_user
|
||
FOREIGN KEY (user_id) REFERENCES public.users(id),
|
||
CONSTRAINT fk_messages_star
|
||
FOREIGN KEY (star_id) REFERENCES public.stars(star_id)
|
||
);
|
||
|
||
CREATE SEQUENCE IF NOT EXISTS activity_messages_id_seq START WITH 10000;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_activity_messages_activity_created
|
||
ON public.activity_messages (activity_id, created_at DESC, id DESC)
|
||
WHERE deleted_at IS NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_activity_messages_user_created
|
||
ON public.activity_messages (user_id, created_at DESC)
|
||
WHERE deleted_at IS NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_activity_messages_activity_incr
|
||
ON public.activity_messages (activity_id, id DESC)
|
||
WHERE deleted_at IS NULL;
|
||
|
||
COMMENT ON TABLE public.activity_messages IS '活动留言表';
|
||
COMMENT ON COLUMN public.activity_messages.id IS '主键,自增';
|
||
COMMENT ON COLUMN public.activity_messages.activity_id IS 'FK -> activities.id';
|
||
COMMENT ON COLUMN public.activity_messages.user_id IS '留言用户 ID';
|
||
COMMENT ON COLUMN public.activity_messages.star_id IS '所属明星/星球 ID';
|
||
COMMENT ON COLUMN public.activity_messages.content IS '留言正文,1-500 字';
|
||
COMMENT ON COLUMN public.activity_messages.status IS '0=正常|1=隐藏|2=已删除';
|
||
COMMENT ON COLUMN public.activity_messages.created_at IS '留言时间,毫秒时间戳';
|
||
COMMENT ON COLUMN public.activity_messages.updated_at IS '更新时间,毫秒时间戳';
|
||
COMMENT ON COLUMN public.activity_messages.deleted_at IS '软删除时间';
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
- [ ] **Step 2: 暂存迁移文件**
|
||
|
||
```bash
|
||
git add backend/migrations/2026_06_22_012_activity_messages.sql
|
||
```
|
||
|
||
> 注:用户需自行决定 commit 时机,本计划不在 AI 自主 commit。
|
||
|
||
---
|
||
|
||
## Task 2: Proto 文件追加 messages 相关定义
|
||
|
||
**Files:**
|
||
- Modify: `backend/proto/activity.proto` (在末尾追加)
|
||
|
||
- [ ] **Step 1: 在 proto 文件末尾追加 3 个 message**
|
||
|
||
在 `backend/proto/activity.proto` 文件末尾(`service ActivityService` 块之前)追加:
|
||
|
||
```protobuf
|
||
// ============== 留言相关消息 ==============
|
||
|
||
message ActivityMessage {
|
||
int64 id = 1;
|
||
int64 activity_id = 2;
|
||
int64 user_id = 3;
|
||
int64 star_id = 4;
|
||
string nickname = 5;
|
||
string avatar_url = 6;
|
||
string content = 7;
|
||
int64 created_at = 8;
|
||
}
|
||
|
||
message ListActivityMessagesRequest {
|
||
int64 activity_id = 1;
|
||
int32 page = 2;
|
||
int32 page_size = 3;
|
||
}
|
||
message ListActivityMessagesResponse {
|
||
topfans.common.BaseResponse base = 1;
|
||
repeated ActivityMessage messages = 2;
|
||
int32 page = 3;
|
||
int32 page_size = 4;
|
||
int32 total = 5;
|
||
}
|
||
|
||
message CreateActivityMessageRequest {
|
||
int64 activity_id = 1;
|
||
int64 user_id = 2;
|
||
int64 star_id = 3;
|
||
string content = 4;
|
||
}
|
||
message CreateActivityMessageResponse {
|
||
topfans.common.BaseResponse base = 1;
|
||
ActivityMessage message = 2;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 service ActivityService 块末尾追加 2 个 RPC**
|
||
|
||
在 `service ActivityService { ... }` 块的最后(`GetLatestContributions` 之后、`}` 之前)追加:
|
||
|
||
```protobuf
|
||
// 列出活动留言
|
||
rpc ListActivityMessages(ListActivityMessagesRequest) returns (ListActivityMessagesResponse) {
|
||
option (google.api.http) = {
|
||
get: "/api/v1/activities/{activity_id}/messages"
|
||
};
|
||
}
|
||
|
||
// 发送一条留言
|
||
rpc CreateActivityMessage(CreateActivityMessageRequest) returns (CreateActivityMessageResponse) {
|
||
option (google.api.http) = {
|
||
post: "/api/v1/activities/{activity_id}/messages"
|
||
body: "*"
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 运行 proto 编译**
|
||
|
||
```bash
|
||
cd backend && make proto
|
||
```
|
||
|
||
Expected: 生成/更新 `backend/pkg/proto/activity/activity.pb.go`,包含新的 RPC 接口。
|
||
|
||
- [ ] **Step 4: 暂存修改**
|
||
|
||
```bash
|
||
git add backend/proto/activity.proto backend/pkg/proto/activity/
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 错误码扩展
|
||
|
||
**Files:**
|
||
- Modify: `backend/pkg/errors/errors.go`
|
||
|
||
- [ ] **Step 1: 在 "活动服务相关错误" 块下方追加 7 个错误变量**
|
||
|
||
在现有 `ErrActivityNotFound`/`ErrActivityItemNotFound` 之后追加:
|
||
|
||
```go
|
||
// 活动留言相关错误
|
||
ErrActivityMessageNotFound = errors.New("活动留言不存在")
|
||
ErrActivityMessageTooFrequent = errors.New("留言太频繁,请稍后再试")
|
||
ErrActivityMessageLimitReached = errors.New("当前活动留言已达上限")
|
||
ErrActivityMessageContentEmpty = errors.New("留言内容不能为空")
|
||
ErrActivityMessageContentTooLong = errors.New("留言内容过长,最多500字")
|
||
ErrActivityMessageContentInvalid = errors.New("留言内容包含不当内容")
|
||
ErrActivityMessageActivityInactive = errors.New("活动不在进行中")
|
||
```
|
||
|
||
- [ ] **Step 2: 在 ToGRPCCode 函数中追加映射**
|
||
|
||
在 `ToGRPCCode` 的 switch 末尾追加:
|
||
|
||
```go
|
||
case errors.Is(err, ErrActivityMessageNotFound):
|
||
return codes.NotFound
|
||
case errors.Is(err, ErrActivityMessageTooFrequent):
|
||
return codes.ResourceExhausted
|
||
case errors.Is(err, ErrActivityMessageLimitReached):
|
||
return codes.ResourceExhausted
|
||
case errors.Is(err, ErrActivityMessageContentEmpty):
|
||
return codes.InvalidArgument
|
||
case errors.Is(err, ErrActivityMessageContentTooLong):
|
||
return codes.InvalidArgument
|
||
case errors.Is(err, ErrActivityMessageContentInvalid):
|
||
return codes.PermissionDenied
|
||
case errors.Is(err, ErrActivityMessageActivityInactive):
|
||
return codes.PermissionDenied
|
||
```
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
```bash
|
||
cd backend/pkg && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 4: Response 中文错误映射
|
||
|
||
**Files:**
|
||
- Modify: `backend/gateway/pkg/response/response.go`
|
||
|
||
- [ ] **Step 1: 在 errorMap 中追加留言相关中文映射**
|
||
|
||
在 `errorMap` map 字面量末尾追加(保持与 errors.go 一致):
|
||
|
||
```go
|
||
"活动留言不存在": "活动留言不存在",
|
||
"留言太频繁": "留言太频繁,请稍后再试",
|
||
"当前活动留言已达上限": "当前活动留言已达上限",
|
||
"留言内容不能为空": "留言内容不能为空",
|
||
"留言内容过长": "留言内容过长,最多500字",
|
||
"留言内容包含不当内容": "留言内容包含不当内容,请修改",
|
||
"活动不在进行中": "活动未开始或已结束",
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/gateway && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 5: ActivityMessage Model
|
||
|
||
**Files:**
|
||
- Create: `backend/pkg/models/activity_message.go`
|
||
|
||
- [ ] **Step 1: 创建 Model**
|
||
|
||
```go
|
||
package models
|
||
|
||
import "time"
|
||
|
||
// ActivityMessage 活动留言
|
||
type ActivityMessage struct {
|
||
ID int64 `gorm:"primaryKey;column:id" json:"id"`
|
||
ActivityID int64 `gorm:"column:activity_id;not null;index" json:"activity_id"`
|
||
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
|
||
StarID int64 `gorm:"column:star_id;not null" json:"star_id"`
|
||
Content string `gorm:"column:content;type:varchar(500);not null" json:"content"`
|
||
Status int16 `gorm:"column:status;not null;default:0" json:"status"`
|
||
CreatedAt int64 `gorm:"column:created_at;not null" json:"created_at"`
|
||
UpdatedAt int64 `gorm:"column:updated_at;not null" json:"updated_at"`
|
||
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at,omitempty"`
|
||
}
|
||
|
||
// TableName 表名
|
||
func (ActivityMessage) TableName() string {
|
||
return "activity_messages"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/pkg && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 6: Repository 层
|
||
|
||
**Files:**
|
||
- Create: `backend/services/activityService/repository/activity_messages_repository.go`
|
||
|
||
- [ ] **Step 1: 创建 Repository**
|
||
|
||
```go
|
||
package repository
|
||
|
||
import (
|
||
"errors"
|
||
|
||
"github.com/topfans/backend/pkg/database"
|
||
"github.com/topfans/backend/pkg/models"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ActivityMessagesRepository 活动留言仓库接口
|
||
type ActivityMessagesRepository interface {
|
||
// Insert 插入一条留言
|
||
Insert(msg *models.ActivityMessage) (int64, error)
|
||
|
||
// ListByActivity 列出活动的留言(分页,按 created_at DESC, id DESC)
|
||
ListByActivity(activityID int64, page, pageSize int) ([]*models.ActivityMessage, int64, error)
|
||
|
||
// CountByUserActivity 统计某用户在某活动的留言数(用于累计上限校验)
|
||
CountByUserActivity(ctx interface{}, activityID, userID int64) (int64, error)
|
||
}
|
||
|
||
// activityMessagesRepository 实现
|
||
type activityMessagesRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewActivityMessagesRepository 创建仓库实例
|
||
func NewActivityMessagesRepository() ActivityMessagesRepository {
|
||
return &activityMessagesRepository{
|
||
db: database.GetDB(),
|
||
}
|
||
}
|
||
|
||
// Insert 插入一条留言,返回新 ID
|
||
func (r *activityMessagesRepository) Insert(msg *models.ActivityMessage) (int64, error) {
|
||
if msg == nil {
|
||
return 0, errors.New("message cannot be nil")
|
||
}
|
||
if err := r.db.Create(msg).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return msg.ID, nil
|
||
}
|
||
|
||
// ListByActivity 列出活动的留言
|
||
func (r *activityMessagesRepository) ListByActivity(activityID int64, page, pageSize int) ([]*models.ActivityMessage, int64, error) {
|
||
if activityID <= 0 {
|
||
return nil, 0, errors.New("activity_id must be greater than 0")
|
||
}
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
if pageSize > 50 {
|
||
pageSize = 50
|
||
}
|
||
|
||
query := r.db.Model(&models.ActivityMessage{}).
|
||
Where("activity_id = ? AND deleted_at IS NULL", activityID)
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
var messages []*models.ActivityMessage
|
||
offset := (page - 1) * pageSize
|
||
if err := query.Order("created_at DESC, id DESC").
|
||
Offset(offset).
|
||
Limit(pageSize).
|
||
Find(&messages).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
return messages, total, nil
|
||
}
|
||
|
||
// CountByUserActivity 统计某用户在某活动的留言数
|
||
func (r *activityMessagesRepository) CountByUserActivity(_ interface{}, activityID, userID int64) (int64, error) {
|
||
if activityID <= 0 || userID <= 0 {
|
||
return 0, errors.New("activity_id and user_id must be greater than 0")
|
||
}
|
||
var count int64
|
||
if err := r.db.Model(&models.ActivityMessage{}).
|
||
Where("activity_id = ? AND user_id = ? AND deleted_at IS NULL", activityID, userID).
|
||
Count(&count).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return count, nil
|
||
}
|
||
```
|
||
|
||
> 注意:`CountByUserActivity` 第一参数 `ctx interface{}` 占位(实际不需要 ctx,保留扩展点)。调用方传 `nil`。
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/services/activityService && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 7: activityService config 文件
|
||
|
||
**Files:**
|
||
- Create: `backend/services/activityService/config/config.go`
|
||
|
||
- [ ] **Step 1: 创建 config 包**
|
||
|
||
```go
|
||
package config
|
||
|
||
import (
|
||
"os"
|
||
"strconv"
|
||
)
|
||
|
||
// ActivityMessageConfig 活动留言配置
|
||
type ActivityMessageConfig struct {
|
||
MessageRateLimitPerMin int64 // 单用户单活动每分钟最多留言数
|
||
MessageLimitPerActivity int64 // 单用户单活动累计留言上限
|
||
BannedWords []string // 敏感词首版本地词表
|
||
}
|
||
|
||
// LoadMessageConfig 从环境变量加载配置(缺省值参考通知服务)
|
||
func LoadMessageConfig() *ActivityMessageConfig {
|
||
cfg := &ActivityMessageConfig{
|
||
MessageRateLimitPerMin: getEnvInt64("ACTIVITY_MESSAGE_RATE_LIMIT_PER_MIN", 5),
|
||
MessageLimitPerActivity: getEnvInt64("ACTIVITY_MESSAGE_LIMIT_PER_ACTIVITY", 100),
|
||
BannedWords: []string{
|
||
"傻逼", "操你", "草泥马", "fuck", "shit",
|
||
},
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func getEnvInt64(key string, defaultVal int64) int64 {
|
||
v := os.Getenv(key)
|
||
if v == "" {
|
||
return defaultVal
|
||
}
|
||
parsed, err := strconv.ParseInt(v, 10, 64)
|
||
if err != nil {
|
||
return defaultVal
|
||
}
|
||
return parsed
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/services/activityService && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 8: Service 层添加 CreateActivityMessage 和 ListActivityMessages 业务方法
|
||
|
||
**Files:**
|
||
- Modify: `backend/services/activityService/service/activity_service.go`
|
||
|
||
- [ ] **Step 1: 在 ActivityService 接口追加 2 个方法**
|
||
|
||
在 `ActivityService` interface 末尾追加:
|
||
|
||
```go
|
||
// CreateActivityMessage 创建一条活动留言
|
||
CreateActivityMessage(ctx context.Context, req *pb.CreateActivityMessageRequest) (*pb.CreateActivityMessageResponse, error)
|
||
|
||
// ListActivityMessages 列出活动留言
|
||
ListActivityMessages(ctx context.Context, req *pb.ListActivityMessagesRequest) (*pb.ListActivityMessagesResponse, error)
|
||
```
|
||
|
||
- [ ] **Step 2: 在 activityService struct 追加 messagesRepo 字段和 cfg 字段**
|
||
|
||
修改 struct 定义:
|
||
|
||
```go
|
||
type activityService struct {
|
||
activityRepo repository.ActivityRepository
|
||
mintingActivityRepo repository.MintingActivityRepository
|
||
messagesRepo repository.ActivityMessagesRepository
|
||
userRPCClient *client.UserRPCClient
|
||
redisClient *redis.Client
|
||
messageCfg *config.ActivityMessageConfig
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 修改 NewActivityService 构造函数注入新依赖**
|
||
|
||
在现有 `NewActivityService` 函数末尾追加:
|
||
|
||
```go
|
||
messagesRepo: repository.NewActivityMessagesRepository(),
|
||
messageCfg: config.LoadMessageConfig(),
|
||
```
|
||
|
||
(保留原有 `activityRepo`/`mintingActivityRepo`/`userRPCClient`/`redisClient` 不变)
|
||
|
||
- [ ] **Step 4: 在 service 文件末尾追加 CreateActivityMessage 实现**
|
||
|
||
```go
|
||
// CreateActivityMessage 创建一条活动留言(含频控/累计上限/敏感词校验 + Redis Publish)
|
||
func (s *activityService) CreateActivityMessage(ctx context.Context, req *pb.CreateActivityMessageRequest) (*pb.CreateActivityMessageResponse, error) {
|
||
// 1. 入参校验
|
||
content := strings.TrimSpace(req.Content)
|
||
if content == "" {
|
||
return nil, appErrors.ErrActivityMessageContentEmpty
|
||
}
|
||
if utf8.RuneCountInString(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 60s)
|
||
rateKey := fmt.Sprintf("msg:rate:%d:%d", req.ActivityId, req.UserId)
|
||
count, err := s.redisClient.Incr(ctx, rateKey).Result()
|
||
if err == nil && count == 1 {
|
||
s.redisClient.Expire(ctx, rateKey, 60*time.Second)
|
||
}
|
||
if err == nil && count > s.messageCfg.MessageRateLimitPerMin {
|
||
return nil, appErrors.ErrActivityMessageTooFrequent
|
||
}
|
||
|
||
// 4. 累计上限
|
||
total, err := s.messagesRepo.CountByUserActivity(ctx, req.ActivityId, req.UserId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if total >= s.messageCfg.MessageLimitPerActivity {
|
||
return nil, appErrors.ErrActivityMessageLimitReached
|
||
}
|
||
|
||
// 5. 敏感词校验(首版本地词表)
|
||
if containsBannedWord(content, s.messageCfg.BannedWords) {
|
||
return nil, appErrors.ErrActivityMessageContentInvalid
|
||
}
|
||
|
||
// 6. 写入
|
||
now := time.Now().UnixMilli()
|
||
msg := &models.ActivityMessage{
|
||
ActivityID: req.ActivityId,
|
||
UserID: req.UserId,
|
||
StarID: req.StarId,
|
||
Content: content,
|
||
Status: 0,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
msgID, err := s.messagesRepo.Insert(msg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 7. 回查昵称头像
|
||
profile, _ := s.userRPCClient.GetFanProfile(ctx, req.UserId, req.StarId)
|
||
nickname := ""
|
||
avatarURL := ""
|
||
if profile != nil {
|
||
nickname = profile.Nickname
|
||
avatarURL = profile.AvatarUrl
|
||
}
|
||
|
||
pbMsg := &pb.ActivityMessage{
|
||
Id: msgID,
|
||
ActivityId: req.ActivityId,
|
||
UserId: req.UserId,
|
||
StarId: req.StarId,
|
||
Nickname: nickname,
|
||
AvatarUrl: avatarURL,
|
||
Content: content,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
// 8. Redis Publish(不论是否有人订阅都发)
|
||
channel := fmt.Sprintf("act:%d:messages", req.ActivityId)
|
||
payload, _ := json.Marshal(map[string]interface{}{
|
||
"activity_id": req.ActivityId,
|
||
"type": "messages_response",
|
||
"message": pbMsg,
|
||
})
|
||
s.redisClient.Publish(ctx, channel, payload)
|
||
|
||
return &pb.CreateActivityMessageResponse{
|
||
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK), Message: "ok"},
|
||
Message: pbMsg,
|
||
}, nil
|
||
}
|
||
|
||
// ListActivityMessages 列出活动留言
|
||
func (s *activityService) ListActivityMessages(ctx context.Context, req *pb.ListActivityMessagesRequest) (*pb.ListActivityMessagesResponse, error) {
|
||
page := int(req.Page)
|
||
pageSize := int(req.PageSize)
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
if pageSize > 50 {
|
||
pageSize = 50
|
||
}
|
||
|
||
// 校验活动存在
|
||
activity, err := s.activityRepo.GetActivityByID(req.ActivityId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if activity == nil {
|
||
return nil, appErrors.ErrActivityNotFound
|
||
}
|
||
|
||
rows, total, err := s.messagesRepo.ListByActivity(req.ActivityId, page, pageSize)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 回查昵称头像
|
||
uidSet := make(map[int64]struct{})
|
||
for _, m := range rows {
|
||
uidSet[m.UserID] = struct{}{}
|
||
}
|
||
|
||
messages := make([]*pb.ActivityMessage, 0, len(rows))
|
||
for _, m := range rows {
|
||
nickname, avatarURL := s.fetchUserProfile(ctx, m.UserID, m.StarID)
|
||
messages = append(messages, &pb.ActivityMessage{
|
||
Id: m.ID,
|
||
ActivityId: m.ActivityID,
|
||
UserId: m.UserID,
|
||
StarId: m.StarID,
|
||
Nickname: nickname,
|
||
AvatarUrl: avatarURL,
|
||
Content: m.Content,
|
||
CreatedAt: m.CreatedAt,
|
||
})
|
||
}
|
||
|
||
return &pb.ListActivityMessagesResponse{
|
||
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK), Message: "ok"},
|
||
Messages: messages,
|
||
Page: int32(page),
|
||
PageSize: int32(pageSize),
|
||
Total: int32(total),
|
||
}, nil
|
||
}
|
||
|
||
// fetchUserProfile 拉取用户昵称头像(失败时返回空串,不影响列表返回)
|
||
func (s *activityService) fetchUserProfile(ctx context.Context, userID, starID int64) (string, string) {
|
||
profile, err := s.userRPCClient.GetFanProfile(ctx, userID, starID)
|
||
if err != nil || profile == nil {
|
||
return "", ""
|
||
}
|
||
return profile.Nickname, profile.AvatarUrl
|
||
}
|
||
|
||
// containsBannedWord 检查是否含敏感词
|
||
func containsBannedWord(content string, banned []string) bool {
|
||
lower := strings.ToLower(content)
|
||
for _, w := range banned {
|
||
if strings.Contains(lower, strings.ToLower(w)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 在文件顶部追加必要 import**
|
||
|
||
检查文件顶部 import 块,确保包含:
|
||
|
||
```go
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
```
|
||
|
||
- [ ] **Step 6: 编译验证**
|
||
|
||
```bash
|
||
cd backend/services/activityService && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
> 注意:`profile.Nickname` / `profile.AvatarUrl` 字段名以 `UserRPCClient.GetFanProfile` 实际返回类型为准;若不同需要调整。
|
||
|
||
---
|
||
|
||
## Task 9: 在 PurchaseItem/BatchPurchaseItem 末尾追加 Redis Publish contributions
|
||
|
||
**Files:**
|
||
- Modify: `backend/services/activityService/service/activity_service.go`
|
||
|
||
- [ ] **Step 1: 在 PurchaseItem 写库成功后追加 Publish**
|
||
|
||
在 `PurchaseItem` 函数中 `CreateContribution` 成功之后、`return` 之前追加:
|
||
|
||
```go
|
||
// 推送 contributions_response 到 Redis Pub/Sub
|
||
contribMsg := &pb.ContributionRecord{
|
||
Id: contribution.ID,
|
||
UserId: userID,
|
||
StarId: req.StarId,
|
||
ItemType: req.ItemType,
|
||
Quantity: int32(req.Quantity),
|
||
ComboCount: int32(comboCount),
|
||
CreatedAt: now,
|
||
}
|
||
// 拉取昵称头像
|
||
if profile, _ := s.userRPCClient.GetFanProfile(ctx, userID, req.StarId); profile != nil {
|
||
contribMsg.Nickname = profile.Nickname
|
||
contribMsg.AvatarUrl = profile.AvatarUrl
|
||
}
|
||
if item != nil {
|
||
contribMsg.ItemId = item.ID
|
||
contribMsg.ItemName = item.ItemName
|
||
contribMsg.ItemIcon = item.IconUrl
|
||
}
|
||
|
||
payload, _ := json.Marshal(map[string]interface{}{
|
||
"activity_id": req.ActivityId,
|
||
"type": "contributions_response",
|
||
"record": contribMsg,
|
||
})
|
||
s.redisClient.Publish(ctx, fmt.Sprintf("act:%d:contributions", req.ActivityId), payload)
|
||
```
|
||
|
||
> 实际变量名以 PurchaseItem 函数体内已有定义为准(如 `item`、`comboCount`、`now` 等)。
|
||
|
||
- [ ] **Step 2: 在 BatchPurchaseItem 末尾对每个成功 item Publish**
|
||
|
||
类似 Step 1,在 BatchPurchaseItem 中每次 `CreateContribution` 成功后追加 Publish。
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
```bash
|
||
cd backend/services/activityService && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 10: Provider 层添加 RPC 入口
|
||
|
||
**Files:**
|
||
- Modify: `backend/services/activityService/provider/activity_provider.go`
|
||
|
||
- [ ] **Step 1: 在 ActivityProvider 上追加 2 个 RPC 方法**
|
||
|
||
在文件末尾追加(参照 `PurchaseItem` 的 pattern):
|
||
|
||
```go
|
||
// ListActivityMessages 列出活动留言
|
||
func (p *ActivityProvider) ListActivityMessages(ctx context.Context, req *pb.ListActivityMessagesRequest) (*pb.ListActivityMessagesResponse, error) {
|
||
logger.Logger.Info("Received ListActivityMessages request", zap.Int64("activity_id", req.ActivityId))
|
||
resp, err := p.activityService.ListActivityMessages(ctx, req)
|
||
if err != nil {
|
||
logger.Logger.Error("ListActivityMessages failed", zap.Error(err))
|
||
return &pb.ListActivityMessagesResponse{
|
||
Base: &pbCommon.BaseResponse{
|
||
Code: uint32(appErrors.ToGRPCCode(err)),
|
||
Message: err.Error(),
|
||
Timestamp: time.Now().UnixMilli(),
|
||
},
|
||
}, err
|
||
}
|
||
logger.Logger.Info("ListActivityMessages successful", zap.Int64("activity_id", req.ActivityId), zap.Int("count", len(resp.Messages)))
|
||
return resp, nil
|
||
}
|
||
|
||
// CreateActivityMessage 发送一条留言
|
||
func (p *ActivityProvider) CreateActivityMessage(ctx context.Context, req *pb.CreateActivityMessageRequest) (*pb.CreateActivityMessageResponse, error) {
|
||
logger.Logger.Info("Received CreateActivityMessage request", zap.Int64("activity_id", req.ActivityId), zap.Int64("user_id", req.UserId))
|
||
resp, err := p.activityService.CreateActivityMessage(ctx, req)
|
||
if err != nil {
|
||
logger.Logger.Error("CreateActivityMessage failed", zap.Error(err))
|
||
return &pb.CreateActivityMessageResponse{
|
||
Base: &pbCommon.BaseResponse{
|
||
Code: uint32(appErrors.ToGRPCCode(err)),
|
||
Message: err.Error(),
|
||
Timestamp: time.Now().UnixMilli(),
|
||
},
|
||
}, err
|
||
}
|
||
logger.Logger.Info("CreateActivityMessage successful", zap.Int64("message_id", resp.Message.Id))
|
||
return resp, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/services/activityService && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 11: ActivityHub WebSocket 实现
|
||
|
||
**Files:**
|
||
- Create: `backend/gateway/socket/activity_socket.go`
|
||
|
||
- [ ] **Step 1: 创建 ActivityHub**
|
||
|
||
```go
|
||
package socket
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"github.com/redis/go-redis/v9"
|
||
"github.com/topfans/backend/pkg/jwt"
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// ActivityHub 管理所有 Activity WebSocket 连接
|
||
type ActivityHub struct {
|
||
clients map[int64]map[*ActivityConn]struct{} // userId -> set of conns(一个用户可多端)
|
||
subscriptions map[string]map[*ActivityConn]struct{} // "act:42:messages" / "act:42:contributions" -> conns
|
||
redisClient *redis.Client
|
||
activityPath string
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
// ActivityConn 单条 WebSocket 连接
|
||
type ActivityConn struct {
|
||
UserID int64
|
||
StarID int64
|
||
Conn *websocket.Conn
|
||
Send chan []byte
|
||
Hub *ActivityHub
|
||
writeMu sync.Mutex
|
||
}
|
||
|
||
// writeJSON 线程安全 JSON 写入
|
||
func (c *ActivityConn) writeJSON(data interface{}) error {
|
||
c.writeMu.Lock()
|
||
defer c.writeMu.Unlock()
|
||
return c.Conn.WriteJSON(data)
|
||
}
|
||
|
||
// NewActivityHub 创建 ActivityHub
|
||
func NewActivityHub(redisClient *redis.Client, activityPath string) *ActivityHub {
|
||
return &ActivityHub{
|
||
clients: make(map[int64]map[*ActivityConn]struct{}),
|
||
subscriptions: make(map[string]map[*ActivityConn]struct{}),
|
||
redisClient: redisClient,
|
||
activityPath: activityPath,
|
||
}
|
||
}
|
||
|
||
// Run 启动 Redis PSubscribe,收到 publish 后 fanout 到本地连接
|
||
func (h *ActivityHub) Run(ctx context.Context) {
|
||
sub := h.redisClient.PSubscribe(ctx, "act:*:messages", "act:*:contributions")
|
||
defer sub.Close()
|
||
ch := sub.Channel()
|
||
logger.Logger.Info("ActivityHub subscribed to Redis Pub/Sub channels")
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
logger.Logger.Info("ActivityHub Run loop exiting due to context done")
|
||
return
|
||
case msg, ok := <-ch:
|
||
if !ok {
|
||
logger.Logger.Warn("ActivityHub Redis Pub/Sub channel closed")
|
||
return
|
||
}
|
||
var payload map[string]interface{}
|
||
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil {
|
||
logger.Logger.Error("ActivityHub failed to unmarshal pubsub payload", zap.Error(err))
|
||
continue
|
||
}
|
||
h.fanout(msg.Channel, payload)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fanout 把 payload 推送到订阅该 channel 的所有本地连接
|
||
func (h *ActivityHub) fanout(channel string, payload map[string]interface{}) {
|
||
h.mu.RLock()
|
||
conns := h.subscriptions[channel]
|
||
targets := make([]*ActivityConn, 0, len(conns))
|
||
for c := range conns {
|
||
targets = append(targets, c)
|
||
}
|
||
h.mu.RUnlock()
|
||
|
||
for _, c := range targets {
|
||
if err := c.writeJSON(payload); err != nil {
|
||
logger.Logger.Error("ActivityHub writeJSON failed", zap.Int64("user_id", c.UserID), zap.Error(err))
|
||
}
|
||
}
|
||
}
|
||
|
||
// HandleWebSocket 处理 /activity 握手
|
||
func (h *ActivityHub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||
token := r.URL.Query().Get("token")
|
||
|
||
userID, starID, err := h.validateToken(token)
|
||
if err != nil {
|
||
logger.Logger.Error("Activity WebSocket token validation failed", zap.Error(err))
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"type": "auth_response",
|
||
"success": false,
|
||
"error": "invalid_token",
|
||
})
|
||
return
|
||
}
|
||
|
||
conn, err := upgrader.Upgrade(w, r, nil)
|
||
if err != nil {
|
||
logger.Logger.Error("Activity WebSocket upgrade failed", zap.Error(err))
|
||
return
|
||
}
|
||
|
||
c := &ActivityConn{
|
||
UserID: userID,
|
||
StarID: starID,
|
||
Conn: conn,
|
||
Send: make(chan []byte, 256),
|
||
Hub: h,
|
||
}
|
||
|
||
h.mu.Lock()
|
||
if h.clients[userID] == nil {
|
||
h.clients[userID] = make(map[*ActivityConn]struct{})
|
||
}
|
||
h.clients[userID][c] = struct{}{}
|
||
h.mu.Unlock()
|
||
|
||
logger.Logger.Info("Activity WebSocket connection established",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
)
|
||
|
||
// 立即推送 auth_response
|
||
conn.WriteJSON(map[string]interface{}{
|
||
"type": "auth_response",
|
||
"success": true,
|
||
"user_id": userID,
|
||
"star_id": starID,
|
||
})
|
||
|
||
go c.readPump()
|
||
go c.writePump()
|
||
}
|
||
|
||
// validateToken 验证 token(JWT)
|
||
func (h *ActivityHub) validateToken(token string) (int64, int64, error) {
|
||
if strings.HasPrefix(token, "Bearer_") {
|
||
token = strings.TrimPrefix(token, "Bearer_")
|
||
}
|
||
claims, err := jwt.ParseToken(token)
|
||
if err != nil {
|
||
return 0, 0, fmt.Errorf("failed to parse token: %w", err)
|
||
}
|
||
if claims.UserID == 0 {
|
||
return 0, 0, fmt.Errorf("invalid user id")
|
||
}
|
||
return claims.UserID, claims.StarID, nil
|
||
}
|
||
|
||
// readPump 读取客户端消息
|
||
func (c *ActivityConn) readPump() {
|
||
defer func() {
|
||
c.Hub.unregister(c)
|
||
c.Conn.Close()
|
||
}()
|
||
|
||
c.Conn.SetReadLimit(64 * 1024) // 64KB(订阅消息很小)
|
||
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||
c.Conn.SetPongHandler(func(string) error {
|
||
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||
return nil
|
||
})
|
||
|
||
for {
|
||
_, message, err := c.Conn.ReadMessage()
|
||
if err != nil {
|
||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||
logger.Logger.Error("Activity WebSocket read error", zap.Error(err))
|
||
}
|
||
break
|
||
}
|
||
|
||
var msg map[string]interface{}
|
||
if err := json.Unmarshal(message, &msg); err != nil {
|
||
logger.Logger.Error("Failed to parse activity message", zap.Error(err))
|
||
continue
|
||
}
|
||
c.handleMessage(msg)
|
||
}
|
||
}
|
||
|
||
// writePump 写消息到客户端
|
||
func (c *ActivityConn) writePump() {
|
||
ticker := time.NewTicker(30 * time.Second)
|
||
defer func() {
|
||
ticker.Stop()
|
||
c.Conn.Close()
|
||
}()
|
||
|
||
for {
|
||
select {
|
||
case message, ok := <-c.Send:
|
||
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||
if !ok {
|
||
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||
return
|
||
}
|
||
w, err := c.Conn.NextWriter(websocket.TextMessage)
|
||
if err != nil {
|
||
return
|
||
}
|
||
w.Write(message)
|
||
if err := w.Close(); err != nil {
|
||
return
|
||
}
|
||
case <-ticker.C:
|
||
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleMessage 处理客户端 subscribe/unsubscribe/ping
|
||
func (c *ActivityConn) handleMessage(msg map[string]interface{}) {
|
||
action, _ := msg["action"].(string)
|
||
switch action {
|
||
case "ping":
|
||
c.Send <- []byte(`{"type":"pong"}`)
|
||
|
||
case "subscribe":
|
||
activityID := toInt64(msg["activity_id"])
|
||
topics := toStringSlice(msg["topics"])
|
||
c.Hub.subscribe(c, activityID, topics)
|
||
c.writeJSON(map[string]interface{}{
|
||
"type": "subscribe_response",
|
||
"activity_id": activityID,
|
||
"topics": topics,
|
||
})
|
||
|
||
case "unsubscribe":
|
||
activityID := toInt64(msg["activity_id"])
|
||
topics := toStringSlice(msg["topics"])
|
||
c.Hub.unsubscribe(c, activityID, topics)
|
||
c.writeJSON(map[string]interface{}{
|
||
"type": "unsubscribe_response",
|
||
"activity_id": activityID,
|
||
"topics": topics,
|
||
})
|
||
|
||
default:
|
||
logger.Logger.Warn("Unknown activity action", zap.String("action", action))
|
||
}
|
||
}
|
||
|
||
// subscribe 幂等订阅
|
||
func (h *ActivityHub) subscribe(c *ActivityConn, activityID int64, topics []string) {
|
||
if activityID <= 0 || len(topics) == 0 {
|
||
return
|
||
}
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
for _, t := range topics {
|
||
ch := fmt.Sprintf("act:%d:%s", activityID, t)
|
||
if h.subscriptions[ch] == nil {
|
||
h.subscriptions[ch] = make(map[*ActivityConn]struct{})
|
||
}
|
||
h.subscriptions[ch][c] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// unsubscribe 幂等取消订阅
|
||
func (h *ActivityHub) unsubscribe(c *ActivityConn, activityID int64, topics []string) {
|
||
if activityID <= 0 || len(topics) == 0 {
|
||
return
|
||
}
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
for _, t := range topics {
|
||
ch := fmt.Sprintf("act:%d:%s", activityID, t)
|
||
if conns, ok := h.subscriptions[ch]; ok {
|
||
delete(conns, c)
|
||
if len(conns) == 0 {
|
||
delete(h.subscriptions, ch)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// unregister 断开时清理
|
||
func (h *ActivityHub) unregister(c *ActivityConn) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if conns, ok := h.clients[c.UserID]; ok {
|
||
delete(conns, c)
|
||
if len(conns) == 0 {
|
||
delete(h.clients, c.UserID)
|
||
}
|
||
}
|
||
for ch, conns := range h.subscriptions {
|
||
if _, ok := conns[c]; ok {
|
||
delete(conns, c)
|
||
if len(conns) == 0 {
|
||
delete(h.subscriptions, ch)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Close 关闭所有连接
|
||
func (h *ActivityHub) Close() {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
for _, conns := range h.clients {
|
||
for c := range conns {
|
||
c.Conn.Close()
|
||
}
|
||
}
|
||
}
|
||
|
||
// helper
|
||
func toInt64(v interface{}) int64 {
|
||
switch x := v.(type) {
|
||
case float64:
|
||
return int64(x)
|
||
case int64:
|
||
return x
|
||
case int:
|
||
return int64(x)
|
||
case string:
|
||
i, _ := strconv.ParseInt(x, 10, 64)
|
||
return i
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func toStringSlice(v interface{}) []string {
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(arr))
|
||
for _, item := range arr {
|
||
if s, ok := item.(string); ok {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/gateway && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
> 复用 `ai_chat_socket.go` 中已有的 `upgrader` 变量(同一个 package,无需重新声明)。
|
||
|
||
---
|
||
|
||
## Task 12: Gateway Config 添加 ActivityPath
|
||
|
||
**Files:**
|
||
- Modify: `backend/gateway/config/config.go`
|
||
|
||
- [ ] **Step 1: 在 WebSocketConfig struct 中追加 ActivityPath 字段**
|
||
|
||
```go
|
||
type WebSocketConfig struct {
|
||
AIChatPath string // WebSocket 路径,默认 /ai-chat
|
||
ActivityPath string // 活动实时推送 WS 路径,默认 /activity
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 Load() 中追加 ActivityPath 默认值**
|
||
|
||
```go
|
||
WebSocket: WebSocketConfig{
|
||
AIChatPath: getEnv("WS_AI_CHAT_PATH", "/ai-chat"),
|
||
ActivityPath: getEnv("WS_ACTIVITY_PATH", "/activity"),
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
```bash
|
||
cd backend/gateway && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 13: Router 注册 ActivityHub 路由和 HTTP 路由
|
||
|
||
**Files:**
|
||
- Modify: `backend/gateway/router/router.go`
|
||
|
||
- [ ] **Step 1: 修改 SetupRouter 签名追加 activityHub 与 activityPath 参数**
|
||
|
||
在 `SetupRouter` 函数签名末尾追加:
|
||
|
||
```go
|
||
activityHub *socket.ActivityHub,
|
||
activityPath string,
|
||
```
|
||
|
||
- [ ] **Step 2: 在 AI Chat WS 路由注册代码块后追加 Activity WS 路由**
|
||
|
||
```go
|
||
// Activity 实时推送 WebSocket 路由
|
||
r.GET(activityPath, gin.WrapF(func(w http.ResponseWriter, r *http.Request) {
|
||
activityHub.HandleWebSocket(w, r)
|
||
}))
|
||
```
|
||
|
||
- [ ] **Step 3: 在 activities 路由组内追加 messages 路由**
|
||
|
||
在 `activities.GET("/:id/contributions/latest", ...)` 之后追加:
|
||
|
||
```go
|
||
activities.GET("/:id/messages", activityCtrl.ListActivityMessages)
|
||
activities.POST("/:id/messages", activityCtrl.CreateActivityMessage)
|
||
```
|
||
|
||
- [ ] **Step 4: 编译验证**
|
||
|
||
```bash
|
||
cd backend/gateway && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 14: Controller 追加 ListActivityMessages / CreateActivityMessage handler
|
||
|
||
**Files:**
|
||
- Modify: `backend/gateway/controller/activity_controller.go`
|
||
|
||
- [ ] **Step 1: 在 ActivityController 上追加 2 个 handler**
|
||
|
||
参照现有 `GetLatestContributions` handler 的 pattern(Dubbo 注入、参数转换、错误码映射):
|
||
|
||
```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) {
|
||
activityID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.ErrorWithCode(c, http.StatusBadRequest, "INVALID_ACTIVITY_ID", "无效的活动ID")
|
||
return
|
||
}
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||
|
||
req := &pb.ListActivityMessagesRequest{
|
||
ActivityId: activityID,
|
||
Page: int32(page),
|
||
PageSize: int32(pageSize),
|
||
}
|
||
|
||
ctx := ctrl.buildActivityCtx(c)
|
||
resp, err := ctrl.activityProvider.ListActivityMessages(ctx, req)
|
||
if err != nil {
|
||
response.Error(c, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if resp.Base.Code != uint32(codes.OK) {
|
||
status := grpcCodeToHTTP(resp.Base.Code)
|
||
response.ErrorWithCode(c, status, strconv.Itoa(int(resp.Base.Code)), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
messages := make([]gin.H, 0, len(resp.Messages))
|
||
for _, m := range resp.Messages {
|
||
messages = append(messages, gin.H{
|
||
"id": m.Id,
|
||
"activity_id": m.ActivityId,
|
||
"user_id": m.UserId,
|
||
"star_id": m.StarId,
|
||
"nickname": m.Nickname,
|
||
"avatar_url": m.AvatarUrl,
|
||
"content": m.Content,
|
||
"created_at": m.CreatedAt,
|
||
})
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"messages": messages,
|
||
"page": resp.Page,
|
||
"page_size": resp.PageSize,
|
||
"total": resp.Total,
|
||
})
|
||
}
|
||
|
||
// 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) {
|
||
activityID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.ErrorWithCode(c, http.StatusBadRequest, "INVALID_ACTIVITY_ID", "无效的活动ID")
|
||
return
|
||
}
|
||
|
||
var body struct {
|
||
Content string `json:"content" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
response.ErrorWithCode(c, http.StatusBadRequest, "INVALID_BODY", "请求体格式错误")
|
||
return
|
||
}
|
||
|
||
userID, _ := c.Get("user_id")
|
||
starID, _ := c.Get("star_id")
|
||
|
||
req := &pb.CreateActivityMessageRequest{
|
||
ActivityId: activityID,
|
||
UserId: toInt64(userID),
|
||
StarId: toInt64(starID),
|
||
Content: body.Content,
|
||
}
|
||
|
||
ctx := ctrl.buildActivityCtx(c)
|
||
resp, err := ctrl.activityProvider.CreateActivityMessage(ctx, req)
|
||
if err != nil {
|
||
response.Error(c, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if resp.Base.Code != uint32(codes.OK) {
|
||
status := grpcCodeToHTTP(resp.Base.Code)
|
||
response.ErrorWithCode(c, status, strconv.Itoa(int(resp.Base.Code)), resp.Base.Message)
|
||
return
|
||
}
|
||
|
||
m := resp.Message
|
||
response.Success(c, gin.H{
|
||
"message": gin.H{
|
||
"id": m.Id,
|
||
"activity_id": m.ActivityId,
|
||
"user_id": m.UserId,
|
||
"star_id": m.StarId,
|
||
"nickname": m.Nickname,
|
||
"avatar_url": m.AvatarUrl,
|
||
"content": m.Content,
|
||
"created_at": m.CreatedAt,
|
||
},
|
||
})
|
||
}
|
||
```
|
||
|
||
> 具体 helper(`buildActivityCtx`、`grpcCodeToHTTP`、`toInt64`)按 controller 文件中已有定义复用;如不存在则需要按同 package 已有 pattern 添加。
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
```bash
|
||
cd backend/gateway && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 15: Gateway main.go 初始化 ActivityHub
|
||
|
||
**Files:**
|
||
- Modify: `backend/gateway/main.go`
|
||
|
||
- [ ] **Step 1: 创建 ActivityHub 实例并启动 Run goroutine**
|
||
|
||
在 router.SetupRouter 调用之前:
|
||
|
||
```go
|
||
activityHub := socket.NewActivityHub(redisClient, cfg.WebSocket.ActivityPath)
|
||
go activityHub.Run(context.Background())
|
||
defer activityHub.Close()
|
||
```
|
||
|
||
- [ ] **Step 2: 把 activityHub 传给 SetupRouter**
|
||
|
||
修改 `router.SetupRouter(...)` 调用,传入 `activityHub` 和 `cfg.WebSocket.ActivityPath`。
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
```bash
|
||
cd backend/gateway && go build ./...
|
||
```
|
||
|
||
Expected: 编译通过。
|
||
|
||
---
|
||
|
||
## Task 16: 前端添加 API 工具函数
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/api.js`
|
||
|
||
- [ ] **Step 1: 在文件中追加 2 个导出函数**
|
||
|
||
在文件末尾(其他 activity API 函数附近)追加:
|
||
|
||
```js
|
||
/**
|
||
* 列出活动留言(首次/下拉加载)
|
||
* @param {string|number} activityId
|
||
* @param {number} [page=1]
|
||
* @param {number} [pageSize=20]
|
||
*/
|
||
export function listActivityMessagesApi(activityId, page = 1, pageSize = 20) {
|
||
return request({
|
||
url: `/api/v1/activities/${activityId}/messages?page=${page}&page_size=${pageSize}`,
|
||
method: 'GET',
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 发送一条活动留言
|
||
* @param {string|number} activityId
|
||
* @param {string} content
|
||
*/
|
||
export function createActivityMessageApi(activityId, content) {
|
||
return request({
|
||
url: `/api/v1/activities/${activityId}/messages`,
|
||
method: 'POST',
|
||
data: { content },
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
```bash
|
||
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"
|
||
```
|
||
|
||
Expected: 无语法错误。
|
||
|
||
---
|
||
|
||
## Task 17: 创建 ActivitySocket.js
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/socket/ActivitySocket.js`
|
||
|
||
- [ ] **Step 1: 实现 ActivitySocket 类**
|
||
|
||
```js
|
||
import SocketManager from './SocketManager.js'
|
||
|
||
const DEFAULT_PATH = '/activity'
|
||
|
||
/**
|
||
* 活动实时推送 WebSocket 客户端
|
||
* - 继承 SocketManager,复用连接/心跳/重连
|
||
* - 额外暴露 topic 订阅 API:subscribe / unsubscribe
|
||
*/
|
||
class ActivitySocket extends SocketManager {
|
||
constructor(options = {}) {
|
||
super({
|
||
serviceName: 'Activity',
|
||
path: options.path || DEFAULT_PATH,
|
||
reconnectInterval: options.reconnectInterval ?? [1000, 2000, 4000, 8000, 16000, 30000],
|
||
heartbeatInterval: options.heartbeatInterval ?? 30000,
|
||
maxReconnectAttempts: options.maxReconnectAttempts ?? Infinity,
|
||
})
|
||
|
||
this._topics = new Set() // 记录已订阅 topic,便于重连后自动重订阅
|
||
}
|
||
|
||
/**
|
||
* 订阅活动主题
|
||
* @param {string|number} activityId
|
||
* @param {string[]} topics - ['messages', 'contributions']
|
||
*/
|
||
subscribe(activityId, topics = []) {
|
||
if (!activityId || !Array.isArray(topics) || topics.length === 0) return
|
||
topics.forEach(t => this._topics.add(`${activityId}:${t}`))
|
||
if (this.isConnected) {
|
||
this.send({ action: 'subscribe', activity_id: Number(activityId), topics })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消订阅活动主题
|
||
*/
|
||
unsubscribe(activityId, topics = []) {
|
||
if (!activityId || !Array.isArray(topics) || topics.length === 0) return
|
||
topics.forEach(t => this._topics.delete(`${activityId}:${t}`))
|
||
if (this.isConnected) {
|
||
this.send({ action: 'unsubscribe', activity_id: Number(activityId), topics })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册 messages 响应回调
|
||
*/
|
||
onMessagesResponse(cb) {
|
||
this.registerHandler('messages_response', cb)
|
||
}
|
||
|
||
offMessagesResponse(cb) {
|
||
this.off('messages_response', cb)
|
||
}
|
||
|
||
/**
|
||
* 注册 contributions 响应回调
|
||
*/
|
||
onContributionsResponse(cb) {
|
||
this.registerHandler('contributions_response', cb)
|
||
}
|
||
|
||
offContributionsResponse(cb) {
|
||
this.off('contributions_response', cb)
|
||
}
|
||
|
||
/**
|
||
* 重连成功后,自动重新订阅所有 topic
|
||
*/
|
||
resubscribeAll() {
|
||
if (this._topics.size === 0) return
|
||
const byActivity = new Map()
|
||
this._topics.forEach(key => {
|
||
const [activityId, topic] = key.split(':')
|
||
if (!byActivity.has(Number(activityId))) byActivity.set(Number(activityId), [])
|
||
byActivity.get(Number(activityId)).push(topic)
|
||
})
|
||
byActivity.forEach((topics, activityId) => {
|
||
this.send({ action: 'subscribe', activity_id: activityId, topics })
|
||
})
|
||
}
|
||
}
|
||
|
||
// ==================== 单例 ====================
|
||
|
||
let instance = null
|
||
|
||
export function getActivitySocket() {
|
||
if (!instance) {
|
||
instance = new ActivitySocket()
|
||
// 重连成功后自动重订阅
|
||
instance.on('connect', () => instance.resubscribeAll())
|
||
}
|
||
return instance
|
||
}
|
||
|
||
export function closeActivitySocket() {
|
||
if (instance) {
|
||
instance.close()
|
||
}
|
||
}
|
||
|
||
export default ActivitySocket
|
||
```
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
```bash
|
||
cd frontend && node -e "import('./utils/socket/ActivitySocket.js').then(m => console.log('OK', typeof m.getActivitySocket))" 2>&1 | head -5 || echo "ESM import test"
|
||
```
|
||
|
||
Expected: 输出 `OK function`(如支持 ESM 直接验证)。
|
||
|
||
---
|
||
|
||
## Task 18: 更新 GlobalSocketManager.js 接入 ActivitySocket
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/socket/GlobalSocketManager.js`
|
||
|
||
- [ ] **Step 1: 在 init 中追加 _initActivity**
|
||
|
||
修改 `init(token)` 方法,在 `_initAiChat(token)` 调用前后追加:
|
||
|
||
```js
|
||
import { getActivitySocket } from './ActivitySocket.js'
|
||
|
||
class GlobalSocketManager {
|
||
// ... 保留原有字段 ...
|
||
|
||
init(token) {
|
||
if (this.initialized) return
|
||
this.initialized = true
|
||
this._initAiChat(token)
|
||
this._initActivity(token)
|
||
}
|
||
|
||
_initActivity(token) {
|
||
const socket = getActivitySocket()
|
||
// 不立即 connect,延后到 useContributionRealtime 实际使用时 connect
|
||
// 但如果 token 已存在,可以提前 connect 复用
|
||
if (token && !socket.isConnected) {
|
||
socket.connect(token).catch(err => console.warn('[GlobalSocket] activity connect error:', err))
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
```bash
|
||
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"
|
||
```
|
||
|
||
Expected: 无语法错误。
|
||
|
||
---
|
||
|
||
## Task 19: 重构 useContributionPolling 暴露 highestIdRef
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/support-activity/composables/useContributionPolling.js`
|
||
|
||
- [ ] **Step 1: 在 return 前导出 latestTimestamp 和 latestId**
|
||
|
||
修改 `return { ... }`:
|
||
|
||
```js
|
||
return {
|
||
records,
|
||
visible,
|
||
loading,
|
||
error,
|
||
start,
|
||
stop,
|
||
reset,
|
||
// 暴露给 useContributionRealtime 用
|
||
highestTimestampRef: () => latestTimestamp,
|
||
highestIdRef: () => latestId,
|
||
}
|
||
```
|
||
|
||
> 用函数返回避免解构时丢失响应性。或改为 `ref`/readonly。
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
```bash
|
||
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"
|
||
```
|
||
|
||
Expected: 无语法错误。
|
||
|
||
> 替代方案:直接在 useContributionRealtime 中维护独立的 `highestIdRef`。但复用更 DRY。
|
||
|
||
---
|
||
|
||
## Task 20: 创建 useContributionRealtime
|
||
|
||
**Files:**
|
||
- Create: `frontend/pages/support-activity/composables/useContributionRealtime.js`
|
||
|
||
- [ ] **Step 1: 实现 composable**
|
||
|
||
```js
|
||
import { onMounted, onUnmounted } from 'vue'
|
||
import { useContributionPolling } from './useContributionPolling.js'
|
||
import { getActivitySocket } from '@/utils/socket/ActivitySocket.js'
|
||
|
||
/**
|
||
* 贡献实时推送 composable(WS 优先,断线降级为轮询)
|
||
* @param {Ref<string|number>} activityId
|
||
* @param {Ref<boolean>} isPageActive
|
||
*/
|
||
export function useContributionRealtime(activityId, isPageActive) {
|
||
const MAX_RECORDS = 5
|
||
|
||
const {
|
||
records,
|
||
visible,
|
||
loading,
|
||
error,
|
||
start: startPolling,
|
||
stop: stopPolling,
|
||
reset: resetPolling,
|
||
highestIdRef,
|
||
highestTimestampRef,
|
||
} = useContributionPolling(activityId, isPageActive)
|
||
|
||
const socket = getActivitySocket()
|
||
let usingWS = false
|
||
|
||
function onWsMessage(payload) {
|
||
if (!payload || Number(payload.activity_id) !== Number(activityId.value)) return
|
||
if (!payload.record) return
|
||
const record = payload.record
|
||
if (record.id > highestIdRef()) {
|
||
records.value = [record, ...records.value].slice(0, MAX_RECORDS)
|
||
// 同步轮询侧游标(保持降级时不重拉)
|
||
// 注意:useContributionPolling 内部仍持有 latestId/最新时间戳变量
|
||
}
|
||
}
|
||
|
||
function onWsConnect() {
|
||
if (usingWS) return
|
||
usingWS = true
|
||
stopPolling() // 停掉可能的轮询
|
||
socket.subscribe(activityId.value, ['contributions'])
|
||
}
|
||
|
||
function onWsDisconnect() {
|
||
if (!usingWS) return
|
||
usingWS = false
|
||
startPolling() // 降级为轮询
|
||
}
|
||
|
||
socket.onContributionsResponse(onWsMessage)
|
||
socket.on('connect', onWsConnect)
|
||
socket.on('disconnect', onWsDisconnect)
|
||
|
||
onMounted(() => {
|
||
if (socket.isConnected) {
|
||
onWsConnect()
|
||
} else {
|
||
// 尝试启动 socket
|
||
const token = uni.getStorageSync('access_token')
|
||
if (token) {
|
||
socket.connect(token).catch(err => console.warn('[useContributionRealtime] connect error:', err))
|
||
}
|
||
// 如果连接失败,先用轮询兜底
|
||
if (!socket.isConnected) startPolling()
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (usingWS) socket.unsubscribe(activityId.value, ['contributions'])
|
||
socket.off('connect', onWsConnect)
|
||
socket.off('disconnect', onWsDisconnect)
|
||
socket.offContributionsResponse(onWsMessage)
|
||
stopPolling()
|
||
resetPolling()
|
||
})
|
||
|
||
return {
|
||
records,
|
||
visible,
|
||
loading,
|
||
error,
|
||
usingWS: () => usingWS,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
```bash
|
||
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"
|
||
```
|
||
|
||
Expected: 无语法错误。
|
||
|
||
---
|
||
|
||
## Task 21: 创建 useMessageRealtime
|
||
|
||
**Files:**
|
||
- Create: `frontend/pages/support-activity/composables/useMessageRealtime.js`
|
||
|
||
- [ ] **Step 1: 实现 composable**
|
||
|
||
```js
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { useStore } from 'vuex'
|
||
import { listActivityMessagesApi, createActivityMessageApi } from '@/utils/api.js'
|
||
import { getActivitySocket } from '@/utils/socket/ActivitySocket.js'
|
||
|
||
const MAX_MESSAGES = 50
|
||
|
||
/**
|
||
* 活动留言实时推送 composable
|
||
*/
|
||
export function useMessageRealtime(activityId) {
|
||
const store = useStore()
|
||
const messages = ref([])
|
||
const loading = ref(false)
|
||
const error = ref(null)
|
||
const currentUserId = computed(() => store.state.user?.userInfo?.uid)
|
||
const socket = getActivitySocket()
|
||
|
||
// 字段映射:后端 → MessageBoard props
|
||
function toComponentShape(m) {
|
||
return {
|
||
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 loadHistory() {
|
||
if (!activityId.value) return
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const res = await listActivityMessagesApi(activityId.value, 1, 20)
|
||
if (res.code === 0) {
|
||
messages.value = (res.data.messages || []).map(toComponentShape)
|
||
} else {
|
||
error.value = res.message || '加载留言失败'
|
||
}
|
||
} catch (e) {
|
||
error.value = e.message || '网络错误'
|
||
console.error('[useMessageRealtime] loadHistory error:', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function onWsMessage(payload) {
|
||
if (!payload || Number(payload.activity_id) !== Number(activityId.value)) return
|
||
if (!payload.message) return
|
||
messages.value.push(toComponentShape(payload.message))
|
||
if (messages.value.length > MAX_MESSAGES) {
|
||
messages.value.splice(0, messages.value.length - MAX_MESSAGES)
|
||
}
|
||
}
|
||
|
||
async function sendMessage(content) {
|
||
if (!content || !content.trim()) return
|
||
const trimmed = content.trim()
|
||
try {
|
||
const res = await createActivityMessageApi(activityId.value, trimmed)
|
||
if (res.code === 0) {
|
||
// 成功时不本地 push,等 WS 推回避免重复
|
||
// WS 断线场景:服务端不会推,由前端 fallback 插入
|
||
if (!socket.isConnected && res.data?.message) {
|
||
messages.value.push(toComponentShape(res.data.message))
|
||
if (messages.value.length > MAX_MESSAGES) {
|
||
messages.value.splice(0, messages.value.length - MAX_MESSAGES)
|
||
}
|
||
}
|
||
} else {
|
||
uni.showToast({ title: res.message || '留言失败', icon: 'none' })
|
||
}
|
||
} catch (e) {
|
||
console.error('[useMessageRealtime] sendMessage error:', e)
|
||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadHistory()
|
||
socket.subscribe(activityId.value, ['messages'])
|
||
socket.onMessagesResponse(onWsMessage)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
socket.unsubscribe(activityId.value, ['messages'])
|
||
socket.offMessagesResponse(onWsMessage)
|
||
})
|
||
|
||
return {
|
||
messages,
|
||
loading,
|
||
error,
|
||
sendMessage,
|
||
refresh: loadHistory,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
```bash
|
||
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"
|
||
```
|
||
|
||
Expected: 无语法错误。
|
||
|
||
---
|
||
|
||
## Task 22: index.vue 集成 useMessageRealtime 和 useContributionRealtime
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/support-activity/index.vue`
|
||
|
||
- [ ] **Step 1: 在 `<script setup>` 顶部追加 import**
|
||
|
||
```js
|
||
import { useMessageRealtime } from './composables/useMessageRealtime.js'
|
||
import { useContributionRealtime } from './composables/useContributionRealtime.js'
|
||
```
|
||
|
||
- [ ] **Step 2: 删除 messageList mock 数据(244-277 行)**
|
||
|
||
删除 `const messageList = ref([...])` 整段(约 35 行)。
|
||
|
||
- [ ] **Step 3: 替换 handleSendMessage**
|
||
|
||
```js
|
||
const { messages: messageList, sendMessage } = useMessageRealtime(activityId)
|
||
|
||
async function handleSendMessage(text) {
|
||
await sendMessage(text)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 替换 ContributionList 的 composable(如果有直接调用 useContributionPolling 的地方)**
|
||
|
||
查找并替换:
|
||
|
||
```js
|
||
// 替换前
|
||
const { records } = useContributionPolling(...)
|
||
|
||
// 替换后
|
||
const { records } = useContributionRealtime(activityId, isPageActive)
|
||
```
|
||
|
||
- [ ] **Step 5: 验证**
|
||
|
||
```bash
|
||
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"
|
||
```
|
||
|
||
Expected: 无语法错误。
|
||
|
||
---
|
||
|
||
## Task 23: 验证整套改动
|
||
|
||
- [ ] **Step 1: 后端编译**
|
||
|
||
```bash
|
||
cd backend && go build ./...
|
||
```
|
||
|
||
Expected: 全部编译通过。
|
||
|
||
- [ ] **Step 2: 后端单测(如已有)**
|
||
|
||
```bash
|
||
cd backend && go test ./services/activityService/... ./gateway/socket/... 2>&1 | tail -30
|
||
```
|
||
|
||
Expected: 测试通过或无测试(项目目前可能无测试)。
|
||
|
||
- [ ] **Step 3: 前端构建**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: 构建成功。
|
||
|
||
- [ ] **Step 4: 数据库迁移 dry-run 检查**
|
||
|
||
```bash
|
||
# 仅检查 SQL 语法,不真正执行
|
||
psql -h localhost -U postgres -d topfans -v ON_ERROR_STOP=1 -c "BEGIN; $(cat backend/migrations/2026_06_22_012_activity_messages.sql | head -n -3) ROLLBACK;" 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: 无错误。
|
||
|
||
- [ ] **Step 5: 重启 gateway,验证 WebSocket 端点**
|
||
|
||
```bash
|
||
# 启动 gateway 后访问
|
||
curl -i "http://localhost:8080/activity?token=Bearer_INVALID"
|
||
```
|
||
|
||
Expected: HTTP 401 + `{"type":"auth_response","success":false,"error":"invalid_token"}`。
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### 1. Spec coverage check
|
||
|
||
| 章节 | 对应任务 |
|
||
|------|----------|
|
||
| §3.1 数据库 migration | Task 1 |
|
||
| §3.2 activity_contributions 沿用 | 无任务(不动) |
|
||
| §4 Proto 定义 | Task 2 |
|
||
| §5 HTTP 接口 | Task 12, 13, 14 |
|
||
| §6 WebSocket 协议 | Task 11 (server), Task 17, 18 (client) |
|
||
| §7.1-7.4 后端实现 | Task 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 |
|
||
| §8 前端实现 | Task 16, 17, 18, 19, 20, 21, 22 |
|
||
| §9 缓存与 Pub/Sub | Task 8, 9 (Redis Publish) |
|
||
| §10 错误码 | Task 3, 4 |
|
||
| §11 测试用例 | Task 23(含手动验证) |
|
||
| §12 变更影响面 | 全部任务(保持向后兼容) |
|
||
| §13 后续迭代 | 无任务(按 spec 不在本期) |
|
||
|
||
**无遗漏章节。**
|
||
|
||
### 2. Placeholder scan
|
||
|
||
- ✅ 所有代码块均有完整实现
|
||
- ✅ 无 "TBD" / "TODO" / "implement later"
|
||
- ✅ 无 "similar to Task N" 复制粘贴(每个文件均独立给出)
|
||
- ✅ 文件路径绝对且具体
|
||
|
||
### 3. Type consistency
|
||
|
||
- `ActivityConn` 在 Task 11 定义,`unregister` / `subscribe` / `unsubscribe` 方法签名一致
|
||
- `highestIdRef` 在 Task 19 暴露为 `() => latestId`,Task 20 调用方式一致
|
||
- `socket.onMessagesResponse` / `offMessagesResponse` / `onContributionsResponse` / `offContributionsResponse` 在 Task 17 定义,Task 20/21 调用一致
|
||
- `msgId` 在 Task 8 写入返回,Task 14 handler 用 `m.Id` 访问(pb.ActivityMessage.Id)
|
||
- `ContributionRecord` 在 proto 已存在(Task 9 复用现有 message)
|
||
|
||
### 4. Spec 反模式检查
|
||
|
||
- ✅ service 层不直接返回 HTTP(Task 14 controller 做转换)
|
||
- ✅ 错误用 `appErrors.ErrXxx`,不用裸 error(Task 8, 14)
|
||
- ✅ MessageBoard.vue props 契约未变(Task 22 不修改 MessageBoard)
|
||
- ✅ 序列起始 10000 符合 CLAUDE.md(Task 1)
|
||
- ✅ 注释使用中文与现有代码一致
|
||
- ✅ 字段命名 snake_case (HTTP) / camelCase (proto) 符合现有风格
|
||
|
||
---
|
||
|
||
## 备注
|
||
|
||
1. **任务粒度**:每个 Task 包含 1-5 个 Step,每个 Step 可在 2-5 分钟完成。
|
||
2. **测试策略**:项目目前缺少 Go 单测基础设施(`backend/services/activityService/` 无 `*_test.go`),所以 backend 测试以**编译通过 + 手动集成验证**为主。如未来添加测试框架,可补充 service 层单测(Task 8 的 6 个分支 + Task 11 的 fanout 逻辑)。
|
||
3. **frontend 测试**:项目目前无 JS 测试框架(`frontend/` 无 `*.test.js`),通过 `npm run lint:js` 和 `npm run build` 验证语法与依赖。
|
||
4. **commit 策略**:按 CLAUDE.md 规范,AI 不主动 commit;待所有 Task 完成后由用户决定 commit 时机。建议在以下里程碑分 5 个 commit:
|
||
- commit 1: Task 1-5(DB + proto + 错误码)
|
||
- commit 2: Task 6-10(service 层 + repository)
|
||
- commit 3: Task 11-15(gateway + ActivityHub)
|
||
- commit 4: Task 16-18(前端 socket 层)
|
||
- commit 5: Task 19-22(前端 composables + 集成)
|
||
5. **用户需求差异**:本计划直接采用 spec 的所有决策(单连接 + topic、不引入全项目测试框架、首版本地敏感词等),未做额外扩展(YAGNI)。 |