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