topfans/docs/superpowers/plans/2026-06-22-activity-realtime-websocket.md

2125 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 活动实时推送(留言 + 贡献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 PublishGateway 新增 `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 验证 tokenJWT
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 的 patternDubbo 注入、参数转换、错误码映射):
```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 订阅 APIsubscribe / 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'
/**
* 贡献实时推送 composableWS 优先,断线降级为轮询)
* @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 层不直接返回 HTTPTask 14 controller 做转换)
- ✅ 错误用 `appErrors.ErrXxx`,不用裸 errorTask 8, 14
- ✅ MessageBoard.vue props 契约未变Task 22 不修改 MessageBoard
- ✅ 序列起始 10000 符合 CLAUDE.mdTask 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-5DB + proto + 错误码)
- commit 2: Task 6-10service 层 + repository
- commit 3: Task 11-15gateway + ActivityHub
- commit 4: Task 16-18前端 socket 层)
- commit 5: Task 19-22前端 composables + 集成)
5. **用户需求差异**:本计划直接采用 spec 的所有决策(单连接 + topic、不引入全项目测试框架、首版本地敏感词等未做额外扩展YAGNI