1088 lines
37 KiB
Markdown
1088 lines
37 KiB
Markdown
# 用户贡献连击聚合推送 — 实施计划
|
||
|
||
> **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
|
||
<text class="item-x">X</text>
|
||
<text
|
||
class="item-count"
|
||
:class="getCountSizeClass(record.quantity)"
|
||
>{{ record.quantity }}</text>
|
||
```
|
||
|
||
**若已如上**:跳至 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 <noreply@anthropic.com>
|
||
```
|
||
|
||
**注**:按 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:每个代码块都是完整可直接复制的代码
|