// Package roomtime stores user-scoped in-room duration read models in MySQL. package roomtime import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" roomtimedomain "hyapp/services/user-service/internal/domain/roomtime" ) // Repository owns in-room session state and user duration aggregates inside user-service MySQL. type Repository struct { db *sql.DB } const ( defaultCompensationBatchSize = 200 defaultOpenSessionCompensationMs = int64(24 * 60 * 60 * 1000) userOutboxStatusPending = "pending" ) // New creates an in-room duration repository backed by the shared user-service connection pool. func New(db *sql.DB) *Repository { return &Repository{db: db} } // ProcessRoomPresenceEvent idempotently applies one RoomUserJoined/RoomUserLeft/RoomUserKicked fact. func (r *Repository) ProcessRoomPresenceEvent(ctx context.Context, event roomtimedomain.RoomPresenceEvent) (roomtimedomain.ApplyResult, error) { if r == nil || r.db == nil { return roomtimedomain.ApplyResult{}, xerr.New(xerr.Unavailable, "room time repository is not configured") } event = normalizeRoomPresenceEvent(event) if event.EventID == "" || event.EventType == "" { return roomtimedomain.ApplyResult{}, xerr.New(xerr.InvalidArgument, "room presence event identity is required") } if event.OccurredAtMs <= 0 { event.OccurredAtMs = time.Now().UnixMilli() } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return roomtimedomain.ApplyResult{}, err } defer tx.Rollback() if err := insertEventConsumption(ctx, tx, event); err != nil { if isDuplicate(err) { return roomtimedomain.ApplyResult{Skipped: true, Reason: "already_consumed"}, nil } return roomtimedomain.ApplyResult{}, err } var result roomtimedomain.ApplyResult switch { case event.RoomID == "" || event.UserID <= 0: result = roomtimedomain.ApplyResult{Skipped: true, Reason: "incomplete_presence_event"} case event.EventType == roomtimedomain.RoomUserJoinedEventType: result, err = applyJoined(ctx, tx, event) case event.EventType == roomtimedomain.RoomUserLeftEventType: result, err = applyLeft(ctx, tx, event, roomtimedomain.EndReasonLeft) case event.EventType == roomtimedomain.RoomUserKickedEventType: result, err = applyLeft(ctx, tx, event, roomtimedomain.EndReasonKicked) default: result = roomtimedomain.ApplyResult{Skipped: true, Reason: "unsupported_presence_event"} } if err != nil { return roomtimedomain.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 roomtimedomain.ApplyResult{}, err } if err := tx.Commit(); err != nil { return roomtimedomain.ApplyResult{}, err } if !result.Skipped { result.Consumed = true } return result, nil } // CompensateOpenSessions closes abnormal sessions whose authoritative leave event never reached user-service. func (r *Repository) CompensateOpenSessions(ctx context.Context, options roomtimedomain.CompensationOptions) (roomtimedomain.CompensationResult, error) { if r == nil || r.db == nil { return roomtimedomain.CompensationResult{}, xerr.New(xerr.Unavailable, "room time repository is not configured") } options = normalizeCompensationOptions(options) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return roomtimedomain.CompensationResult{}, err } defer tx.Rollback() cutoff := options.NowMs - options.OpenSessionMaxAgeMs app := appcode.FromContext(ctx) rows, err := tx.QueryContext(ctx, ` SELECT app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, last_room_version FROM user_room_sessions WHERE app_code = ? AND status = ? AND joined_at_ms <= ? ORDER BY joined_at_ms ASC, room_session_id ASC LIMIT ? FOR UPDATE SKIP LOCKED `, app, roomtimedomain.SessionStatusOpen, cutoff, options.BatchSize) if err != nil { return roomtimedomain.CompensationResult{}, err } candidates := make([]roomSessionRow, 0, options.BatchSize) for rows.Next() { var session roomSessionRow if err := rows.Scan(&session.AppCode, &session.RoomSessionID, &session.UserID, &session.RoomID, &session.Status, &session.IsRobot, &session.JoinedAtMs, &session.LastRoomVersion); err != nil { _ = rows.Close() return roomtimedomain.CompensationResult{}, err } candidates = append(candidates, session) } if err := rows.Close(); err != nil { return roomtimedomain.CompensationResult{}, err } if err := rows.Err(); err != nil { return roomtimedomain.CompensationResult{}, err } var result roomtimedomain.CompensationResult for _, session := range candidates { if err := closeRoomSession(ctx, tx, session, closeRoomSessionInput{ ClosedEventID: fmt.Sprintf("compensated:%s", session.RoomSessionID), EndAtMs: minInt64(options.NowMs, session.JoinedAtMs+options.OpenSessionMaxAgeMs), EndReason: roomtimedomain.CompensationReasonOpenSessionMaxWindow, UpdatedAtMs: options.NowMs, RoomVersion: session.LastRoomVersion, }); err != nil { return roomtimedomain.CompensationResult{}, err } result.ClosedCount++ } // 孤儿 Left 只用于修正乱序到达;超过补偿窗口后已不可能再匹配任何进房事实,按批清理。 deleted, err := tx.ExecContext(ctx, ` DELETE FROM user_room_orphan_leaves WHERE app_code = ? AND left_at_ms <= ? LIMIT ? `, app, cutoff, options.BatchSize) if err != nil { return roomtimedomain.CompensationResult{}, err } if affected, err := deleted.RowsAffected(); err == nil { result.OrphanLeavesDeleted = int(affected) } if err := tx.Commit(); err != nil { return roomtimedomain.CompensationResult{}, err } return result, nil } func applyJoined(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent) (roomtimedomain.ApplyResult, error) { openSessions, err := openSessionsForUpdate(ctx, tx, event.AppCode, event.UserID) if err != nil { return roomtimedomain.ApplyResult{}, err } for _, session := range openSessions { // 一个用户同一时刻只保留一个在房会话:房间侧一个在线周期只发一条 Joined, // 这里还有 open 会话说明它的 Left/Kicked 丢失或尚未到达,用新进房时刻闭合避免双计。 reason := roomtimedomain.EndReasonSupersededByJoin if session.RoomID == event.RoomID { reason = roomtimedomain.EndReasonSupersededByRejoin } if err := closeRoomSession(ctx, tx, session, closeRoomSessionInput{ ClosedEventID: event.EventID, EndAtMs: event.OccurredAtMs, EndReason: reason, UpdatedAtMs: event.OccurredAtMs, RoomVersion: event.RoomVersion, }); err != nil { return roomtimedomain.ApplyResult{}, err } } orphan, matched, err := matchOrphanLeave(ctx, tx, event) if err != nil { return roomtimedomain.ApplyResult{}, err } joinedSession := roomSessionRow{ AppCode: event.AppCode, RoomSessionID: event.EventID, UserID: event.UserID, RoomID: event.RoomID, Status: roomtimedomain.SessionStatusOpen, IsRobot: event.IsRobot, JoinedAtMs: event.OccurredAtMs, LastRoomVersion: event.RoomVersion, } if matched { // Left 先到、Joined 后到:直接落一条已闭合会话,时长取两个事实的间隔。 if err := insertRoomSession(ctx, tx, joinedSession, event, roomtimedomain.SessionStatusOpen); err != nil { return roomtimedomain.ApplyResult{}, err } if err := closeRoomSession(ctx, tx, joinedSession, closeRoomSessionInput{ ClosedEventID: orphan.EventID, EndAtMs: orphan.LeftAtMs, EndReason: roomtimedomain.EndReasonOrphanLeft, UpdatedAtMs: event.OccurredAtMs, RoomVersion: event.RoomVersion, }); err != nil { return roomtimedomain.ApplyResult{}, err } if _, err := tx.ExecContext(ctx, ` DELETE FROM user_room_orphan_leaves WHERE app_code = ? AND event_id = ? `, event.AppCode, orphan.EventID); err != nil { return roomtimedomain.ApplyResult{}, err } return roomtimedomain.ApplyResult{Consumed: true, Opened: true, Closed: true, Reason: "matched_orphan_left"}, nil } if err := insertRoomSession(ctx, tx, joinedSession, event, roomtimedomain.SessionStatusOpen); err != nil { return roomtimedomain.ApplyResult{}, err } return roomtimedomain.ApplyResult{Consumed: true, Opened: true}, nil } func applyLeft(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent, endReason string) (roomtimedomain.ApplyResult, error) { openSessions, err := openRoomSessionsForUpdate(ctx, tx, event.AppCode, event.RoomID, event.UserID) if err != nil { return roomtimedomain.ApplyResult{}, err } closed := 0 sawNewerSession := false for _, session := range openSessions { if session.JoinedAtMs > event.OccurredAtMs { // Left 早于当前 open 会话的进房时刻,说明它属于更早的在线周期;那个周期已被 supersede 闭合,直接丢弃。 sawNewerSession = true continue } if err := closeRoomSession(ctx, tx, session, closeRoomSessionInput{ ClosedEventID: event.EventID, EndAtMs: event.OccurredAtMs, EndReason: endReason, UpdatedAtMs: event.OccurredAtMs, RoomVersion: event.RoomVersion, }); err != nil { return roomtimedomain.ApplyResult{}, err } closed++ } if closed > 0 { return roomtimedomain.ApplyResult{Consumed: true, Closed: true}, nil } if sawNewerSession { return roomtimedomain.ApplyResult{Skipped: true, Reason: "stale_left_event"}, nil } // 没有可闭合的会话:Left 可能先于 Joined 到达,暂存等 Joined 到达时直接生成闭合会话。 // 踢人对已离房用户也会发 RoomUserKicked,这类孤儿记录不会匹配任何更晚的进房事实,由补偿任务清理。 if _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO user_room_orphan_leaves (app_code, event_id, room_id, user_id, left_at_ms, reason, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?) `, event.AppCode, event.EventID, event.RoomID, event.UserID, event.OccurredAtMs, endReason, event.OccurredAtMs); err != nil { return roomtimedomain.ApplyResult{}, err } return roomtimedomain.ApplyResult{Skipped: true, Reason: "left_before_join_buffered"}, nil } type closeRoomSessionInput struct { ClosedEventID string EndAtMs int64 EndReason string UpdatedAtMs int64 RoomVersion int64 } func closeRoomSession(ctx context.Context, tx *sql.Tx, session roomSessionRow, input closeRoomSessionInput) error { input.ClosedEventID = strings.TrimSpace(input.ClosedEventID) if input.ClosedEventID == "" { input.ClosedEventID = "closed:" + session.RoomSessionID } input.EndReason = strings.TrimSpace(input.EndReason) if input.EndReason == "" { input.EndReason = roomtimedomain.EndReasonLeft } if input.EndAtMs < session.JoinedAtMs { input.EndAtMs = session.JoinedAtMs } if input.UpdatedAtMs <= 0 { input.UpdatedAtMs = input.EndAtMs } roomOnlineMs := maxInt64(0, input.EndAtMs-session.JoinedAtMs) _, err := tx.ExecContext(ctx, ` UPDATE user_room_sessions SET status = ?, ended_at_ms = ?, end_reason = ?, room_online_ms = ?, closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? WHERE app_code = ? AND room_session_id = ? AND status <> ? `, roomtimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, roomOnlineMs, input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs, session.AppCode, session.RoomSessionID, roomtimedomain.SessionStatusEnded) if err != nil { return err } if session.IsRobot { // 机器人在房不产生活跃/时长统计事实;会话仍闭合留档,方便排查调度行为。 return nil } return incrementDaily(ctx, tx, session, input) } func incrementDaily(ctx context.Context, tx *sql.Tx, session roomSessionRow, input closeRoomSessionInput) error { for date, part := range splitByUTCDay(session.JoinedAtMs, input.EndAtMs) { _, err := tx.ExecContext(ctx, ` INSERT INTO user_room_daily_stats ( app_code, user_id, stat_date, room_online_ms, session_count, first_room_at_ms, last_room_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 1, ?, ?, ?) ON DUPLICATE KEY UPDATE room_online_ms = room_online_ms + VALUES(room_online_ms), session_count = session_count + 1, first_room_at_ms = CASE WHEN first_room_at_ms = 0 OR first_room_at_ms > VALUES(first_room_at_ms) THEN VALUES(first_room_at_ms) ELSE first_room_at_ms END, last_room_at_ms = GREATEST(last_room_at_ms, VALUES(last_room_at_ms)), updated_at_ms = VALUES(updated_at_ms) `, session.AppCode, session.UserID, date, part.DurationMs, part.FirstAtMs, part.LastAtMs, input.UpdatedAtMs) if err != nil { return err } if err := insertUserOutbox(ctx, tx, session.AppCode, roomtimedomain.EventTypeUserRoomDailyStatsUpdated+":"+session.RoomSessionID+":"+date, roomtimedomain.EventTypeUserRoomDailyStatsUpdated, "user_room_daily_stats", session.UserID, input.UpdatedAtMs, map[string]any{ "app_code": session.AppCode, "user_id": session.UserID, "room_id": session.RoomID, "stat_date": date, "room_session_id": session.RoomSessionID, "room_online_delta_ms": part.DurationMs, "session_count_delta": 1, "end_reason": input.EndReason, "updated_at_ms": input.UpdatedAtMs, }); err != nil { return err } } return nil } type roomSessionRow struct { AppCode string RoomSessionID string UserID int64 RoomID string Status string IsRobot bool JoinedAtMs int64 LastRoomVersion int64 } type orphanLeaveRow struct { EventID string LeftAtMs int64 } func openSessionsForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64) ([]roomSessionRow, error) { return scanSessions(tx.QueryContext(ctx, ` SELECT app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, last_room_version FROM user_room_sessions WHERE app_code = ? AND user_id = ? AND status = ? ORDER BY joined_at_ms ASC, room_session_id ASC FOR UPDATE `, appCode, userID, roomtimedomain.SessionStatusOpen)) } func openRoomSessionsForUpdate(ctx context.Context, tx *sql.Tx, appCode string, roomID string, userID int64) ([]roomSessionRow, error) { return scanSessions(tx.QueryContext(ctx, ` SELECT app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, last_room_version FROM user_room_sessions WHERE app_code = ? AND user_id = ? AND room_id = ? AND status = ? ORDER BY joined_at_ms ASC, room_session_id ASC FOR UPDATE `, appCode, userID, roomID, roomtimedomain.SessionStatusOpen)) } func scanSessions(rows *sql.Rows, err error) ([]roomSessionRow, error) { if err != nil { return nil, err } defer rows.Close() sessions := []roomSessionRow{} for rows.Next() { var session roomSessionRow if err := rows.Scan(&session.AppCode, &session.RoomSessionID, &session.UserID, &session.RoomID, &session.Status, &session.IsRobot, &session.JoinedAtMs, &session.LastRoomVersion); err != nil { return nil, err } sessions = append(sessions, session) } return sessions, rows.Err() } func matchOrphanLeave(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent) (orphanLeaveRow, bool, error) { var orphan orphanLeaveRow err := tx.QueryRowContext(ctx, ` SELECT event_id, left_at_ms FROM user_room_orphan_leaves WHERE app_code = ? AND room_id = ? AND user_id = ? AND left_at_ms >= ? ORDER BY left_at_ms ASC LIMIT 1 FOR UPDATE `, event.AppCode, event.RoomID, event.UserID, event.OccurredAtMs).Scan(&orphan.EventID, &orphan.LeftAtMs) if err == sql.ErrNoRows { return orphanLeaveRow{}, false, nil } if err != nil { return orphanLeaveRow{}, false, err } return orphan, true, nil } func insertRoomSession(ctx context.Context, tx *sql.Tx, session roomSessionRow, event roomtimedomain.RoomPresenceEvent, status string) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_room_sessions ( app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, ended_at_ms, end_reason, room_online_ms, opened_event_id, closed_event_id, last_event_id, last_room_version, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, '', 0, ?, '', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE 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) `, session.AppCode, session.RoomSessionID, session.UserID, session.RoomID, status, session.IsRobot, session.JoinedAtMs, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.OccurredAtMs) return err } func insertEventConsumption(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_room_event_consumption ( app_code, event_id, event_type, room_id, user_id, status, skip_reason, created_at_ms, consumed_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 'processing', '', ?, NULL, ?) `, event.AppCode, event.EventID, event.EventType, event.RoomID, event.UserID, event.OccurredAtMs, event.OccurredAtMs) return err } func finishEventConsumption(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent, status string, reason string) error { _, err := tx.ExecContext(ctx, ` UPDATE user_room_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 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 normalizeRoomPresenceEvent(event roomtimedomain.RoomPresenceEvent) roomtimedomain.RoomPresenceEvent { event.AppCode = appcode.Normalize(event.AppCode) event.EventID = strings.TrimSpace(event.EventID) event.EventType = strings.TrimSpace(event.EventType) event.RoomID = strings.TrimSpace(event.RoomID) return event } func normalizeCompensationOptions(options roomtimedomain.CompensationOptions) roomtimedomain.CompensationOptions { if options.NowMs <= 0 { options.NowMs = time.Now().UnixMilli() } if options.BatchSize <= 0 { options.BatchSize = defaultCompensationBatchSize } if options.OpenSessionMaxAgeMs <= 0 { options.OpenSessionMaxAgeMs = defaultOpenSessionCompensationMs } return options } 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 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 }