package bossraid import ( "fmt" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } func (r *Repository) Create(room *BossRoom) error { return r.db.Create(room).Error } func (r *Repository) Update(room *BossRoom) error { return r.db.Save(room).Error } func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) { var room BossRoom if err := r.db.Where("session_name = ?", sessionName).First(&room).Error; err != nil { return nil, err } return &room, nil } // FindBySessionNameForUpdate acquires a row-level lock (SELECT ... FOR UPDATE) // to prevent concurrent state transitions. func (r *Repository) FindBySessionNameForUpdate(sessionName string) (*BossRoom, error) { var room BossRoom if err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("session_name = ?", sessionName).First(&room).Error; err != nil { return nil, err } return &room, nil } // Transaction wraps a function in a database transaction. func (r *Repository) Transaction(fn func(txRepo *Repository) error) error { return r.db.Transaction(func(tx *gorm.DB) error { return fn(&Repository{db: tx}) }) } // CountActiveByUsername checks if a player is already in an active boss raid. func (r *Repository) CountActiveByUsername(username string) (int64, error) { var count int64 // LIKE 특수문자 이스케이프 escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(username) search := `"` + escaped + `"` err := r.db.Model(&BossRoom{}). Where("status IN ? AND players LIKE ?", []RoomStatus{StatusWaiting, StatusInProgress}, "%"+search+"%", ).Count(&count).Error return count, err } // --- DedicatedServer & RoomSlot --- // UpsertDedicatedServer creates or updates a server group by name. func (r *Repository) UpsertDedicatedServer(server *DedicatedServer) error { var existing DedicatedServer err := r.db.Where("server_name = ?", server.ServerName).First(&existing).Error if err == gorm.ErrRecordNotFound { return r.db.Create(server).Error } if err != nil { return err } existing.MaxRooms = server.MaxRooms return r.db.Save(&existing).Error } // FindDedicatedServerByName finds a server group by name. func (r *Repository) FindDedicatedServerByName(serverName string) (*DedicatedServer, error) { var server DedicatedServer if err := r.db.Where("server_name = ?", serverName).First(&server).Error; err != nil { return nil, err } return &server, nil } // EnsureRoomSlots ensures the correct number of room slots exist for a server. func (r *Repository) EnsureRoomSlots(serverID uint, serverName string, maxRooms int) error { for i := 0; i < maxRooms; i++ { sessionName := fmt.Sprintf("%s_Room%d", serverName, i) var existing RoomSlot err := r.db.Where("session_name = ?", sessionName).First(&existing).Error if err == gorm.ErrRecordNotFound { slot := RoomSlot{ DedicatedServerID: serverID, SlotIndex: i, SessionName: sessionName, Status: SlotIdle, } if err := r.db.Create(&slot).Error; err != nil { return err } } else if err != nil { return err } } return nil } // AssignSlotToInstance finds an unassigned (or stale) slot and assigns it to the given instanceID. // Returns the assigned slot with its sessionName. func (r *Repository) AssignSlotToInstance(serverID uint, instanceID string, staleThreshold time.Time) (*RoomSlot, error) { // First check if this instance already has a slot assigned var existing RoomSlot err := r.db. Clauses(clause.Locking{Strength: "UPDATE"}). Where("dedicated_server_id = ? AND instance_id = ?", serverID, instanceID). First(&existing).Error if err == nil { // Already assigned — refresh heartbeat now := time.Now() existing.LastHeartbeat = &now r.db.Save(&existing) return &existing, nil } // Find an unassigned slot (instance_id is empty or heartbeat is stale) var slot RoomSlot err = r.db. Clauses(clause.Locking{Strength: "UPDATE"}). Where("dedicated_server_id = ? AND (instance_id = '' OR instance_id IS NULL OR last_heartbeat < ?)", serverID, staleThreshold). Order("slot_index ASC"). First(&slot).Error if err != nil { return nil, fmt.Errorf("사용 가능한 슬롯이 없습니다") } // Assign this instance to the slot now := time.Now() slot.InstanceID = instanceID slot.LastHeartbeat = &now slot.Status = SlotIdle slot.BossRoomID = nil if err := r.db.Save(&slot).Error; err != nil { return nil, err } return &slot, nil } // UpdateHeartbeat updates the heartbeat for a specific instance. func (r *Repository) UpdateHeartbeat(instanceID string) error { now := time.Now() result := r.db.Model(&RoomSlot{}). Where("instance_id = ?", instanceID). Update("last_heartbeat", now) if result.RowsAffected == 0 { return fmt.Errorf("인스턴스를 찾을 수 없습니다: %s", instanceID) } return result.Error } // FindIdleRoomSlot finds an idle room slot with a live instance (with row-level lock). func (r *Repository) FindIdleRoomSlot(staleThreshold time.Time) (*RoomSlot, error) { var slot RoomSlot err := r.db. Clauses(clause.Locking{Strength: "UPDATE"}). Where("status = ? AND instance_id != '' AND instance_id IS NOT NULL AND last_heartbeat >= ?", SlotIdle, staleThreshold). Order("id ASC"). First(&slot).Error if err != nil { return nil, err } return &slot, nil } // UpdateRoomSlot updates a room slot. func (r *Repository) UpdateRoomSlot(slot *RoomSlot) error { return r.db.Save(slot).Error } // FindRoomSlotBySession finds a room slot by its session name. func (r *Repository) FindRoomSlotBySession(sessionName string) (*RoomSlot, error) { var slot RoomSlot if err := r.db.Where("session_name = ?", sessionName).First(&slot).Error; err != nil { return nil, err } return &slot, nil } // ResetRoomSlot sets a room slot back to idle and clears its BossRoomID. // Does NOT clear InstanceID — the container still owns the slot. func (r *Repository) ResetRoomSlot(sessionName string) error { result := r.db.Model(&RoomSlot{}). Where("session_name = ?", sessionName). Updates(map[string]interface{}{ "status": SlotIdle, "boss_room_id": nil, }) return result.Error } // DeleteRoomBySessionName removes BossRoom records for a given session name. // Used during ResetRoom to prevent duplicate session_name conflicts on next entry. // Unscoped to perform hard delete — soft delete would leave the unique index occupied. func (r *Repository) DeleteRoomBySessionName(sessionName string) error { return r.db.Unscoped().Where("session_name = ?", sessionName).Delete(&BossRoom{}).Error } // ResetStaleSlots clears instanceID for slots with stale heartbeats // and resets any active raids on those slots. func (r *Repository) ResetStaleSlots(threshold time.Time) (int64, error) { result := r.db.Model(&RoomSlot{}). Where("instance_id != '' AND instance_id IS NOT NULL AND last_heartbeat < ?", threshold). Updates(map[string]interface{}{ "instance_id": "", "status": SlotIdle, "boss_room_id": nil, }) return result.RowsAffected, result.Error } // UpdateRoomStatus updates only the status of a boss room by session name. func (r *Repository) UpdateRoomStatus(sessionName string, status RoomStatus) error { result := r.db.Model(&BossRoom{}). Where("session_name = ?", sessionName). Update("status", status) if result.RowsAffected == 0 { return fmt.Errorf("방을 찾을 수 없습니다: %s", sessionName) } return result.Error } // TransitionRoomStatus atomically updates a room's status only if it currently matches expectedStatus. // Returns ErrStatusConflict if the row was not in the expected state (optimistic locking). func (r *Repository) TransitionRoomStatus(sessionName string, expectedStatus RoomStatus, newStatus RoomStatus, extras map[string]interface{}) error { updates := map[string]interface{}{"status": newStatus} for k, v := range extras { updates[k] = v } result := r.db.Model(&BossRoom{}). Where("session_name = ? AND status = ?", sessionName, expectedStatus). Updates(updates) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrStatusConflict } return nil } // TransitionRoomStatusMulti atomically updates a room's status only if it currently matches one of the expected statuses. // Returns ErrStatusConflict if the row was not in any of the expected states. func (r *Repository) TransitionRoomStatusMulti(sessionName string, expectedStatuses []RoomStatus, newStatus RoomStatus, extras map[string]interface{}) error { updates := map[string]interface{}{"status": newStatus} for k, v := range extras { updates[k] = v } result := r.db.Model(&BossRoom{}). Where("session_name = ? AND status IN ?", sessionName, expectedStatuses). Updates(updates) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrStatusConflict } return nil } // TransitionSlotStatus atomically updates a room slot's status only if it currently matches expectedStatus. func (r *Repository) TransitionSlotStatus(sessionName string, expectedStatus SlotStatus, newStatus SlotStatus) error { result := r.db.Model(&RoomSlot{}). Where("session_name = ? AND status = ?", sessionName, expectedStatus). Update("status", newStatus) if result.Error != nil { return result.Error } // Slot transition failures are non-fatal — log but don't block return nil } // GetRoomSlotsByServer returns all room slots for a given server. func (r *Repository) GetRoomSlotsByServer(serverID uint) ([]RoomSlot, error) { var slots []RoomSlot err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error return slots, err } // --- RewardFailure --- // SaveRewardFailure inserts a new reward failure record. func (r *Repository) SaveRewardFailure(rf *RewardFailure) error { return r.db.Create(rf).Error } // GetPendingRewardFailures returns unresolved failures that haven't exceeded 10 retries. func (r *Repository) GetPendingRewardFailures(limit int) ([]RewardFailure, error) { var failures []RewardFailure err := r.db. Where("resolved_at IS NULL AND retry_count < 10"). Order("created_at ASC"). Limit(limit). Find(&failures).Error return failures, err } // ResolveRewardFailure marks a reward failure as resolved by setting ResolvedAt. func (r *Repository) ResolveRewardFailure(id uint) error { now := time.Now() return r.db.Model(&RewardFailure{}). Where("id = ?", id). Update("resolved_at", now).Error } // IncrementRetryCount increments the retry count and updates the error message. func (r *Repository) IncrementRetryCount(id uint, errMsg string) error { return r.db.Model(&RewardFailure{}). Where("id = ?", id). Updates(map[string]interface{}{ "retry_count": gorm.Expr("retry_count + 1"), "error": errMsg, }).Error } // UpdateLastTxID saves the last attempted blockchain transaction ID for idempotency checking. func (r *Repository) UpdateLastTxID(id uint, txID string) error { return r.db.Model(&RewardFailure{}). Where("id = ?", id). Update("last_tx_id", txID).Error }