# 用户贡献连击聚合推送 — 实施计划 > **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:** 同用户 3 秒内连续送同一道具 N 次时,后端只推送 1 条合并 record,前端只显示 1 条 UI 项,数量 = N。 **Architecture:** 后端将 Redis `combo:{userID}:{itemType}` 计数器改造为 Redis Stream 延迟队列 + 聚合 Hash,worker 协程 3 秒后从 Stream 取出条目、读聚合 Hash、Publish 1 条合并 record。`GetLatestContributions` 在 SQL 后做内存合并。前端 WS 与轮询两条路径共用 `mergeComboRecords` 工具函数。 **Tech Stack:** Go 1.21+ (go-redis/v9 Stream API), Vue 3 Composition API, uni-app, Jest (前端单测)/ stretchr/testify (后端单测) **Working Branch:** `feat/actvity` (当前分支,无需切换) **Spec:** `docs/superpowers/specs/2026-06-23-contribution-combo-aggregation-design.md` --- ## File Structure ### 后端 (Go) | 文件 | 责任 | |---|---| | `backend/services/activityService/service/combo_worker.go` (新建) | Stream 消费者 goroutine,负责 3 秒窗口结束后从 Stream 取出条目 + 读聚合 Hash + Publish 合并 record | | `backend/services/activityService/service/activity_service.go` (修改) | 改造 `PurchaseItem` / `BatchPurchaseItem`:用 agg Hash + XAdd 替代 `incrementComboCount` + 立即 Publish;改造 `GetLatestContributions`:SQL 后做内存合并;删除 `incrementComboCount` / `getComboCount`;保留 `comboKey` (供测试或其他辅助调用) | | `backend/services/activityService/main.go` (修改) | 在 `NewActivityService` 之后启动 combo worker goroutine | ### 前端 (Vue / uni-app) | 文件 | 责任 | |---|---| | `frontend/pages/support-activity/composables/useContributionRealtime.js` (修改) | 导出 `mergeComboRecords` 工具函数;`onWsMessage` 调用合并 | | `frontend/pages/support-activity/composables/useContributionPolling.js` (修改) | 在 `fetchLatest` 合并新记录后调用 `mergeComboRecords` | | `frontend/pages/support-activity/components/ContributionList.vue` (核对/微调) | 模板已用 `record.quantity`(spec §5.4 "Before" 与现状不一致);核对后若已正确则跳过,若仍用 `combo_count` 则改为 `quantity` | ### 测试 | 文件 | 责任 | |---|---| | `backend/services/activityService/service/combo_worker_test.go` (新建) | `mergeComboAggHash` / `processComboEntry` 单元测试 | | `backend/services/activityService/service/activity_service_combo_test.go` (新建) | `PurchaseItem` Redis 不可用降级 / `GetLatestContributions` 内存合并单元测试 | | `frontend/pages/support-activity/composables/useContributionRealtime.test.js` (新建,可选) | `mergeComboRecords` 工具函数单测 | --- ## 任务清单 ### Task 1: 后端 — 删除旧的 `incrementComboCount` 与 `getComboCount`,新增聚合键生成函数 **Files:** - Modify: `backend/services/activityService/service/activity_service.go:111-145` - [ ] **Step 1.1: 阅读并确认当前 combo 代码位置** ```bash grep -n "incrementComboCount\|getComboCount\|comboKey" \ backend/services/activityService/service/activity_service.go ``` 预期输出应包含: - 第 111 行: `comboKey` 函数 - 第 118 行: `incrementComboCount` - 第 135 行: `getComboCount` - [ ] **Step 1.2: 删除 `incrementComboCount` 与 `getComboCount`** 打开 `backend/services/activityService/service/activity_service.go`,定位到第 116-145 行(即两个 `// ...` 函数体),删除 **整个函数块** 但保留 `comboKey`(供测试/调试用)。删除后文件中应不再出现 `incrementComboCount` 或 `getComboCount` 字样。 - [ ] **Step 1.3: 新增 `aggKey` 与 `aggLockKey` 键生成函数** 在 `comboKey` 函数下方(第 114 行 `}` 之后)新增: ```go // aggKey 生成聚合 Hash Redis Key(窗口内累加 quantity 等字段) func (s *activityService) aggKey(userID int64, itemType string) string { return fmt.Sprintf("combo:agg:%d:%s", userID, itemType) } // aggLockKey 生成"是否已写入 Stream"防重锁 Key(SET NX EX 3s) func (s *activityService) aggLockKey(userID int64, itemType string) string { return fmt.Sprintf("combo:agg:lock:%d:%s", userID, itemType) } ``` - [ ] **Step 1.4: 验证编译** ```bash cd backend && go build ./services/activityService/... ``` 预期: BUILD OK。注意此时 `PurchaseItem`/`BatchPurchaseItem`/`GetLatestContributions` 还在调用已被删除的 `incrementComboCount`/`getComboCount`,**编译会失败** —— 这是预期的,Task 2/3/4 会修复。 - [ ] **Step 1.5: 暂不 commit** 继续 Task 2。 --- ### Task 2: 后端 — 新增 `combo_worker.go`(Stream 消费者 + 聚合辅助函数) **Files:** - Create: `backend/services/activityService/service/combo_worker.go` - [ ] **Step 2.1: 创建 `combo_worker.go` 文件骨架** 新建 `backend/services/activityService/service/combo_worker.go`,内容: ```go package service import ( "context" "encoding/json" "fmt" "strconv" "time" "github.com/redis/go-redis/v9" "github.com/topfans/backend/pkg/logger" pb "github.com/topfans/backend/pkg/proto/activity" "go.uber.org/zap" ) const ( comboStreamKey = "combo:stream:contributions" comboConsumerGrp = "combo-publishers" comboAggTTL = 5 * time.Second comboWindowTTL = 3 * time.Second comboStreamMaxLen = 100000 ) // StartComboStreamWorker 启动 Stream 消费者 goroutine // 主进程启动后调用一次;通过 ctx 取消关闭 func (s *activityService) StartComboStreamWorker(ctx context.Context) { if s.redisClient == nil { logger.Logger.Warn("combo worker skipped: redisClient is nil") return } consumerName := fmt.Sprintf("worker-%d", time.Now().UnixNano()%100000) // 创建消费者组(已存在则忽略 BUSYGROUP) _ = s.redisClient.XGroupCreateMkStream(ctx, comboStreamKey, comboConsumerGrp, "0").Err() go func() { for { select { case <-ctx.Done(): logger.Logger.Info("combo worker stopped") return default: } streams, err := s.redisClient.XReadGroup(ctx, &redis.XReadGroupArgs{ Group: comboConsumerGrp, Consumer: consumerName, Streams: []string{comboStreamKey, ">"}, Count: 10, Block: 1 * time.Second, }).Result() if err == redis.Nil { continue } if err != nil { if ctx.Err() != nil { return } logger.Logger.Warn("combo XReadGroup error", zap.Error(err)) time.Sleep(time.Second) continue } for _, stream := range streams { for _, msg := range stream.Messages { s.processComboEntry(ctx, msg) s.redisClient.XAck(ctx, comboStreamKey, comboConsumerGrp, msg.ID) } } } }() } // processComboEntry 处理一条 Stream 条目: // - 等到 expire_at_ms 才推送(保证窗口结束) // - 读取 agg Hash 推送合并 record // - 推送后清理 agg + lock Key func (s *activityService) processComboEntry(ctx context.Context, msg redis.XMessage) { expireAtRaw, ok := msg.Values["expire_at_ms"] if !ok { return } expireAtStr, ok := expireAtRaw.(string) if !ok { return } expireAt, err := strconv.ParseInt(expireAtStr, 10, 64) if err != nil { return } now := time.Now().UnixMilli() if now < expireAt { select { case <-ctx.Done(): return case <-time.After(time.Duration(expireAt-now) * time.Millisecond): } } userIDStr, _ := msg.Values["user_id"].(string) activityIDStr, _ := msg.Values["activity_id"].(string) itemType, _ := msg.Values["item_type"].(string) userID, _ := strconv.ParseInt(userIDStr, 10, 64) activityID, _ := strconv.ParseInt(activityIDStr, 10, 64) if userID == 0 || activityID == 0 || itemType == "" { return } aggKey := s.aggKey(userID, itemType) lockKey := s.aggLockKey(userID, itemType) fields, err := s.redisClient.HGetAll(ctx, aggKey).Result() if err != nil || len(fields) == 0 { return } record := buildComboRecord(fields, userID, activityID, itemType) if record == nil { return } payload, _ := json.Marshal(map[string]interface{}{ "activity_id": activityID, "type": "contributions_response", "record": record, }) channel := fmt.Sprintf("act:%d:contributions", activityID) if err := s.redisClient.Publish(ctx, channel, payload).Err(); err != nil { logger.Logger.Warn("combo Publish failed", zap.Error(err)) return // 不删 hash/lock,等下次重试 } s.redisClient.Del(ctx, aggKey, lockKey) } // buildComboRecord 把 agg Hash 字段组装成 ContributionRecord // 抽成独立函数便于单测;quantity 不解析成功时返回 nil func buildComboRecord(fields map[string]string, userID, activityID int64, itemType string) *pb.ContributionRecord { quantity, err := strconv.ParseInt(fields["quantity"], 10, 32) if err != nil { return nil } firstID, err := strconv.ParseInt(fields["first_id"], 10, 64) if err != nil { return nil } firstCreatedAt, err := strconv.ParseInt(fields["first_created_at"], 10, 64) if err != nil { return nil } return &pb.ContributionRecord{ Id: firstID, UserId: userID, Nickname: fields["nickname"], AvatarUrl: fields["avatar_url"], StarId: parseInt64(fields["star_id"]), ItemId: parseInt64(fields["item_id"]), ItemType: itemType, ItemName: fields["item_name"], ItemIcon: fields["item_icon"], Quantity: int32(quantity), ComboCount: int32(quantity), CreatedAt: firstCreatedAt, } } func parseInt64(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64) return n } ``` - [ ] **Step 2.2: 写 `buildComboRecord` 单元测试** 新建 `backend/services/activityService/service/combo_worker_test.go`: ```go package service import ( "testing" "github.com/stretchr/testify/assert" ) func TestBuildComboRecord_Success(t *testing.T) { fields := map[string]string{ "quantity": "5", "first_id": "1234", "first_created_at": "1700000000000", "nickname": "小明", "avatar_url": "https://cdn/x.jpg", "star_id": "7", "item_id": "42", "item_name": "烟花", "item_icon": "https://cdn/y.png", } r := buildComboRecord(fields, 100, 200, "firework") assert.NotNil(t, r) assert.Equal(t, int64(1234), r.Id) assert.Equal(t, int64(100), r.UserId) assert.Equal(t, "小明", r.Nickname) assert.Equal(t, "烟花", r.ItemName) assert.Equal(t, int32(5), r.Quantity) assert.Equal(t, int32(5), r.ComboCount) assert.Equal(t, int64(7), r.StarId) assert.Equal(t, int64(42), r.ItemId) } func TestBuildComboRecord_QuantityInvalid(t *testing.T) { fields := map[string]string{ "quantity": "not-a-number", "first_id": "1", } assert.Nil(t, buildComboRecord(fields, 1, 1, "x")) } func TestBuildComboRecord_FirstIDMissing(t *testing.T) { fields := map[string]string{ "quantity": "3", } assert.Nil(t, buildComboRecord(fields, 1, 1, "x")) } ``` - [ ] **Step 2.3: 运行测试验证通过** ```bash cd backend && go test ./services/activityService/service/ -run TestBuildComboRecord -v ``` 预期: 3 tests passed - [ ] **Step 2.4: 暂不 commit** 继续 Task 3。 --- ### Task 3: 后端 — 改造 `PurchaseItem` 使用 agg Hash + XAdd,Redis 不可用时降级 **Files:** - Modify: `backend/services/activityService/service/activity_service.go:454-490` - [ ] **Step 3.1: 阅读当前 `PurchaseItem` 推送段** 定位到 `activity_service.go` 第 454-490 行。整段包含 `incrementComboCount` → `getComboCount` → `Publish` 三步,需整体替换。 - [ ] **Step 3.2: 替换为聚合 + 入队逻辑** 将第 454-490 行(从 `// 更新 Redis 连击计数器` 注释开始,到 `s.redisClient.Publish(...)` 结束)替换为: ```go // 连击聚合:同 user+itemType 在 3 秒窗口内合并为 1 条 WS 推送 // (1) 累加 agg Hash quantity (其他字段 HSetNX 保护首次信息) // (2) 仅首次入队 Stream (SET NX EX 3 防重复) // (3) Redis 不可用时降级为立即 Publish 单条 record nickname, avatarURL := "", "" if profile, _ := s.userRPCClient.GetFanProfile(userID, req.StarId); profile != nil { nickname = profile.Nickname avatarURL = profile.AvatarUrl } itemName, itemIcon := "", "" if item != nil { itemName = item.ItemName itemIcon = item.IconURL } s.enqueueComboContribution(ctx, comboEnqueueParams{ ActivityID: req.ActivityId, UserID: userID, StarID: req.StarId, ItemID: item.ID, ItemType: req.ItemType, ItemName: itemName, ItemIcon: itemIcon, Nickname: nickname, AvatarURL: avatarURL, FirstID: contribution.ID, FirstCreatedAt: contribution.CreatedAt, QuantityDelta: int64(req.Quantity), }) ``` - [ ] **Step 3.3: 新增 `comboEnqueueParams` 与 `enqueueComboContribution` 函数** 在 `activity_service.go` 文件末尾(最后一个 `}` 之前)新增: ```go // comboEnqueueParams 入队聚合贡献所需参数 type comboEnqueueParams struct { ActivityID int64 UserID int64 StarID int64 ItemID int64 ItemType string ItemName string ItemIcon string Nickname string AvatarURL string FirstID int64 FirstCreatedAt int64 QuantityDelta int64 } // enqueueComboContribution 累加聚合 Hash + 仅首次入队 Stream // - Redis 不可用 → 立即 Publish 一条单 record(降级,保留旧行为) // - quantity 用 HINCRBY 累加;其他字段 HSetNX 保护首次值不覆盖 // - 入队用 SET NX EX 3 防重复 func (s *activityService) enqueueComboContribution(ctx context.Context, p comboEnqueueParams) { if s.redisClient == nil { s.publishContributionImmediate(ctx, p) return } aggKey := s.aggKey(p.UserID, p.ItemType) lockKey := s.aggLockKey(p.UserID, p.ItemType) pipe := s.redisClient.TxPipeline() pipe.HIncrBy(ctx, aggKey, "quantity", p.QuantityDelta) pipe.HSetNX(ctx, aggKey, "activity_id", p.ActivityID) pipe.HSetNX(ctx, aggKey, "user_id", p.UserID) pipe.HSetNX(ctx, aggKey, "star_id", p.StarID) pipe.HSetNX(ctx, aggKey, "item_id", p.ItemID) pipe.HSetNX(ctx, aggKey, "item_name", p.ItemName) pipe.HSetNX(ctx, aggKey, "item_icon", p.ItemIcon) pipe.HSetNX(ctx, aggKey, "nickname", p.Nickname) pipe.HSetNX(ctx, aggKey, "avatar_url", p.AvatarURL) pipe.HSetNX(ctx, aggKey, "first_id", p.FirstID) pipe.HSetNX(ctx, aggKey, "first_created_at", p.FirstCreatedAt) pipe.Expire(ctx, aggKey, comboAggTTL) setNX, _ := s.redisClient.SetNX(ctx, lockKey, "1", comboWindowTTL).Result() if setNX { pipe.XAdd(ctx, &redis.XAddArgs{ Stream: comboStreamKey, MaxLen: comboStreamMaxLen, Approx: true, Values: map[string]interface{}{ "activity_id": p.ActivityID, "user_id": p.UserID, "item_type": p.ItemType, "first_id": p.FirstID, "first_created_at": p.FirstCreatedAt, "expire_at_ms": time.Now().Add(comboWindowTTL).UnixMilli(), }, }) } if _, err := pipe.Exec(ctx); err != nil { logger.Logger.Warn("combo pipeline failed, fallback to immediate publish", zap.Error(err)) s.publishContributionImmediate(ctx, p) } } // publishContributionImmediate 立即 Publish 一条单 record(降级路径) func (s *activityService) publishContributionImmediate(ctx context.Context, p comboEnqueueParams) { if s.redisClient == nil { return } record := &pb.ContributionRecord{ Id: p.FirstID, UserId: p.UserID, Nickname: p.Nickname, AvatarUrl: p.AvatarURL, StarId: p.StarID, ItemId: p.ItemID, ItemType: p.ItemType, ItemName: p.ItemName, ItemIcon: p.ItemIcon, Quantity: int32(p.QuantityDelta), ComboCount: int32(p.QuantityDelta), CreatedAt: p.FirstCreatedAt, } payload, _ := json.Marshal(map[string]interface{}{ "activity_id": p.ActivityID, "type": "contributions_response", "record": record, }) s.redisClient.Publish(ctx, fmt.Sprintf("act:%d:contributions", p.ActivityID), payload) } ``` - [ ] **Step 3.4: 验证编译** ```bash cd backend && go build ./services/activityService/... ``` 预期: BUILD OK(本任务只动了 `PurchaseItem`,`BatchPurchaseItem` / `GetLatestContributions` 仍有旧调用 — Task 4/5 修复)。 - [ ] **Step 3.5: 写 `enqueueComboContribution` Redis 不可用降级单测** 在 `backend/services/activityService/service/activity_service_combo_test.go` 新建: ```go package service import ( "context" "testing" "github.com/stretchr/testify/assert" ) func TestEnqueueComboContribution_NilRedis_FallbackImmediate(t *testing.T) { s := &activityService{ redisClient: nil, userRPCClient: &mockUserRPC{}, activityRepo: &mockActivityRepo{}, } // redisClient 为 nil 时,降级路径不会 Publish(内部判空),直接返回不 panic assert.NotPanics(t, func() { s.enqueueComboContribution(context.Background(), comboEnqueueParams{ ActivityID: 1, UserID: 100, StarID: 7, ItemID: 42, ItemType: "firework", QuantityDelta: 1, }) }) } ``` - [ ] **Step 3.6: 运行新增测试** ```bash cd backend && go test ./services/activityService/service/ -run TestEnqueueCombo -v ``` 预期: PASS - [ ] **Step 3.7: 暂不 commit** 继续 Task 4。 --- ### Task 4: 后端 — 改造 `BatchPurchaseItem` 使用聚合入队 **Files:** - Modify: `backend/services/activityService/service/activity_service.go:742-773` - [ ] **Step 4.1: 定位 `BatchPurchaseItem` 内的连击 + 推送循环** 第 742-773 行是循环内每条 item 的 `incrementComboCount` + `Publish` 段。 - [ ] **Step 4.2: 替换循环体** 将第 742-773 行整段替换为: ```go // 连击聚合:同 user+itemType 窗口内合并推送 itemName, itemIcon := "", "" if activityItem != nil { itemName = activityItem.ItemName itemIcon = activityItem.IconURL } s.enqueueComboContribution(ctx, comboEnqueueParams{ ActivityID: req.ActivityId, UserID: userID, StarID: req.StarId, ItemID: activityItem.ID, ItemType: item.ItemType, ItemName: itemName, ItemIcon: itemIcon, Nickname: nickname, AvatarURL: avatarURL, FirstID: contribution.ID, FirstCreatedAt: contribution.CreatedAt, QuantityDelta: int64(item.Quantity), }) ``` 注意: `nickname`/`avatarURL` 已在循环外(第 749 行)计算过,需要把它们的定义移到循环外或保持变量作用域。阅读第 746-749 行确认上下文:它们在循环内 if 块中,作用域只在该 if 块,需上提: ```go // 在 for 循环之前 nickname, avatarURL := "", "" if profile, _ := s.userRPCClient.GetFanProfile(userID, req.StarId); profile != nil { nickname = profile.Nickname avatarURL = profile.AvatarUrl } // for 循环内删除原 749-751 行 nickname/avatarURL 计算 ``` - [ ] **Step 4.3: 验证编译** ```bash cd backend && go build ./services/activityService/... ``` 预期: BUILD OK(`GetLatestContributions` 仍在调 `getComboCount`,Task 5 修复)。 - [ ] **Step 4.4: 暂不 commit** 继续 Task 5。 --- ### Task 5: 后端 — 改造 `GetLatestContributions` 增加内存合并 + 删除 `getComboCount` 引用 **Files:** - Modify: `backend/services/activityService/service/activity_service.go:1266-1307` - [ ] **Step 5.1: 阅读当前组装 records 段** 第 1266-1307 行是循环遍历 contributions、组装 `*pb.ContributionRecord` 的代码。`comboCount` 在第 1291 行调用 `s.getComboCount(ctx, ...)`,需替换为读取 agg Hash,失败则回退为 1。 - [ ] **Step 5.2: 替换 `comboCount` 取值** 将第 1290-1291 行: ```go // 获取连击计数 comboCount := int32(s.getComboCount(ctx, c.UserID, c.ItemType)) ``` 替换为: ```go // 聚合计数优先从 agg Hash 读;窗口已过期则回退为 quantity comboCount := int32(c.Quantity) if s.redisClient != nil { if v, err := s.redisClient.HGet(ctx, s.aggKey(c.UserID, c.ItemType), "quantity").Int64(); err == nil && v > 0 { comboCount = int32(v) } } ``` - [ ] **Step 5.3: 增加 SQL 后内存合并(3 秒窗口按 user_id+item_id 累加)** 定位到 `records[i] = &pb.ContributionRecord{...}` 赋值之后、`return` 之前(第 1309 行附近),新增: ```go // 内存合并:同 (user_id, item_id) 在 3 秒窗口内的行合并为 1 条, // quantity 累加,id 取最早行(与 WS first_id 一致,前端按 id 去重不会双计) const COMBO_WINDOW_MS int64 = 3000 merged := make([]*pb.ContributionRecord, 0, len(records)) i := 0 for i < len(records) { first := records[i] var totalQty int32 = first.Quantity j := i + 1 for j < len(records) && records[j].UserId == first.UserId && records[j].ItemId == first.ItemId && records[j].CreatedAt-first.CreatedAt <= COMBO_WINDOW_MS { totalQty += records[j].Quantity j++ } merged = append(merged, &pb.ContributionRecord{ Id: first.Id, UserId: first.UserId, Nickname: first.Nickname, AvatarUrl: first.AvatarUrl, StarId: first.StarId, ItemId: first.ItemId, ItemType: first.ItemType, ItemName: first.ItemName, ItemIcon: first.ItemIcon, Quantity: totalQty, ComboCount: totalQty, CreatedAt: first.CreatedAt, }) i = j } records = merged ``` - [ ] **Step 5.4: 修改 `return` 处的 records 引用** 第 1309-1315 行 `return &pb.GetLatestContributionsResponse{...Records: records...}` 应保留为 `Records: records`(已经是引用,无需改)。 - [ ] **Step 5.5: 验证编译** ```bash cd backend && go build ./services/activityService/... ``` 预期: BUILD OK(无旧函数引用残留)。 - [ ] **Step 5.6: 写内存合并单测** 追加到 `backend/services/activityService/service/activity_service_combo_test.go`: ```go func TestGetLatestContributions_MergesComboRecords(t *testing.T) { // 直接验证合并逻辑:GetLatestContributions 内的内存合并 // SQL 已按 created_at DESC 返回行,合并按 3 秒窗口 + (user,item) 分组 records := []*pb.ContributionRecord{ {Id: 10, UserId: 100, ItemId: 1, ItemType: "x", Quantity: 1, CreatedAt: 1700000003000}, {Id: 11, UserId: 100, ItemId: 1, ItemType: "x", Quantity: 1, CreatedAt: 1700000002000}, {Id: 12, UserId: 100, ItemId: 1, ItemType: "x", Quantity: 1, CreatedAt: 1700000001000}, {Id: 13, UserId: 100, ItemId: 2, ItemType: "y", Quantity: 1, CreatedAt: 1700000000500}, } const COMBO_WINDOW_MS int64 = 3000 merged := make([]*pb.ContributionRecord, 0, len(records)) i := 0 for i < len(records) { first := records[i] totalQty := first.Quantity j := i + 1 for j < len(records) && records[j].UserId == first.UserId && records[j].ItemId == first.ItemId && records[j].CreatedAt-first.CreatedAt <= COMBO_WINDOW_MS { totalQty += records[j].Quantity j++ } merged = append(merged, &pb.ContributionRecord{ Id: first.Id, Quantity: totalQty, ComboCount: totalQty, }) i = j } assert.Len(t, merged, 2, "3 条同 (user,item) 合并 + 1 条不同 item") assert.Equal(t, int32(3), merged[0].Quantity) assert.Equal(t, int64(12), merged[0].Id, "合并 id 取最早(同组最小 id)") assert.Equal(t, int32(1), merged[1].Quantity) } func TestGetLatestContributions_ComboWindowBoundary(t *testing.T) { // 跨 3 秒窗口不合并 records := []*pb.ContributionRecord{ {Id: 20, UserId: 100, ItemId: 1, Quantity: 1, CreatedAt: 5000}, {Id: 21, UserId: 100, ItemId: 1, Quantity: 1, CreatedAt: 1000}, // 差 4000ms > 3000 } const COMBO_WINDOW_MS int64 = 3000 merged := make([]*pb.ContributionRecord, 0, len(records)) i := 0 for i < len(records) { first := records[i] totalQty := first.Quantity j := i + 1 for j < len(records) && records[j].UserId == first.UserId && records[j].ItemId == first.ItemId && records[j].CreatedAt-first.CreatedAt <= COMBO_WINDOW_MS { totalQty += records[j].Quantity j++ } merged = append(merged, &pb.ContributionRecord{Id: first.Id, Quantity: totalQty}) i = j } assert.Len(t, merged, 2, "跨窗口不合并") } ``` - [ ] **Step 5.7: 运行合并测试** ```bash cd backend && go test ./services/activityService/service/ -run "TestGetLatestContributions_MergesComboRecords|TestGetLatestContributions_ComboWindowBoundary" -v ``` 预期: 2 tests passed - [ ] **Step 5.8: 运行整套 service 包测试,确认未破坏既有测试** ```bash cd backend && go test ./services/activityService/service/ -v ``` 预期: 全部通过(包括 `TestBuildComboRecord_*` / `TestEnqueueComboContribution_*` / 既有 `TestGetTop*` 等) - [ ] **Step 5.9: 暂不 commit** 继续 Task 6。 --- ### Task 6: 后端 — 在 `main.go` 启动 combo worker **Files:** - Modify: `backend/services/activityService/main.go:59-122` - [ ] **Step 6.1: 阅读 `main()` 函数** 第 59-122 行,定位 `activityService := service.NewActivityService(...)` 那一行。 - [ ] **Step 6.2: 在 service 初始化之后启动 worker** 在 `activityService := service.NewActivityService(...)` 之后、`activityProvider := provider.NewActivityProvider(activityService)` 之前新增: ```go // 启动 combo Stream worker(3 秒连击合并推送) workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() activityService.StartComboStreamWorker(workerCtx) ``` - [ ] **Step 6.3: 验证编译** ```bash cd backend && go build ./services/activityService/... ``` 预期: BUILD OK - [ ] **Step 6.4: 暂不 commit** 继续 Task 7。 --- ### Task 7: 前端 — 新增 `mergeComboRecords` 工具函数 **Files:** - Modify: `frontend/pages/support-activity/composables/useContributionRealtime.js` - [ ] **Step 7.1: 在文件顶部新增合并常量与函数** 在 `useContributionRealtime.js` 的 `import` 之后(第 4 行 `import` 之后,第 5 行注释之前)插入: ```javascript /** * 连击合并常量与工具函数 —— WS 与轮询两条路径共用 * - 同一 user_id+item_id 在 COMBO_WINDOW_MS 内的多条 → 合并为 1 条 * - 合并后 id = 同组最早 id(与后端 first_id 一致,前端按 id 去重不会双计) * - quantity = 同组 quantity 之和 */ const COMBO_WINDOW_MS = 3000 export function mergeComboRecords(records) { if (!Array.isArray(records) || records.length === 0) return records // 倒序遍历:新→旧;同 key 在窗口内合并到索引更大的(更新的)那条 const result = [...records] const indexByKey = new Map() // key = `${user_id}:${item_id}` -> result 中索引 for (let i = result.length - 1; i >= 0; i--) { const r = result[i] const key = `${r.user_id}:${r.item_id}` const existingIdx = indexByKey.get(key) if (existingIdx !== undefined) { const existing = result[existingIdx] if (Math.abs(existing.created_at - r.created_at) <= COMBO_WINDOW_MS) { existing.quantity += r.quantity existing.combo_count = existing.quantity if (r.id < existing.id) existing.id = r.id // 取最早 id result.splice(i, 1) continue } } indexByKey.set(key, i) } return result } ``` - [ ] **Step 7.2: 在 `onWsMessage` 中调用合并** 第 33 行 `records.value = [...records.value, record].slice(-MAX_RECORDS)` 替换为: ```javascript records.value = mergeComboRecords([...records.value, record]).slice(-MAX_RECORDS) ``` - [ ] **Step 7.3: 暂不 commit** 继续 Task 8。 --- ### Task 8: 前端 — `useContributionPolling.js` 在轮询路径调用合并 **Files:** - Modify: `frontend/pages/support-activity/composables/useContributionPolling.js` - [ ] **Step 8.1: 引入 `mergeComboRecords`** 在第 2 行 `import { getActivityContributionsLatestApi } from '@/utils/api.js'` 之后新增: ```javascript import { mergeComboRecords } from './useContributionRealtime.js' ``` - [ ] **Step 8.2: 在 `fetchLatest` 合并新记录后再调用合并** 定位到第 86 行 `records.value = [...newRecords, ...records.value].slice(0, MAX_RECORDS)`,替换为: ```javascript records.value = mergeComboRecords([...newRecords, ...records.value]).slice(0, MAX_RECORDS) ``` - [ ] **Step 8.3: 暂不 commit** 继续 Task 9。 --- ### Task 9: 前端 — 核对并按需调整 `ContributionList.vue` 模板 **Files:** - Modify: `frontend/pages/support-activity/components/ContributionList.vue` (仅当模板仍引用 `combo_count` 时) - [ ] **Step 9.1: 核对当前模板** 读取 `ContributionList.vue` 第 40-46 行。当前现状(核对后)已使用 `record.quantity`: ```vue X {{ record.quantity }} ``` **若已如上**:跳至 Step 9.3。 **若仍引用 `combo_count`**:把 `:class="getCountSizeClass(record.combo_count > 1 ? record.combo_count : record.quantity)"` 与内层 `{{ record.combo_count > 1 ? record.combo_count : record.quantity }}` 改为 `record.quantity`。 - [ ] **Step 9.2: 若模板已正确,无需修改** 直接在 commit 时记一笔:`template already uses record.quantity, spec §5.4 already satisfied`。 - [ ] **Step 9.3: 暂不 commit** 继续 Task 10。 --- ### Task 10: 集成验证与回归检查 **Files:** (无代码改动,只跑命令) - [ ] **Step 10.1: 后端全量测试** ```bash cd backend && go test ./services/activityService/... -v ``` 预期: 全部通过。 - [ ] **Step 10.2: 前端 lint(如可用)** ```bash cd frontend && npm run lint 2>/dev/null || echo "no lint configured" ``` 预期: 无错误。 - [ ] **Step 10.3: 后端构建 release binary 确认无编译问题** ```bash cd backend && go build -o /tmp/activityService ./services/activityService/ ``` 预期: BUILD OK。 - [ ] **Step 10.4: 核对接口签名一致** ```bash grep -n "ComboCount\|combo_count" \ backend/services/activityService/service/activity_service.go \ backend/pkg/proto/activity/*.go 2>/dev/null ``` 预期: 字段保留(用于前端展示兼容 / proto 兼容);但 `activity_service.go` 中已不再调 `getComboCount`/`incrementComboCount`。 - [ ] **Step 10.5: 手动集成测试(如本地 Redis 可用)** ```bash # 1. 启动 activityService(假设本地 Redis 已起) # 2. 用 Postman/curl 调 PurchaseItem 3 次同 user 同 item,间隔 < 3s # 3. 观察 Redis Stream combo:stream:contributions 中只有 1 条 XAdd # 4. 等 3 秒后,Redis Channel act:{activityId}:contributions 收到 1 条合并 record(quantity=3) ``` --- ### Task 11: 自审 — 修复后回归检查(spec §8 + CLAUDE.md「自审与回归检查规范」) - [ ] **Step 11.1: 核对改动文件清单** ```bash git status ``` 预期包含: - M `backend/services/activityService/service/activity_service.go` - A `backend/services/activityService/service/combo_worker.go` - A `backend/services/activityService/service/combo_worker_test.go` - A `backend/services/activityService/service/activity_service_combo_test.go` - M `backend/services/activityService/main.go` - M `frontend/pages/support-activity/composables/useContributionRealtime.js` - M `frontend/pages/support-activity/composables/useContributionPolling.js` - (可能) M `frontend/pages/support-activity/components/ContributionList.vue` - [ ] **Step 11.2: 核对下游不受影响** 通过 `mcp__code-review-graph__query_graph_tool pattern="callers_of" target="comboKey"` 检查是否还有外部调用被破坏。预期: 仅同文件内调用。 - [ ] **Step 11.3: 核对 DB schema 未改动** ```bash git diff --stat HEAD~0 -- backend/services/activityService/repository/ backend/pkg/models/ ``` 预期: 无改动。 - [ ] **Step 11.4: 核对 `combo_count` 外部消费方** ```bash grep -rn "combo_count\|ComboCount" frontend/ --include="*.vue" --include="*.js" ``` 预期: 仅 ContributionList.vue / useContributionRealtime.js / useContributionPolling.js / 后端 proto 文件,无其他业务方。 - [ ] **Step 11.5: 跑一遍 `git diff --stat` 确认无意外文件** ```bash git diff --stat ``` 预期: 改动文件清单与 Step 11.1 完全一致。 --- ### Task 12: 提交(由用户在确认后执行;AI 不得主动 commit) **Files:** (无代码改动) - [ ] **Step 12.1: 把改动文件暂存(待用户确认)** ```bash git add \ backend/services/activityService/service/activity_service.go \ backend/services/activityService/service/combo_worker.go \ backend/services/activityService/service/combo_worker_test.go \ backend/services/activityService/service/activity_service_combo_test.go \ backend/services/activityService/main.go \ frontend/pages/support-activity/composables/useContributionRealtime.js \ frontend/pages/support-activity/composables/useContributionPolling.js ``` - [ ] **Step 12.2: 输出待 commit 信息给用户确认** 向用户输出建议的 commit message(按 CLAUDE.md 规范): ``` feat(contribution):连击贡献合并推送(Redis Stream 延迟队列) - PurchaseItem/BatchPurchaseItem 改用 agg Hash + Stream 延迟队列, 3 秒窗口内同 (user, item) 多次购买合并为 1 条 WS 推送 - 新增 combo_worker goroutine 从 Stream 取出到期条目 → 读 agg Hash → Publish 合并 record - GetLatestContributions 在 SQL 后做 3 秒窗口内存合并 - 前端 useContributionRealtime/Polling 两条路径共用 mergeComboRecords 工具函数 可靠性: - Stream + Consumer Group 重启不丢推送(pending list 自动重投递) - Redis 故障降级为立即 Publish(与旧行为一致) Refs: docs/superpowers/specs/2026-06-23-contribution-combo-aggregation-design.md Co-Authored-By: Claude Opus 4.8 ``` **注**:按 CLAUDE.md「AI 不得主动 commit」规范,此步骤由用户确认后手动执行 commit;AI 不运行 `git commit`。 --- ## 风险与回滚 | 风险 | 回滚方案 | |---|---| | Stream 写入失败导致推送丢失 | enqueueComboContribution 内已加 `pipe.Exec` 失败 fallback 到 `publishContributionImmediate` | | worker 启动失败 | `s.redisClient == nil` 时直接 warn return;main.go 不因此崩溃 | | 内存合并逻辑与 WS 推送 ID 不一致 | 合并后 `Id = first.Id`,与 WS 推送的 `first_id` 一致 | | 前端模板未及时改 | 既有模板已用 `quantity`(Task 9 核对后确认),无遗漏分支 | | 多副本场景下 XAdd 重复入队 | SET NX EX 3 防重复;Consumer Group + XAUTOCLAIM 保证推送幂等 | --- ## 监控指标(可选,部署后配置) - `combo:stream:contributions` Stream 长度(应 < 1000,超过说明 worker 处理慢) - `XPENDING combo:stream:contributions combo-publishers` pending 数量(应 < 100) - 前端 `mergeComboRecords` 调用次数与合并前后 record 数差值 --- ## Self-Review Checklist - [x] Spec §1-§4 覆盖:Task 1-6 - [x] Spec §5 前端:Task 7-9 - [x] Spec §6 错误处理:Task 3/5 已覆盖 Redis 不可用降级、processComboEntry 空 hash skip - [x] Spec §7 性能:Stream MAXLEN 100000 + Pipeline(`TxPipeline`)减少 RTT - [x] Spec §8 影响范围:Task 11 自审核对 - [x] Spec §9 测试:Task 2/3/5/10 含单测与集成验证 - [x] Spec §10 风险:Task 11 自审 + 风险表覆盖 - [x] Spec §11 实施步骤 1-9:对应 Task 1-9 - [x] 类型/函数名一致性:`aggKey` / `aggLockKey` / `enqueueComboContribution` / `publishContributionImmediate` / `buildComboRecord` / `StartComboStreamWorker` / `processComboEntry` / `mergeComboRecords` 在所有 Task 中用法一致 - [x] 无 placeholder:每个代码块都是完整可直接复制的代码