로딩 화면에서 강제 종료하면 BossRoom이 waiting 상태로 남아 재입장이 영구 차단되는 문제 수정. - waiting 상태 2분 초과 BossRoom 자동 정리 (15초 주기) - RequestEntry 시 해당 유저의 stale waiting room 선제 정리 - 연결된 RoomSlot도 idle로 리셋 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
385 lines
13 KiB
Go
385 lines
13 KiB
Go
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
|
|
}
|
|
|
|
// FindStaleWaitingRoomsByUsername returns waiting rooms containing the given username
|
|
// that were created before the threshold.
|
|
func (r *Repository) FindStaleWaitingRoomsByUsername(username string, threshold time.Time) ([]BossRoom, error) {
|
|
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(username)
|
|
search := `"` + escaped + `"`
|
|
var rooms []BossRoom
|
|
err := r.db.Where("status = ? AND players LIKE ? AND created_at < ?",
|
|
StatusWaiting, "%"+search+"%", threshold).
|
|
Find(&rooms).Error
|
|
return rooms, 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
|
|
}
|
|
|
|
// CleanupStaleWaitingRooms deletes BossRoom records stuck in "waiting" status
|
|
// past the given threshold and resets their associated RoomSlots to idle.
|
|
// This handles cases where players disconnect during loading before the Fusion session starts.
|
|
func (r *Repository) CleanupStaleWaitingRooms(threshold time.Time) (int64, error) {
|
|
// 1. waiting 상태에서 threshold보다 오래된 방 조회
|
|
var staleRooms []BossRoom
|
|
if err := r.db.Where("status = ? AND created_at < ?", StatusWaiting, threshold).
|
|
Find(&staleRooms).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
if len(staleRooms) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
// 2. 연결된 슬롯을 idle로 리셋
|
|
staleSessionNames := make([]string, len(staleRooms))
|
|
for i, room := range staleRooms {
|
|
staleSessionNames[i] = room.SessionName
|
|
}
|
|
r.db.Model(&RoomSlot{}).
|
|
Where("session_name IN ? AND status = ?", staleSessionNames, SlotWaiting).
|
|
Updates(map[string]interface{}{
|
|
"status": SlotIdle,
|
|
"boss_room_id": nil,
|
|
})
|
|
|
|
// 3. BossRoom 레코드 하드 삭제
|
|
result := r.db.Unscoped().
|
|
Where("status = ? AND created_at < ?", StatusWaiting, threshold).
|
|
Delete(&BossRoom{})
|
|
|
|
return result.RowsAffected, result.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
|
|
}
|