544 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}