# 用户贡献连击聚合推送方案
## 1. 概述
### 1.1 背景
应援活动页面([support-activity/index.vue](frontend/pages/support-activity/index.vue))实时展示用户的贡献动态([ContributionList.vue](frontend/pages/support-activity/components/ContributionList.vue))。同一个用户在短时间内连续多次送同一个道具时,**应当合并为一条 UI 项**(数量累加),而不是显示 N 条独立的"道具×1"。
### 1.2 当前问题(实测)
用户连续点击"道具A" 3 次,前端实际显示:
```
小明 送烟花 ×1 ← 第 1 条
小明 送烟花 ×2 ← 第 2 条
小明 送烟花 ×3 ← 第 3 条
```
期望显示:
```
小明 送烟花 ×3 ← 唯一一条
```
### 1.3 根因
后端已有 Redis 连击计数机制(`combo:{userID}:{itemType}`,TTL 3 秒,INCR),但**只把累计值贴在每条 record 的 `combo_count` 字段上,并没有真正合并推送**:
- `PurchaseItem` 每次购买都会:
1. 写一行 `activity_contributions`(DB 行 ID 单调递增)
2. `INCR combo` 计数器
3. 立即 `Publish` 一条独立的 `ContributionRecord` 到 Redis Channel `act:{activityId}:contributions`
- 前端 `useContributionRealtime.js:31-34` 收到 WS 推送后直接 `[...records, record].slice(-5)` 追加
- 模板 `ContributionList.vue:43-46` 用 `combo_count > 1 ? combo_count : quantity` 显示:第 1 条 combo_count=1 走 quantity 分支显示 ×1,第 2 条 combo_count=2 显示 ×2 ……
也就是说 Redis 聚合是"半个聚合":字段存在但推送层和展示层都没合并。
### 1.4 目标
| 项目 | 目标 |
|---|---|
| 用户感知 | 同一用户 3 秒内送同一道具 N 次 → 前端只看到 1 条 UI 项,数量 = N |
| 后端约束 | DB 仍然每条购买写 1 行(排行榜/统计需要粒度) |
| 推送约束 | WS 在 3 秒窗口内只推送 1 条**合并**后的 record |
| 前端约束 | 模板与现有轮询/WS 接收逻辑改动最小 |
| 可靠性 | 多副本 / 进程重启不丢推送 |
---
## 2. 设计总览
### 2.1 数据流
```
用户第 1 次购买 ──┐
├─→ DB: 各写 1 行 contribution(共 N 行独立行)
用户第 2 次购买 ──┤
├─→ Redis Stream + Hash: 累加 aggregate 字段(quantity、首次信息)
用户第 N 次购买 ──┘
↓
(3 秒窗口结束) ──→ Worker 从 Stream 取出到期条目 ──→ Publish 1 条合并 record ──→ 前端追加 1 条 UI 项
```
### 2.2 设计选型
延迟推送的 3 种候选方案对比:
| 方案 | 可靠性 | 实现成本 | 适用场景 |
|---|---|---|---|
| A. goroutine + `time.Sleep` | ⚠ 中(重启丢推送) | ⭐ 最低 | 单副本、低 QPS |
| **B. Redis Stream 延迟队列** | ✅ 高(Stream 持久化、重启不丢、消费者组支持多副本) | ⭐⭐ 中 | **本项目采用:多副本、生产级** |
| C. Redis Keyspace Notifications | ⚠ 中(订阅断开期间事件丢失) | ⭐⭐ 中 | Redis 由运维统一管理 |
**采用 B**:项目已多副本 / 容器化部署(CLAUDE.md 提到 docker 目录、Kitex 微服务),goroutine 方案重启即丢推送,不可接受。
---
## 3. Redis Key 设计
| Key | 类型 | TTL | 用途 |
|---|---|---|---|
| `combo:stream:contributions` | Stream | 永久(MAXLEN 限制) | 延迟推送任务队列 |
| `combo:agg:{userID}:{itemType}` | Hash | 5s | 同一 (user, itemType) 在 3 秒窗口内的累计信息 |
| `combo:agg:lock:{userID}:{itemType}` | String `"1"` | 3s | "是否已写入 Stream",用 `SET NX EX 3` 防重复入队 |
**Stream entry 字段**:
```
activity_id user_id item_type first_id first_created_at expire_at_ms
```
- `expire_at_ms` = 入队时 `now + 3000`,worker 取出后判断 `now >= expire_at_ms` 才推送
- `MAXLEN ~ 100000`(仅作兜底,正常负载远低于此)
**agg Hash 字段**:
```
activity_id user_id star_id item_id item_name item_icon
nickname avatar_url quantity first_id first_created_at
```
- `quantity` 用 `HINCRBY` 累加
- 其余字段首次写入后保持不变
---
## 4. 后端改造
### 4.1 文件改动清单
| 文件 | 改动 |
|---|---|
| `services/activityService/service/activity_service.go` | `PurchaseItem` 改造;新增 `enqueueAggregatedContribution`、`consumeComboStream` |
| `services/activityService/service/combo_worker.go` | 新增:Stream worker(独立 goroutine,main 启动) |
| `services/activityService/main.go` | 启动 worker goroutine |
| `services/activityService/service/activity_service.go::GetLatestContributions` | combo_count 来源改为 `HGET combo:agg quantity`,聚合已过期时回退为 1 |
### 4.2 `PurchaseItem` 改造点([activity_service.go:432-473](backend/services/activityService/service/activity_service.go#L432-L473))
```go
// === PurchaseItem 改造 ===
// 1. DB 写入不变
err = s.activityRepo.CreateContribution(contribution)
// 2. 删除原来的:incrementComboCount + 立即 Publish
// 3. 新增:写入聚合 Hash + 入队 Stream
if s.redisClient != nil {
aggKey := fmt.Sprintf("combo:agg:%d:%s", userID, req.ItemType)
lockKey := fmt.Sprintf("combo:agg:lock:%d:%s", userID, req.ItemType)
// (a) 累加 quantity + 首次写入其他字段
// 注:先全部写入,最后再 Expire —— 避免 hash 刚创建就被先 EXPIRE 抹掉 TTL
s.redisClient.HIncrBy(ctx, aggKey, "quantity", int64(req.Quantity))
// HSetNX 保护非 quantity 字段不覆盖
s.redisClient.HSetNX(ctx, aggKey, "activity_id", req.ActivityId)
s.redisClient.HSetNX(ctx, aggKey, "user_id", userID)
s.redisClient.HSetNX(ctx, aggKey, "star_id", req.StarId)
s.redisClient.HSetNX(ctx, aggKey, "item_id", item.ID)
s.redisClient.HSetNX(ctx, aggKey, "item_name", itemName)
s.redisClient.HSetNX(ctx, aggKey, "item_icon", itemIcon)
s.redisClient.HSetNX(ctx, aggKey, "nickname", nickname)
s.redisClient.HSetNX(ctx, aggKey, "avatar_url", avatarURL)
s.redisClient.HSetNX(ctx, aggKey, "first_id", contribution.ID)
s.redisClient.HSetNX(ctx, aggKey, "first_created_at", contribution.CreatedAt)
// 所有写入完成后再续 TTL,确保 3 秒窗口内 hash 始终存在
s.redisClient.Expire(ctx, aggKey, 5*time.Second)
// (b) 仅首次入队 Stream(SET NX EX 3 防重复)
isFirst, _ := s.redisClient.SetNX(ctx, lockKey, "1", 3*time.Second).Result()
if isFirst {
expireAt := time.Now().Add(3 * time.Second).UnixMilli()
s.redisClient.XAdd(ctx, &redis.XAddArgs{
Stream: "combo:stream:contributions",
MaxLen: 100000,
Approx: true,
Values: map[string]interface{}{
"activity_id": req.ActivityId,
"user_id": userID,
"item_type": req.ItemType,
"first_id": contribution.ID,
"first_created_at": contribution.CreatedAt,
"expire_at_ms": expireAt,
},
})
}
}
```
### 4.3 新增:`combo_worker.go`(Stream 消费者)
```go
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/zerosaturation/go-topsdk/util/logger"
pb "topfans/pkg/proto/activity"
)
// StartComboStreamWorker 启动 Stream 消费者(main 中调用一次)
func (s *activityService) StartComboStreamWorker(ctx context.Context) {
streamKey := "combo:stream:contributions"
consumerGroup := "combo-publishers"
consumerName := fmt.Sprintf("worker-%d", time.Now().UnixNano()%100000)
// 创建消费者组(已存在则忽略 BUSYGROUP)
_ = s.redisClient.XGroupCreateMkStream(ctx, streamKey, consumerGroup, "0").Err()
go func() {
for {
select {
case <-ctx.Done():
return
default:
}
// 阻塞读取新条目(最多阻塞 1s)
streams, err := s.redisClient.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: consumerGroup,
Consumer: consumerName,
Streams: []string{streamKey, ">"},
Count: 10,
Block: 1 * time.Second,
}).Result()
if err == redis.Nil {
continue
}
if err != nil {
logger.Logger.Warn("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, streamKey, consumerGroup, msg.ID)
}
}
}
}()
}
// processComboEntry 处理一条 Stream 条目:
// - 等到 expire_at_ms 才推送(保证窗口结束)
// - 读取 agg Hash 推送合并 record
func (s *activityService) processComboEntry(ctx context.Context, msg redis.XMessage) {
expireAt, _ := strconv.ParseInt(msg.Values["expire_at_ms"].(string), 10, 64)
now := time.Now().UnixMilli()
if now < expireAt {
// 还没到窗口结束,挂起一段时间后再处理(用 XClaim 重投递或本地 sleep)
time.Sleep(time.Duration(expireAt-now) * time.Millisecond)
}
userID, _ := strconv.ParseInt(msg.Values["user_id"].(string), 10, 64)
activityID, _ := strconv.ParseInt(msg.Values["activity_id"].(string), 10, 64)
itemType := msg.Values["item_type"].(string)
aggKey := fmt.Sprintf("combo:agg:%d:%s", userID, itemType)
lockKey := fmt.Sprintf("combo:agg:lock:%d:%s", userID, itemType)
fields, err := s.redisClient.HGetAll(ctx, aggKey).Result()
if err != nil || len(fields) == 0 {
return
}
quantity, _ := strconv.ParseInt(fields["quantity"], 10, 32)
firstID, _ := strconv.ParseInt(fields["first_id"], 10, 64)
firstCreatedAt, _ := strconv.ParseInt(fields["first_created_at"], 10, 64)
record := &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,
}
payload, _ := json.Marshal(map[string]interface{}{
"activity_id": activityID,
"type": "contributions_response",
"record": record,
})
s.redisClient.Publish(ctx, fmt.Sprintf("act:%d:contributions", activityID), payload)
s.redisClient.Del(ctx, aggKey, lockKey)
}
```
> **窗口等待策略**:worker 取出条目后用本地 `time.Sleep` 等待 `expire_at_ms` 再推送。简单可靠;如果 worker 重启,条目会通过消费者组的 pending list 重新投递,重启期间不会丢推送。
### 4.4 `main.go` 改动
```go
// services/activityService/main.go
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 在 NewActivityService 之后启动 worker
activityService := service.NewActivityService(...)
activityService.StartComboStreamWorker(ctx)
```
### 4.5 删除旧代码
删除 `activity_service.go::incrementComboCount` 和 `getComboCount`(已被新方案取代),但保留 `comboKey` 以备工具方法调用。
### 4.6 `GetLatestContributions` 改造(轮询路径合并)
`GetLatestContributions` 当前从 SQL 返回每条独立 contribution 行。3 秒窗口内同一个 user+item 的 N 行必须合并,否则前端轮询路径会出现"道具×1、×2、×3"。
**改造点**:在 SQL 查询后、组装响应前,对窗口内的行做内存合并:
```go
// GetLatestContributions 末尾,组装 records 之前
// 1. SQL 返回按 created_at DESC 排序的独立行
// 2. 按 (user_id, item_id) 分组,3 秒内累加 quantity、id 取最早的(first_id)
const COMBO_WINDOW_MS = 3000
merged := make([]*pb.ContributionRecord, 0, len(contributions))
i := 0
for i < len(contributions) {
first := contributions[i]
j := i + 1
var totalQty int32 = first.Quantity
for j < len(contributions) &&
contributions[j].UserID == first.UserID &&
contributions[j].ItemID == first.ItemID &&
contributions[j].CreatedAt-first.CreatedAt <= COMBO_WINDOW_MS {
totalQty += contributions[j].Quantity
j++
}
records[i] = &pb.ContributionRecord{
Id: first.ID, // 用 first_id,前端按 id 去重
...
Quantity: totalQty,
ComboCount: totalQty,
CreatedAt: first.CreatedAt,
}
i = j
}
```
> 关键不变量:合并后的 record.id = 同组最早一行 DB 行的 ID,与 WS 推送保持一致 —— 前端按 id 去重时不会把"轮询拉到的一行"与"WS 推过来的合并 record"当成两条不同的 UI 项。
---
## 5. 前端处理
### 5.1 改动范围
| 文件 | 改动 |
|---|---|
| `frontend/pages/support-activity/composables/useContributionRealtime.js` | 抽取 `mergeComboRecords` 工具函数;WS 与轮询两条路径共用,按 `(user_id, item_id)` + 3 秒窗口合并 |
| `frontend/pages/support-activity/composables/useContributionPolling.js` | 调用 `mergeComboRecords` 后再写入 records |
| `frontend/pages/support-activity/components/ContributionList.vue` | 模板去掉 `combo_count` 兜底分支,直接用 `quantity` |
### 5.2 新增:`composables/useContributionRealtime.js::mergeComboRecords`
```javascript
/**
* 将一组 contribution record 按 (user_id, item_id) + 时间窗口合并
* - 同一 user+item 在 COMBO_WINDOW_MS 内多条 → 合并为 1 条
* - 合并后 id = 同组最早 id(与后端 WS 推送的 first_id 一致)
* - quantity = 同组 quantity 之和
* - 顺序保持输入顺序(最新在前)
*/
const COMBO_WINDOW_MS = 3000
export function mergeComboRecords(records) {
if (!Array.isArray(records) || records.length === 0) return records
// 已有 records 索引(按 user_id+item_id 找最新一条)
const indexByKey = new Map() // key = `${user_id}:${item_id}` -> records index
const result = [...records]
// 倒序遍历:新→旧,新的覆盖旧的(聚合窗口内则合并到最新一条)
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]
// 两者时间差在 3 秒内 → 合并
if (Math.abs(existing.created_at - r.created_at) <= COMBO_WINDOW_MS) {
existing.quantity += r.quantity
existing.combo_count = existing.quantity
// id 取较早的(first_id)
if (r.id < existing.id) existing.id = r.id
// 删除被合并的旧条目
result.splice(i, 1)
continue
}
}
indexByKey.set(key, i)
}
return result
}
```
### 5.3 WS 与轮询两条路径都调用合并
**WS 路径**(`useContributionRealtime.js::onWsMessage`):
```javascript
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()) {
const merged = mergeComboRecords([...records.value, record])
records.value = merged.slice(-MAX_RECORDS)
}
}
```
**轮询路径**(`useContributionPolling.js::fetchLatest`):
```javascript
// 在 [...newRecords, ...records.value].slice(0, MAX_RECORDS) 后增加 mergeComboRecords
if (isNew) {
records.value.forEach(resetRecordTimer)
const combined = mergeComboRecords([...newRecords, ...records.value])
records.value = combined.slice(0, MAX_RECORDS)
newRecords.forEach(resetRecordTimer)
latestTimestamp = firstRecord.created_at
latestId = firstRecord.id
}
```
### 5.4 模板调整([ContributionList.vue:43-46](frontend/pages/support-activity/components/ContributionList.vue#L43-L46))
**Before**:
```vue
{{ record.combo_count > 1 ? record.combo_count : record.quantity }}
```
**After**:
```vue
{{ record.quantity }}
```
> 后端合并后 `quantity === combo_count`,模板可直接用 `quantity`;`getCountSizeClass` 函数保留。
> `useContributionRealtime.js:31-34` 的 `record.id > highestIdRef()` 去重保护保留,作为幂等兜底。
---
## 6. 错误处理
| 场景 | 处理 |
|---|---|
| Redis 不可用 | 降级为每条 Purchase 立即 Publish 单条 record(保留旧行为) |
| Worker 进程崩溃 / 重启 | 消费者组 pending list 在重启时 XAUTOCLAIM 重新投递,不丢推送 |
| 用户在 3 秒末尾继续点击(marker 已被 worker 取出但尚未 Publish) | 此时 hash 的 quantity 已累加;worker 取出时 sleep 到 expire_at_ms 后会读取到最新 quantity,合并推送完整结果 |
| HSetNX 之前 hash 已被 worker 删除(极小窗口竞态) | `processComboEntry` 读到空 hash → 直接 return;下一条 purchase 会重新入队 |
| 同一 user 不同 item_type | 不同 key,互不影响 |
| 跨 activity | `act:{activityId}:contributions` channel 已带 activity_id,前端按 activity_id 过滤 |
---
## 7. 性能与一致性
### 7.1 性能指标
- **DB 写入**:每次 Purchase 1 行(与现状一致)
- **Redis 操作**:1 次 HINCRBY + N 次 HSETNX + 1 次 SETNX + 1 次 XADD ≈ 4-6 次 Redis 调用(Pipeline 可优化到 1 次)
- **WS 推送**:3 秒内 N 次 Purchase → 1 条合并 record(与现状 N 条相比降低 N 倍推送频率)
- **Worker 开销**:单 goroutine,每秒 XReadGroup 一次,常驻内存 < 1MB
### 7.2 排行榜一致性
`activity_contributions` 表每条独立行写入不变:
- `GetContributionRanking`([activity_service.go:811-919](backend/services/activityService/service/activity_service.go#L811-L919))按 sum(quantity) 聚合
- 3 秒窗口合并只在推送层做,不影响 DB 聚合结果
---
## 8. 影响范围
### 8.1 后端需要修改的文件
1. `services/activityService/service/activity_service.go`
- `PurchaseItem` 替换 combo 计数与推送逻辑([activity_service.go:432-473](backend/services/activityService/service/activity_service.go#L432-L473))
- `BatchPurchaseItem` 同样改造([activity_service.go:726-755](backend/services/activityService/service/activity_service.go#L726-L755))
- `GetLatestContributions` 增加内存合并:按 `(user_id, item_id)` + 3 秒窗口累加 quantity([activity_service.go:1017-1104](backend/services/activityService/service/activity_service.go#L1017-L1104))
- 删除 `incrementComboCount`、`getComboCount`(保留 `comboKey`)
2. `services/activityService/service/combo_worker.go`(新增)
- `StartComboStreamWorker`
- `processComboEntry`
3. `services/activityService/main.go`
- 启动 worker goroutine
### 8.2 前端需要修改的文件
1. `frontend/pages/support-activity/composables/useContributionRealtime.js`
- 新增 `mergeComboRecords` 工具函数
- `onWsMessage` 调用 mergeComboRecords
2. `frontend/pages/support-activity/composables/useContributionPolling.js`
- 引入 `mergeComboRecords`
- `fetchLatest` 在合并新记录后调用 mergeComboRecords
3. `frontend/pages/support-activity/components/ContributionList.vue`
- 模板去掉 `combo_count` 兜底分支(line 43-46)
### 8.3 不需要修改的文件
- `gateway/socket/activity_socket.go` Redis PubSub 订阅(payload 格式不变)
- `gateway/controller/activity_controller.go` HTTP 转换(响应字段不变)
- Repository 层 SQL
- 数据库 schema
---
## 9. 测试验证
### 9.1 单元测试
- `PurchaseItem` 在 Redis 不可用时降级为立即推送(与旧行为一致)
- `processComboEntry` 在 hash 为空时跳过
- XAdd MAXLEN 不会无限增长
### 9.2 集成测试
| 场景 | 预期 |
|---|---|
| 单次购买 | 1 条 DB 行 + 1 条 WS 推送,quantity=1 |
| 1 秒内连买 3 次同一道具 | 3 条 DB 行 + 1 条合并 WS 推送,quantity=3,record.id = 第 1 条的 id |
| 1 秒内连买 3 次不同道具 | 3 条 DB 行 + 3 条独立 WS 推送(每条 quantity=1) |
| 跨 3 秒购买 | 第 1 段合并成 1 条推送;3 秒后再来 1 次 → 第 2 段合并成 1 条推送 |
| Redis 故障 | 降级为立即推送(与旧行为一致) |
| Worker 重启 | pending list 重新投递,不丢推送 |
| 多副本部署 | SETNX 保证仅 1 个副本入队;消费者组保证推送幂等 |
### 9.3 前端验证
- 快速点 3 次同一道具 → UI 仅 1 条新项,数量从 1 累加到 3
- 切换道具 → 不同 UI 项独立显示
- 跨 3 秒 → 出现 2 条独立 UI 项
---
## 10. 风险与缓解
| 风险 | 缓解 |
|---|---|
| `notify-keyspace-events` 未开启导致 HSETNX 监听不到? | 不依赖 Keyspace Notifications,仅用 Stream |
| `XReadGroup` Block 阻塞 → worker 协程泄漏 | 用 ctx.Done 退出 + main 关闭时 cancel |
| Stream 内存增长 | `MAXLEN ~ 100000` 截断 |
| HSetNX 与 HIncrBy 竞态导致 quantity 丢失? | HSetNX 在 IncrBy 之前,pipeline 内顺序保证 |
| worker 取条目后 sleep 期间崩溃 → 条目留在 pending | XAUTOCLAIM 重新认领(消费者组自带机制) |
| `combo_count` 字段外部消费方 | 仅前端展示用,前端改造一并删除兜底分支 |
---
## 11. 实施步骤
1. [ ] 在 Redis 创建消费者组 `combo-publishers`(运维一次性脚本,或在 worker 启动时 XGroupCreateMkStream)
2. [ ] 新增 `combo_worker.go`,实现 `StartComboStreamWorker` + `processComboEntry`
3. [ ] 在 `main.go` 启动 worker
4. [ ] 改造 `PurchaseItem` 与 `BatchPurchaseItem`:删除 `incrementComboCount`/`getComboCount`;改为 agg hash + Stream 入队
5. [ ] 改造 `GetLatestContributions`:增加内存合并逻辑(按 user_id+item_id + 3 秒窗口累加 quantity)
6. [ ] 前端新增 `mergeComboRecords` 工具函数;WS 与轮询两条路径共用
7. [ ] 前端模板:去掉 `combo_count` 兜底分支
8. [ ] 集成测试:单测、连击 3 次推送合并、轮询 API 合并、多副本部署验证
9. [ ] 监控告警:worker pending list 长度告警、Stream 长度告警
---
## 12. 后续优化(可选)
- **Pipeline 优化**:把 HIncrBy + HSetNX + SetNX + XAdd 合并到一个 Pipeline
- **批量推送**:worker 一次处理多条同 user 条目(用 consumer group 多个 consumer 并行)
- **Lua 脚本**:原子化"累加 + 入队",省 4-6 次 Redis 调用为 1 次