// Package mysql 实现 room-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "errors" "fmt" "strings" "google.golang.org/protobuf/proto" "hyapp/pkg/appcode" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" roomservice "hyapp/services/room-service/internal/room/service" ) // GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。 func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) { // 幂等检查只看 command log,只有成功提交过的命令才算 seen。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms FROM room_command_log WHERE app_code = ? AND room_id = ? AND command_id = ? LIMIT 1`, appcode.FromContext(ctx), roomID, commandID, ) var record roomservice.CommandRecord if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { // 没有命令日志表示本命令尚未成功提交。 return roomservice.CommandRecord{}, false, nil } return roomservice.CommandRecord{}, false, err } return record, true, nil } // SaveCommand 追加一条成功命令日志。 func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error { // ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。 appCode := normalizedRecordAppCode(ctx, record.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE command_id = command_id`, appCode, record.RoomID, record.RoomVersion, record.CommandID, record.CommandType, record.OwnerNodeID, record.LeaseToken, record.Payload, record.Replayable, record.CreatedAtMS, ) return err } // SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。 func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error { appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } if err := r.lockRoomMutation(ctx, tx, appCode, commit.Command.RoomID); err != nil { _ = tx.Rollback() return err } if commit.Command.Replayable && enforceUniqueReplayableRoomVersion(commit.Command.CommandType) { var existingCommandID string err := tx.QueryRowContext(ctx, `SELECT command_id FROM room_command_log FORCE INDEX (idx_room_command_replay) WHERE app_code = ? AND room_id = ? AND room_version = ? AND replayable = TRUE AND command_id <> ? LIMIT 1 FOR UPDATE`, appCode, commit.Command.RoomID, commit.Command.RoomVersion, commit.Command.CommandID, ).Scan(&existingCommandID) if err != nil && !errors.Is(err, sql.ErrNoRows) { _ = tx.Rollback() return err } if existingCommandID != "" { _ = tx.Rollback() // 同房间同版本只能有一个可回放状态变更;这是恢复链路的最后一道持久化防线。 return fmt.Errorf("room command version conflict: room_id=%s room_version=%d existing_command_id=%s new_command_id=%s", commit.Command.RoomID, commit.Command.RoomVersion, existingCommandID, commit.Command.CommandID) } } if _, err := tx.ExecContext(ctx, `INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE command_id = command_id`, appCode, commit.Command.RoomID, commit.Command.RoomVersion, commit.Command.CommandID, commit.Command.CommandType, commit.Command.OwnerNodeID, commit.Command.LeaseToken, commit.Command.Payload, commit.Command.Replayable, commit.Command.CreatedAtMS, ); err != nil { _ = tx.Rollback() return err } if err := r.insertRoomOutboxRecords(ctx, tx, commit.OutboxRecords); err != nil { // 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。 _ = tx.Rollback() return err } if commit.DeleteRoom { if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil { _ = tx.Rollback() return err } return tx.Commit() } if strings.TrimSpace(commit.RoomStatus) != "" { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET status = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, commit.RoomStatus, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET status = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, commit.RoomStatus, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.RoomSeatCount != nil { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET seat_count = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomSeatCount, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET seat_count = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomSeatCount, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.RoomPasswordHash != nil { locked := strings.TrimSpace(*commit.RoomPasswordHash) != "" if _, err := tx.ExecContext(ctx, `UPDATE rooms SET room_password_hash = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomPasswordHash, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET locked = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, locked, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.RoomMode != nil { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET mode = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomMode, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.VisibleRegionID != nil { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET visible_region_id = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.VisibleRegionID, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.CloseInfo != nil { info := commit.CloseInfo reason, adminName, closedAt := info.Reason, info.AdminName, info.ClosedAtMS adminID := info.AdminID if info.ClearOnOpen { reason, adminName, closedAt, adminID = "", "", 0, 0 } for _, table := range []string{"rooms", "room_list_entries"} { if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET close_reason = ?, closed_by_admin_id = ?, closed_by_admin_name = ?, closed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, reason, adminID, adminName, closedAt, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } } for _, stat := range commit.RoomUserGiftStats { if stat.UserID <= 0 || stat.GiftValue <= 0 { continue } statAppCode := normalizedRecordAppCode(ctx, stat.AppCode) statRoomID := strings.TrimSpace(stat.RoomID) if statRoomID == "" { statRoomID = commit.Command.RoomID } if _, err := tx.ExecContext(ctx, `INSERT INTO room_user_gift_stats ( app_code, room_id, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE gift_value = gift_value + VALUES(gift_value), last_gift_at_ms = VALUES(last_gift_at_ms), updated_at_ms = VALUES(updated_at_ms)`, statAppCode, statRoomID, stat.UserID, stat.GiftValue, stat.LastGiftAtMS, commit.Command.CreatedAtMS, commit.Command.CreatedAtMS, ); err != nil { _ = tx.Rollback() return err } } for _, stat := range commit.RoomUserContributionPeriodStats { if stat.UserID <= 0 || stat.GiftValue <= 0 || strings.TrimSpace(stat.Period) == "" || stat.PeriodStartMS <= 0 { continue } statAppCode := normalizedRecordAppCode(ctx, stat.AppCode) statRoomID := strings.TrimSpace(stat.RoomID) if statRoomID == "" { statRoomID = commit.Command.RoomID } if _, err := tx.ExecContext(ctx, `INSERT INTO room_user_contribution_period_stats ( app_code, room_id, period, period_start_ms, period_end_ms, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE gift_value = gift_value + VALUES(gift_value), period_end_ms = VALUES(period_end_ms), last_gift_at_ms = VALUES(last_gift_at_ms), updated_at_ms = VALUES(updated_at_ms)`, statAppCode, statRoomID, strings.TrimSpace(stat.Period), stat.PeriodStartMS, stat.PeriodEndMS, stat.UserID, stat.GiftValue, stat.LastGiftAtMS, commit.Command.CreatedAtMS, commit.Command.CreatedAtMS, ); err != nil { _ = tx.Rollback() return err } } if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 { weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode) weeklyRoomID := strings.TrimSpace(weekly.RoomID) if weeklyRoomID == "" { weeklyRoomID = commit.Command.RoomID } occurredMS := weekly.OccurredMS if occurredMS <= 0 { occurredMS = commit.Command.CreatedAtMS } weekStartMS := roomContributionWeekStartMS(occurredMS) if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET weekly_contribution = CASE WHEN contribution_week_start_ms = ? THEN weekly_contribution + ? ELSE ? END, contribution_week_start_ms = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, weekStartMS, weekly.GiftValue, weekly.GiftValue, weekStartMS, commit.Command.CreatedAtMS, weeklyAppCode, weeklyRoomID, ); err != nil { _ = tx.Rollback() return err } } return tx.Commit() } func (r *Repository) lockRoomMutation(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error { var lockedRoomID string err := tx.QueryRowContext(ctx, `SELECT room_id FROM rooms WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID, ).Scan(&lockedRoomID) if errors.Is(err, sql.ErrNoRows) { // CreateRoom 的测试级别 command-log 写入可能没有预置 rooms 行;生产命令在 SaveMutation 前已有 room meta。 return nil } if err != nil { return err } return nil } func enforceUniqueReplayableRoomVersion(commandType string) bool { switch strings.TrimSpace(commandType) { case command.SendGift{}.Type(): // SendGift 是账务成功后的 additive 房间表现:热度、榜单和展示事件都可以按自增 id 顺序补偿。 // 历史机器人房间已经存在同版本礼物命令时,继续拦截会让 Cell 永久卡在旧版本,反而阻止后续快照推进。 return false default: // presence、麦位、房间资料和生命周期命令会改变恢复结构;同版本双写必须 fail-closed。 return true } } func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error { // 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。 // room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。 statements := []string{ `DELETE FROM room_outbox WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_robot_rooms WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_region_pins WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_follows WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_user_contribution_period_stats WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`, `DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`, `DELETE FROM rooms WHERE app_code = ? AND room_id = ?`, } for _, statement := range statements { if _, err := tx.ExecContext(ctx, statement, appCode, roomID); err != nil { return err } } return nil } func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, records []outbox.Record) error { if len(records) == 0 { return nil } for _, record := range records { record.AppCode = normalizedRecordAppCode(ctx, record.AppCode) if record.Envelope != nil { record.Envelope.AppCode = record.AppCode } envelopeBytes, err := proto.Marshal(record.Envelope) if err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE event_id = event_id`, record.AppCode, record.EventID, record.EventType, record.RoomID, record.Status, envelopeBytes, record.CreatedAtMS, record.RetryCount, record.NextRetryAtMS, nullableString(record.LastError), record.CreatedAtMS, ); err != nil { return err } } return nil } // ListCommandsAfter 返回快照版本之后的可回放命令日志。 func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) { // 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。 rows, err := r.db.QueryContext(ctx, `SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms FROM room_command_log WHERE app_code = ? AND room_id = ? AND room_version > ? ORDER BY room_version ASC, id ASC`, appcode.FromContext(ctx), roomID, roomVersion, ) if err != nil { return nil, err } defer rows.Close() records := make([]roomservice.CommandRecord, 0) for rows.Next() { // payload 保持原始字节,领域层根据 command_type 反序列化。 var record roomservice.CommandRecord if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil { return nil, err } records = append(records, record) } if err := rows.Err(); err != nil { // rows.Err 捕获迭代过程中发生的底层连接或扫描错误。 return nil, err } return records, nil } // SaveSnapshot 保存房间最新快照。 func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error { // 快照按 room_id 保留最新一条;同版本后写可能来自旧 owner 分支,不能覆盖已经存在的恢复来源。 appCode := normalizedRecordAppCode(ctx, snapshot.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE payload = IF(VALUES(room_version) > room_version, VALUES(payload), payload), created_at_ms = IF(VALUES(room_version) > room_version, VALUES(created_at_ms), created_at_ms), updated_at_ms = IF(VALUES(room_version) > room_version, VALUES(updated_at_ms), updated_at_ms), room_version = IF(VALUES(room_version) > room_version, VALUES(room_version), room_version)`, appCode, snapshot.RoomID, snapshot.RoomVersion, snapshot.Payload, snapshot.CreatedAtMS, snapshot.CreatedAtMS, ) return err } // GetLatestSnapshot 读取房间最新快照。 func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) { // room_snapshots 主键是 room_id,因此查询结果最多一条。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_version, payload, created_at_ms FROM room_snapshots WHERE app_code = ? AND room_id = ?`, appcode.FromContext(ctx), roomID, ) var snapshot roomservice.SnapshotRecord if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { // 没有快照时上层会从 RoomMeta 和 command log 从头恢复。 return roomservice.SnapshotRecord{}, false, nil } return roomservice.SnapshotRecord{}, false, err } return snapshot, true, nil }