// Package mictime runs room mic outbox consumers and exposes mic duration queries. package mictime import ( "context" "log" "time" "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) 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 } // 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 { log.Printf("component=user_mic_time op=room_mic_worker status=disabled 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 { log.Printf("component=user_mic_time op=room_mic_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error()) } 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): } } } 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 }