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

59 KiB
Raw Blame History

活动实时推送(留言 + 贡献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: 创建迁移文件

-- 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: 暂存迁移文件
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 块之前)追加:

// ============== 留言相关消息 ==============

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 之后、} 之前)追加:

  // 列出活动留言
  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 编译
cd backend && make proto

Expected: 生成/更新 backend/pkg/proto/activity/activity.pb.go,包含新的 RPC 接口。

  • Step 4: 暂存修改
git add backend/proto/activity.proto backend/pkg/proto/activity/

Task 3: 错误码扩展

Files:

  • Modify: backend/pkg/errors/errors.go

  • Step 1: 在 "活动服务相关错误" 块下方追加 7 个错误变量

在现有 ErrActivityNotFound/ErrActivityItemNotFound 之后追加:

	// 活动留言相关错误
	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 末尾追加:

	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: 编译验证
cd backend/pkg && go build ./...

Expected: 编译通过。


Task 4: Response 中文错误映射

Files:

  • Modify: backend/gateway/pkg/response/response.go

  • Step 1: 在 errorMap 中追加留言相关中文映射

errorMap map 字面量末尾追加(保持与 errors.go 一致):

		"活动留言不存在":                       "活动留言不存在",
		"留言太频繁":                          "留言太频繁,请稍后再试",
		"当前活动留言已达上限":                  "当前活动留言已达上限",
		"留言内容不能为空":                    "留言内容不能为空",
		"留言内容过长":                        "留言内容过长最多500字",
		"留言内容包含不当内容":                "留言内容包含不当内容,请修改",
		"活动不在进行中":                      "活动未开始或已结束",
  • Step 2: 编译验证
cd backend/gateway && go build ./...

Expected: 编译通过。


Task 5: ActivityMessage Model

Files:

  • Create: backend/pkg/models/activity_message.go

  • Step 1: 创建 Model

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: 编译验证
cd backend/pkg && go build ./...

Expected: 编译通过。


Task 6: Repository 层

Files:

  • Create: backend/services/activityService/repository/activity_messages_repository.go

  • Step 1: 创建 Repository

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: 编译验证
cd backend/services/activityService && go build ./...

Expected: 编译通过。


Task 7: activityService config 文件

Files:

  • Create: backend/services/activityService/config/config.go

  • Step 1: 创建 config 包

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: 编译验证
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 末尾追加:

	// 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 定义:

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 函数末尾追加:

		messagesRepo: repository.NewActivityMessagesRepository(),
		messageCfg:   config.LoadMessageConfig(),

(保留原有 activityRepo/mintingActivityRepo/userRPCClient/redisClient 不变)

  • Step 4: 在 service 文件末尾追加 CreateActivityMessage 实现
// 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 块,确保包含:

	"encoding/json"
	"fmt"
	"strings"
	"time"
	"unicode/utf8"
  • Step 6: 编译验证
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 之前追加:

	// 推送 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 函数体内已有定义为准(如 itemcomboCountnow 等)。

  • Step 2: 在 BatchPurchaseItem 末尾对每个成功 item Publish

类似 Step 1在 BatchPurchaseItem 中每次 CreateContribution 成功后追加 Publish。

  • Step 3: 编译验证
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

// 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: 编译验证
cd backend/services/activityService && go build ./...

Expected: 编译通过。


Task 11: ActivityHub WebSocket 实现

Files:

  • Create: backend/gateway/socket/activity_socket.go

  • Step 1: 创建 ActivityHub

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: 编译验证
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 字段

type WebSocketConfig struct {
	AIChatPath   string // WebSocket 路径,默认 /ai-chat
	ActivityPath string // 活动实时推送 WS 路径,默认 /activity
}
  • Step 2: 在 Load() 中追加 ActivityPath 默认值
WebSocket: WebSocketConfig{
	AIChatPath:   getEnv("WS_AI_CHAT_PATH", "/ai-chat"),
	ActivityPath: getEnv("WS_ACTIVITY_PATH", "/activity"),
},
  • Step 3: 编译验证
cd backend/gateway && go build ./...

Expected: 编译通过。


Task 13: Router 注册 ActivityHub 路由和 HTTP 路由

Files:

  • Modify: backend/gateway/router/router.go

  • Step 1: 修改 SetupRouter 签名追加 activityHub 与 activityPath 参数

SetupRouter 函数签名末尾追加:

activityHub *socket.ActivityHub,
activityPath string,
  • Step 2: 在 AI Chat WS 路由注册代码块后追加 Activity WS 路由
// 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", ...) 之后追加:

activities.GET("/:id/messages", activityCtrl.ListActivityMessages)
activities.POST("/:id/messages", activityCtrl.CreateActivityMessage)
  • Step 4: 编译验证
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 注入、参数转换、错误码映射):

// 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,
		},
	})
}

具体 helperbuildActivityCtxgrpcCodeToHTTPtoInt64)按 controller 文件中已有定义复用;如不存在则需要按同 package 已有 pattern 添加。

  • Step 2: 编译验证
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 调用之前:

activityHub := socket.NewActivityHub(redisClient, cfg.WebSocket.ActivityPath)
go activityHub.Run(context.Background())
defer activityHub.Close()
  • Step 2: 把 activityHub 传给 SetupRouter

修改 router.SetupRouter(...) 调用,传入 activityHubcfg.WebSocket.ActivityPath

  • Step 3: 编译验证
cd backend/gateway && go build ./...

Expected: 编译通过。


Task 16: 前端添加 API 工具函数

Files:

  • Modify: frontend/utils/api.js

  • Step 1: 在文件中追加 2 个导出函数

在文件末尾(其他 activity API 函数附近)追加:

/**
 * 列出活动留言(首次/下拉加载)
 * @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: 验证
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 类

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: 验证
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) 调用前后追加:

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: 验证
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 { ... }

  return {
    records,
    visible,
    loading,
    error,
    start,
    stop,
    reset,
    // 暴露给 useContributionRealtime 用
    highestTimestampRef: () => latestTimestamp,
    highestIdRef: () => latestId,
  }

用函数返回避免解构时丢失响应性。或改为 ref/readonly。

  • Step 2: 验证
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

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: 验证
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

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: 验证
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

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
const { messages: messageList, sendMessage } = useMessageRealtime(activityId)

async function handleSendMessage(text) {
  await sendMessage(text)
}
  • Step 4: 替换 ContributionList 的 composable如果有直接调用 useContributionPolling 的地方)

查找并替换:

// 替换前
const { records } = useContributionPolling(...)

// 替换后
const { records } = useContributionRealtime(activityId, isPageActive)
  • Step 5: 验证
cd frontend && npm run lint:js 2>&1 | head -20 || echo "lint not configured"

Expected: 无语法错误。


Task 23: 验证整套改动

  • Step 1: 后端编译
cd backend && go build ./...

Expected: 全部编译通过。

  • Step 2: 后端单测(如已有)
cd backend && go test ./services/activityService/... ./gateway/socket/... 2>&1 | tail -30

Expected: 测试通过或无测试(项目目前可能无测试)。

  • Step 3: 前端构建
cd frontend && npm run build 2>&1 | tail -20

Expected: 构建成功。

  • Step 4: 数据库迁移 dry-run 检查
# 仅检查 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 端点
# 启动 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 暴露为 () => latestIdTask 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:jsnpm 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