// Package mictime stores user-scoped microphone online duration read models in MySQL. package mictime import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" mictimedomain "hyapp/services/user-service/internal/domain/mictime" ) // Repository owns mic session state and user duration aggregates inside user-service MySQL. type Repository struct { db *sql.DB } const ( defaultCompensationBatchSize = 100 defaultPendingPublishCompensationMaxAgeMs = int64(2 * 60 * 1000) defaultPublishingCompensationMaxAgeMs = int64(12 * 60 * 60 * 1000) userOutboxStatusPending = "pending" ) // New creates a mic duration repository backed by the shared user-service connection pool. func New(db *sql.DB) *Repository { return &Repository{db: db} } // ProcessRoomMicEvent idempotently applies one RoomMicChanged fact. func (r *Repository) ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { if r == nil || r.db == nil { return mictimedomain.ApplyResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") } event = normalizeRoomMicEvent(event) if event.EventID == "" || event.EventType == "" { return mictimedomain.ApplyResult{}, xerr.New(xerr.InvalidArgument, "room mic event identity is required") } if event.OccurredAtMs <= 0 { event.OccurredAtMs = time.Now().UnixMilli() } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return mictimedomain.ApplyResult{}, err } defer tx.Rollback() if err := insertEventConsumption(ctx, tx, event); err != nil { if isDuplicate(err) { return mictimedomain.ApplyResult{Skipped: true, Reason: "already_consumed"}, nil } return mictimedomain.ApplyResult{}, err } if event.EventType != mictimedomain.RoomMicChangedEventType { if err := finishEventConsumption(ctx, tx, event, "skipped", "not_room_mic_changed"); err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Skipped: true, Reason: "not_room_mic_changed"}, tx.Commit() } if event.MicSessionID == "" || event.TargetUserID <= 0 { if err := finishEventConsumption(ctx, tx, event, "skipped", "incomplete_mic_event"); err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Skipped: true, Reason: "incomplete_mic_event"}, tx.Commit() } var result mictimedomain.ApplyResult switch event.Action { case mictimedomain.ActionUp: result, err = applyUp(ctx, tx, event) case mictimedomain.ActionPublishConfirmed: result, err = applyPublishConfirmed(ctx, tx, event) case mictimedomain.ActionChange: result, err = applyChange(ctx, tx, event) case mictimedomain.ActionDown: result, err = applyDown(ctx, tx, event) default: result = mictimedomain.ApplyResult{Skipped: true, Reason: "unsupported_mic_action"} } if err != nil { return mictimedomain.ApplyResult{}, err } if result.Reason == "" && result.Skipped { result.Reason = "skipped" } status := "consumed" if result.Skipped { status = "skipped" } if err := finishEventConsumption(ctx, tx, event, status, result.Reason); err != nil { return mictimedomain.ApplyResult{}, err } if err := tx.Commit(); err != nil { return mictimedomain.ApplyResult{}, err } if !result.Skipped { result.Consumed = true } return result, nil } // GetLifetimeStats returns the current user-wide mic duration aggregate. func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) { if r == nil || r.db == nil { return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") } if userID <= 0 { return mictimedomain.LifetimeStats{}, xerr.New(xerr.InvalidArgument, "user_id is required") } stats := mictimedomain.LifetimeStats{AppCode: appcode.Normalize(appCode), UserID: userID} err := r.db.QueryRowContext(ctx, ` SELECT seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms FROM user_mic_lifetime_stats WHERE app_code = ? AND user_id = ? `, stats.AppCode, userID).Scan(&stats.SeatOccupiedMs, &stats.MicOnlineMs, &stats.SessionCount, &stats.FirstMicAtMs, &stats.LastMicAtMs, &stats.UpdatedAtMs) if err == sql.ErrNoRows { return stats, nil } if err != nil { return mictimedomain.LifetimeStats{}, err } return stats, nil } // CompensateOpenSessions closes abnormal sessions whose authoritative down event never reached user-service. func (r *Repository) CompensateOpenSessions(ctx context.Context, options mictimedomain.CompensationOptions) (mictimedomain.CompensationResult, error) { if r == nil || r.db == nil { return mictimedomain.CompensationResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") } options = normalizeCompensationOptions(options) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return mictimedomain.CompensationResult{}, err } defer tx.Rollback() pendingCutoff := options.NowMs - options.PendingPublishMaxAgeMs publishingCutoff := options.NowMs - options.PublishingSessionMaxAgeMs app := appcode.FromContext(ctx) rows, err := tx.QueryContext(ctx, ` SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms, publishing_started_at_ms, ended_at_ms, last_room_version FROM user_mic_sessions WHERE app_code = ? AND (( status = ? AND seat_started_at_ms <= ? ) OR ( status = ? AND seat_started_at_ms <= ? )) ORDER BY seat_started_at_ms ASC, mic_session_id ASC LIMIT ? FOR UPDATE SKIP LOCKED `, app, mictimedomain.SessionStatusPendingPublish, pendingCutoff, mictimedomain.SessionStatusPublishing, publishingCutoff, options.BatchSize) if err != nil { return mictimedomain.CompensationResult{}, err } candidates := make([]micSessionRow, 0, options.BatchSize) for rows.Next() { var session micSessionRow if err := rows.Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status, &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion); err != nil { _ = rows.Close() return mictimedomain.CompensationResult{}, err } candidates = append(candidates, session) } if err := rows.Close(); err != nil { return mictimedomain.CompensationResult{}, err } if err := rows.Err(); err != nil { return mictimedomain.CompensationResult{}, err } var result mictimedomain.CompensationResult for _, session := range candidates { input := closeMicSessionInput{ ClosedEventID: fmt.Sprintf("compensated:%s", session.MicSessionID), EndAtMs: compensationEndAt(session, options), UpdatedAtMs: options.NowMs, RoomVersion: session.LastRoomVersion, } switch session.Status { case mictimedomain.SessionStatusPendingPublish: input.EndReason = mictimedomain.CompensationReasonPendingPublishTimeout result.PendingPublishClosed++ case mictimedomain.SessionStatusPublishing: input.EndReason = mictimedomain.CompensationReasonPublishingMaxWindow result.PublishingSessionClosed++ default: continue } if err := closeMicSession(ctx, tx, session, input); err != nil { return mictimedomain.CompensationResult{}, err } result.ClosedCount++ } if err := tx.Commit(); err != nil { return mictimedomain.CompensationResult{}, err } return result, nil } func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { seatNo := event.ToSeat if seatNo <= 0 { seatNo = event.FromSeat } if seatNo <= 0 { return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_seat_no"}, nil } _, err := tx.ExecContext(ctx, ` INSERT INTO user_mic_sessions ( app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms, publishing_started_at_ms, ended_at_ms, end_reason, seat_occupied_ms, mic_online_ms, opened_event_id, publish_event_id, closed_event_id, last_event_id, last_room_version, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, '', 0, 0, ?, '', '', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE current_seat_no = CASE WHEN status = ? THEN current_seat_no ELSE VALUES(current_seat_no) END, last_event_id = VALUES(last_event_id), last_room_version = GREATEST(last_room_version, VALUES(last_room_version)), updated_at_ms = VALUES(updated_at_ms) `, event.AppCode, event.MicSessionID, event.TargetUserID, event.RoomID, seatNo, seatNo, mictimedomain.SessionStatusPendingPublish, event.OccurredAtMs, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.OccurredAtMs, mictimedomain.SessionStatusEnded) if err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Consumed: true}, nil } func applyPublishConfirmed(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID) if err != nil || !exists { if err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil } if session.UserID != event.TargetUserID { return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil } if session.Status == mictimedomain.SessionStatusEnded { return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil } publishAt := event.PublishEventTimeMs if publishAt <= 0 { publishAt = event.OccurredAtMs } if publishAt < session.SeatStartedAtMs { // RTC/client clocks can arrive slightly earlier than server MicUp time; clamp to avoid negative online windows. publishAt = session.SeatStartedAtMs } if session.PublishingStartedAtMs.Valid && publishAt <= session.PublishingStartedAtMs.V { if err := updateSessionLastEvent(ctx, tx, event); err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Skipped: true, Reason: "publish_already_confirmed"}, nil } _, err = tx.ExecContext(ctx, ` UPDATE user_mic_sessions SET status = ?, publishing_started_at_ms = ?, publish_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? WHERE app_code = ? AND mic_session_id = ? AND status <> ? `, mictimedomain.SessionStatusPublishing, publishAt, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded) if err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Consumed: true}, nil } func applyChange(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID) if err != nil || !exists { if err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil } if session.UserID != event.TargetUserID { return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil } if session.Status == mictimedomain.SessionStatusEnded { return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil } if event.ToSeat <= 0 { return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_target_seat"}, nil } _, err = tx.ExecContext(ctx, ` UPDATE user_mic_sessions SET current_seat_no = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? WHERE app_code = ? AND mic_session_id = ? AND status <> ? `, event.ToSeat, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded) if err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Consumed: true}, nil } func applyDown(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID) if err != nil || !exists { if err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil } if session.UserID != event.TargetUserID { return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil } if session.Status == mictimedomain.SessionStatusEnded { return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil } endReason := strings.TrimSpace(event.Reason) if endReason == "" { endReason = "down" } if err := closeMicSession(ctx, tx, session, closeMicSessionInput{ ClosedEventID: event.EventID, EndAtMs: event.OccurredAtMs, EndReason: endReason, UpdatedAtMs: event.OccurredAtMs, RoomVersion: event.RoomVersion, }); err != nil { return mictimedomain.ApplyResult{}, err } return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil } type closeMicSessionInput struct { ClosedEventID string EndAtMs int64 EndReason string UpdatedAtMs int64 RoomVersion int64 } func closeMicSession(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error { input.ClosedEventID = strings.TrimSpace(input.ClosedEventID) if input.ClosedEventID == "" { input.ClosedEventID = "closed:" + session.MicSessionID } input.EndReason = strings.TrimSpace(input.EndReason) if input.EndReason == "" { input.EndReason = "down" } if input.EndAtMs < session.SeatStartedAtMs { input.EndAtMs = session.SeatStartedAtMs } if input.UpdatedAtMs <= 0 { input.UpdatedAtMs = input.EndAtMs } seatOccupiedMs := maxInt64(0, input.EndAtMs-session.SeatStartedAtMs) micOnlineMs := int64(0) if session.PublishingStartedAtMs.Valid { publishStart := max(session.PublishingStartedAtMs.V, session.SeatStartedAtMs) micOnlineMs = maxInt64(0, input.EndAtMs-publishStart) } _, err := tx.ExecContext(ctx, ` UPDATE user_mic_sessions SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?, closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? WHERE app_code = ? AND mic_session_id = ? AND status <> ? `, mictimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, seatOccupiedMs, micOnlineMs, input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs, session.AppCode, session.MicSessionID, mictimedomain.SessionStatusEnded) if err != nil { return err } if err := incrementLifetime(ctx, tx, session, input, seatOccupiedMs, micOnlineMs); err != nil { return err } if err := incrementDaily(ctx, tx, session, input); err != nil { return err } return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicSessionClosed, session.MicSessionID, ""), mictimedomain.EventTypeUserMicSessionClosed, "user_mic_session", session.UserID, input.UpdatedAtMs, map[string]any{ "app_code": session.AppCode, "user_id": session.UserID, "room_id": session.RoomID, "mic_session_id": session.MicSessionID, "seat_occupied_ms": seatOccupiedMs, "mic_online_ms": micOnlineMs, "seat_started_at_ms": session.SeatStartedAtMs, "ended_at_ms": input.EndAtMs, "end_reason": input.EndReason, "closed_event_id": input.ClosedEventID, }) } func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput, seatMs int64, micMs int64) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_mic_lifetime_stats ( app_code, user_id, seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 1, ?, ?, ?) ON DUPLICATE KEY UPDATE seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms), mic_online_ms = mic_online_ms + VALUES(mic_online_ms), session_count = session_count + 1, first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END, last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)), updated_at_ms = VALUES(updated_at_ms) `, session.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, input.EndAtMs, input.UpdatedAtMs) if err != nil { return err } return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicLifetimeStatsUpdated, session.MicSessionID, ""), mictimedomain.EventTypeUserMicLifetimeStatsUpdated, "user_mic_lifetime_stats", session.UserID, input.UpdatedAtMs, map[string]any{ "app_code": session.AppCode, "user_id": session.UserID, "mic_session_id": session.MicSessionID, "seat_occupied_delta_ms": seatMs, "mic_online_delta_ms": micMs, "session_count_delta": 1, "updated_at_ms": input.UpdatedAtMs, }) } func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error { seatParts := splitByUTCDay(session.SeatStartedAtMs, input.EndAtMs) micParts := map[string]dayDuration{} if session.PublishingStartedAtMs.Valid { publishStart := max(session.PublishingStartedAtMs.V, session.SeatStartedAtMs) micParts = splitByUTCDay(publishStart, input.EndAtMs) } dates := map[string]struct{}{} for date := range seatParts { dates[date] = struct{}{} } for date := range micParts { dates[date] = struct{}{} } for date := range dates { seatPart := seatParts[date] micPart := micParts[date] firstAt := minPositive(seatPart.FirstAtMs, micPart.FirstAtMs) lastAt := maxInt64(seatPart.LastAtMs, micPart.LastAtMs) if firstAt == 0 || lastAt == 0 { continue } sessionCount := int64(0) roomCount := int64(0) if seatPart.DurationMs > 0 { // A cross-day session is visible on every day it occupied a seat. sessionCount = 1 roomCount = 1 } _, err := tx.ExecContext(ctx, ` INSERT INTO user_mic_daily_stats ( app_code, user_id, stat_date, seat_occupied_ms, mic_online_ms, session_count, room_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms), mic_online_ms = mic_online_ms + VALUES(mic_online_ms), session_count = session_count + VALUES(session_count), room_count = room_count + VALUES(room_count), first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END, last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)), updated_at_ms = VALUES(updated_at_ms) `, session.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, input.UpdatedAtMs) if err != nil { return err } if err := insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicDailyStatsUpdated, session.MicSessionID, date), mictimedomain.EventTypeUserMicDailyStatsUpdated, "user_mic_daily_stats", session.UserID, input.UpdatedAtMs, map[string]any{ "app_code": session.AppCode, "user_id": session.UserID, "stat_date": date, "mic_session_id": session.MicSessionID, "seat_occupied_delta_ms": seatPart.DurationMs, "mic_online_delta_ms": micPart.DurationMs, "session_count_delta": sessionCount, "room_count_delta": roomCount, "updated_at_ms": input.UpdatedAtMs, }); err != nil { return err } } return nil } type micSessionRow struct { AppCode string MicSessionID string UserID int64 RoomID string FirstSeatNo int32 CurrentSeatNo int32 Status string SeatStartedAtMs int64 PublishingStartedAtMs sql.Null[int64] EndedAtMs sql.Null[int64] LastRoomVersion int64 } func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) { var session micSessionRow err := tx.QueryRowContext(ctx, ` SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms, publishing_started_at_ms, ended_at_ms, last_room_version FROM user_mic_sessions WHERE app_code = ? AND mic_session_id = ? FOR UPDATE `, appCode, micSessionID).Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status, &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion) if err == sql.ErrNoRows { return micSessionRow{}, false, nil } if err != nil { return micSessionRow{}, false, err } return session, true, nil } func updateSessionLastEvent(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error { _, err := tx.ExecContext(ctx, ` UPDATE user_mic_sessions SET last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? WHERE app_code = ? AND mic_session_id = ? `, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID) return err } func insertEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_mic_event_consumption ( app_code, event_id, event_type, mic_session_id, user_id, action, status, skip_reason, created_at_ms, consumed_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 'processing', '', ?, NULL, ?) `, event.AppCode, event.EventID, event.EventType, event.MicSessionID, event.TargetUserID, event.Action, event.OccurredAtMs, event.OccurredAtMs) return err } func finishEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent, status string, reason string) error { _, err := tx.ExecContext(ctx, ` UPDATE user_mic_event_consumption SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ? `, status, strings.TrimSpace(reason), event.OccurredAtMs, event.OccurredAtMs, event.AppCode, event.EventID) return err } func normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomMicEvent { event.AppCode = appcode.Normalize(event.AppCode) event.EventID = strings.TrimSpace(event.EventID) event.EventType = strings.TrimSpace(event.EventType) event.RoomID = strings.TrimSpace(event.RoomID) event.Action = strings.TrimSpace(event.Action) event.MicSessionID = strings.TrimSpace(event.MicSessionID) event.PublishState = strings.TrimSpace(event.PublishState) event.Reason = strings.TrimSpace(event.Reason) return event } func normalizeCompensationOptions(options mictimedomain.CompensationOptions) mictimedomain.CompensationOptions { if options.NowMs <= 0 { options.NowMs = time.Now().UnixMilli() } if options.BatchSize <= 0 { options.BatchSize = defaultCompensationBatchSize } if options.PendingPublishMaxAgeMs <= 0 { options.PendingPublishMaxAgeMs = defaultPendingPublishCompensationMaxAgeMs } if options.PublishingSessionMaxAgeMs <= 0 { options.PublishingSessionMaxAgeMs = defaultPublishingCompensationMaxAgeMs } return options } func compensationEndAt(session micSessionRow, options mictimedomain.CompensationOptions) int64 { switch session.Status { case mictimedomain.SessionStatusPendingPublish: return minInt64(options.NowMs, session.SeatStartedAtMs+options.PendingPublishMaxAgeMs) case mictimedomain.SessionStatusPublishing: return minInt64(options.NowMs, session.SeatStartedAtMs+options.PublishingSessionMaxAgeMs) default: return options.NowMs } } func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventID string, eventType string, aggregateType string, aggregateID int64, createdAtMs int64, payload map[string]any) error { payloadBytes, err := json.Marshal(payload) if err != nil { return err } _, err = tx.ExecContext(ctx, ` INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.Normalize(appCode), eventID, eventType, aggregateType, aggregateID, userOutboxStatusPending, string(payloadBytes), createdAtMs, createdAtMs) return err } func userMicOutboxEventID(eventType string, micSessionID string, suffix string) string { if strings.TrimSpace(suffix) == "" { return eventType + ":" + micSessionID } return eventType + ":" + micSessionID + ":" + suffix } type dayDuration struct { DurationMs int64 FirstAtMs int64 LastAtMs int64 } func splitByUTCDay(startMs int64, endMs int64) map[string]dayDuration { parts := map[string]dayDuration{} if endMs <= startMs { return parts } for cursor := startMs; cursor < endMs; { cursorTime := time.UnixMilli(cursor).UTC() dayStart := time.Date(cursorTime.Year(), cursorTime.Month(), cursorTime.Day(), 0, 0, 0, 0, time.UTC) nextDayMs := dayStart.AddDate(0, 0, 1).UnixMilli() segmentEnd := minInt64(endMs, nextDayMs) date := dayStart.Format("2006-01-02") part := parts[date] if part.FirstAtMs == 0 || cursor < part.FirstAtMs { part.FirstAtMs = cursor } if segmentEnd > part.LastAtMs { part.LastAtMs = segmentEnd } part.DurationMs += segmentEnd - cursor parts[date] = part cursor = segmentEnd } return parts } func isDuplicate(err error) bool { var mysqlErr *mysqldriver.MySQLError return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 } func minPositive(a int64, b int64) int64 { if a <= 0 { return b } if b <= 0 || a < b { return a } return b } func minInt64(a int64, b int64) int64 { if a < b { return a } return b } func maxInt64(a int64, b int64) int64 { if a > b { return a } return b }