// Package mictime runs room mic outbox consumers and exposes mic duration queries. package mictime import ( "context" "log/slog" "time" "hyapp/pkg/logx" "hyapp/pkg/xerr" mictimedomain "hyapp/services/user-service/internal/domain/mictime" ) // Repository applies room mic events and returns user duration aggregates. type Repository interface { ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) CompensateOpenSessions(ctx context.Context, options mictimedomain.CompensationOptions) (mictimedomain.CompensationResult, error) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) } // RoomOutboxReader scans room-service RoomMicChanged facts in creation order. type RoomOutboxReader interface { ListRoomMicEventsAfter(ctx context.Context, cursor mictimedomain.RoomOutboxCursor, batchSize int) ([]mictimedomain.RoomMicEvent, error) } // Service owns the user mic duration read model. type Service struct { repository Repository reader RoomOutboxReader } // WorkerOptions controls local room outbox polling. type WorkerOptions struct { WorkerID string PollInterval time.Duration BatchSize int } // CompensationWorkerOptions controls abnormal open session closure. type CompensationWorkerOptions struct { WorkerID string BatchSize int PendingPublishMaxAge time.Duration PublishingSessionMaxAge time.Duration } // New creates a mic time service with persistence and optional room outbox reader. func New(repository Repository, reader RoomOutboxReader) *Service { return &Service{repository: repository, reader: reader} } // GetLifetimeStats returns user-wide accumulated mic duration. func (s *Service) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) { if s == nil || s.repository == nil { return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") } return s.repository.GetLifetimeStats(ctx, appCode, userID) } // RunRoomMicWorker consumes RoomMicChanged facts from room-service outbox. func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) { if s == nil || s.repository == nil || s.reader == nil { logx.Warn(ctx, "worker_disabled", slog.String("worker", "room_mic"), slog.String("component", "user_mic_time"), slog.String("reason", "missing_dependency")) return } if options.PollInterval <= 0 { options.PollInterval = 5 * time.Second } if options.BatchSize <= 0 { options.BatchSize = 100 } var cursor mictimedomain.RoomOutboxCursor for { processed, err := s.processBatch(ctx, &cursor, options.BatchSize) if err != nil { logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "room_mic"), slog.String("component", "user_mic_time"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize)) } if ctx.Err() != nil { return } if processed >= options.BatchSize { // Backlog is still present; continue without sleeping until the cursor catches up. continue } select { case <-ctx.Done(): return case <-time.After(options.PollInterval): } } } // ProcessOpenSessionCompensationBatch exposes one bounded compensation scan for cron-service. func (s *Service) ProcessOpenSessionCompensationBatch(ctx context.Context, options CompensationWorkerOptions) (mictimedomain.CompensationResult, error) { if s == nil || s.repository == nil { return mictimedomain.CompensationResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") } if options.BatchSize <= 0 { options.BatchSize = 100 } if options.PendingPublishMaxAge <= 0 { options.PendingPublishMaxAge = 2 * time.Minute } if options.PublishingSessionMaxAge <= 0 { options.PublishingSessionMaxAge = 12 * time.Hour } return s.repository.CompensateOpenSessions(ctx, mictimedomain.CompensationOptions{ NowMs: time.Now().UnixMilli(), BatchSize: options.BatchSize, PendingPublishMaxAgeMs: options.PendingPublishMaxAge.Milliseconds(), PublishingSessionMaxAgeMs: options.PublishingSessionMaxAge.Milliseconds(), }) } func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOutboxCursor, batchSize int) (int, error) { events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize) if err != nil { return 0, err } for _, event := range events { if _, err := s.repository.ProcessRoomMicEvent(ctx, event); err != nil { return 0, err } cursor.CreatedAtMs = event.OutboxCreatedAtMs cursor.EventID = event.EventID } return len(events), nil }