544 lines
20 KiB
Go
544 lines
20 KiB
Go
// 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
|
||
}
|