package service import ( "context" "log/slog" "sync" "time" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/outbox" ) const ( // roomRocketProgressCoalesceWindow 固定按 UTC 毫秒切 1 秒桶,把高频燃料变化压缩成客户端可接受的展示粒度。 roomRocketProgressCoalesceWindow = time.Second // roomRocketProgressFlushInterval 控制到期 bucket 的扫描频率;100ms 让实际延迟稳定落在 1 秒窗口附近。 roomRocketProgressFlushInterval = 100 * time.Millisecond ) type roomRocketProgressPending struct { record outbox.Record robotLane bool rocketID string level int32 currentFuel int64 roomVersion int64 } type roomRocketProgressKey struct { appCode string roomID string rocketID string level int32 robotLane bool windowStartMS int64 } type roomRocketProgressEntry struct { key roomRocketProgressKey record outbox.Record roomVersion int64 currentFuel int64 flushAtMS int64 } type roomRocketProgressCoalescer struct { mu sync.Mutex publisher integration.OutboxPublisher pending map[roomRocketProgressKey]roomRocketProgressEntry } func newRoomRocketProgressCoalescer(publisher integration.OutboxPublisher) *roomRocketProgressCoalescer { return &roomRocketProgressCoalescer{ publisher: publisher, pending: make(map[roomRocketProgressKey]roomRocketProgressEntry), } } func (c *roomRocketProgressCoalescer) enqueue(progress roomRocketProgressPending) { if c == nil || progress.record.Envelope == nil || progress.rocketID == "" || progress.level <= 0 { // 入队只接受已经构造成 outbox envelope 的火箭进度;异常输入说明调用方没有走标准 SendGift 构造链路。 return } record := progress.record record.AppCode = appcode.Normalize(record.AppCode) record.Envelope.AppCode = record.AppCode if record.CreatedAtMS <= 0 { record.CreatedAtMS = record.Envelope.GetOccurredAtMs() } if progress.roomVersion <= 0 { progress.roomVersion = record.Envelope.GetRoomVersion() } windowStartMS := roomRocketProgressWindowStartMS(record.CreatedAtMS) key := roomRocketProgressKey{ appCode: record.AppCode, roomID: record.RoomID, rocketID: progress.rocketID, level: progress.level, robotLane: progress.robotLane, windowStartMS: windowStartMS, } entry := roomRocketProgressEntry{ key: key, record: record, roomVersion: progress.roomVersion, currentFuel: progress.currentFuel, flushAtMS: windowStartMS + roomRocketProgressCoalesceWindow.Milliseconds(), } c.mu.Lock() defer c.mu.Unlock() c.mergeLocked(entry) } func (c *roomRocketProgressCoalescer) flushDue(ctx context.Context, nowMS int64) ([]outbox.Record, error) { if c == nil { return nil, nil } due := c.takeDue(nowMS) if len(due) == 0 { return nil, nil } normalEntries, robotRecords := splitRoomRocketProgressDue(due) if err := c.publishDue(ctx, normalEntries); err != nil { c.requeue(normalEntries, nowMS+roomRocketProgressFlushInterval.Milliseconds()) return robotRecords, err } return robotRecords, nil } func (c *roomRocketProgressCoalescer) takeDue(nowMS int64) []roomRocketProgressEntry { c.mu.Lock() defer c.mu.Unlock() due := make([]roomRocketProgressEntry, 0) for key, entry := range c.pending { if entry.flushAtMS > nowMS { continue } due = append(due, entry) delete(c.pending, key) } return due } func (c *roomRocketProgressCoalescer) requeue(entries []roomRocketProgressEntry, nextFlushAtMS int64) { c.mu.Lock() defer c.mu.Unlock() for _, entry := range entries { entry.flushAtMS = nextFlushAtMS c.mergeLocked(entry) } } func (c *roomRocketProgressCoalescer) mergeLocked(entry roomRocketProgressEntry) { current, exists := c.pending[entry.key] if !exists || roomRocketProgressEntryNewer(entry, current) { c.pending[entry.key] = entry } } func splitRoomRocketProgressDue(entries []roomRocketProgressEntry) ([]roomRocketProgressEntry, []outbox.Record) { normal := make([]roomRocketProgressEntry, 0, len(entries)) robotRecords := make([]outbox.Record, 0) for _, entry := range entries { record := entry.record record.AppCode = appcode.Normalize(record.AppCode) if record.Envelope != nil { record.Envelope.AppCode = record.AppCode } if entry.key.robotLane { robotRecords = append(robotRecords, record) continue } entry.record = record normal = append(normal, entry) } return normal, robotRecords } func (c *roomRocketProgressCoalescer) publishDue(ctx context.Context, entries []roomRocketProgressEntry) error { if c.publisher == nil { return nil } for _, entry := range entries { record := entry.record if record.Envelope == nil { continue } record.AppCode = appcode.Normalize(record.AppCode) record.Envelope.AppCode = record.AppCode if err := c.publisher.PublishOutboxEvent(appcode.WithContext(ctx, record.AppCode), record.Envelope); err != nil { return err } } return nil } func roomRocketProgressEntryNewer(candidate roomRocketProgressEntry, current roomRocketProgressEntry) bool { if candidate.roomVersion != current.roomVersion { return candidate.roomVersion > current.roomVersion } if candidate.currentFuel != current.currentFuel { return candidate.currentFuel > current.currentFuel } return candidate.record.CreatedAtMS >= current.record.CreatedAtMS } func roomRocketProgressWindowStartMS(createdAtMS int64) int64 { windowMS := roomRocketProgressCoalesceWindow.Milliseconds() if createdAtMS <= 0 { return 0 } return createdAtMS / windowMS * windowMS } // FlushRoomRocketProgress 把已经超过 1 秒窗口的火箭进度直接发腾讯 IM;机器人 lane 保持原展示直发路径。 func (s *Service) FlushRoomRocketProgress(ctx context.Context) error { if s == nil || s.roomRocketProgressCoalescer == nil { return nil } robotRecords, err := s.roomRocketProgressCoalescer.flushDue(ctx, s.clock.Now().UTC().UnixMilli()) s.publishRobotDisplayRecordsBestEffort(ctx, robotRecords) return err } // RunRoomRocketProgressFlushWorker 周期性把内存合并后的普通火箭进度直接投递到房间 IM。 func (s *Service) RunRoomRocketProgressFlushWorker(ctx context.Context, interval time.Duration) { if interval <= 0 { interval = roomRocketProgressFlushInterval } if err := s.FlushRoomRocketProgress(ctx); err != nil && ctx.Err() == nil { logx.Warn(ctx, "room_rocket_progress_flush_failed", slog.String("error", err.Error())) } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := s.FlushRoomRocketProgress(ctx); err != nil && ctx.Err() == nil { logx.Warn(ctx, "room_rocket_progress_flush_failed", slog.String("error", err.Error())) } } } }