에러 표준화: - pkg/apperror — AppError 타입, 7개 sentinel error - pkg/middleware/error_handler — Fiber ErrorHandler 통합 - 핸들러에서 AppError 반환 시 구조화된 JSON 자동 응답 BossRaid Race Condition: - 상태 전이 4곳 낙관적 잠금 (UPDATE WHERE status=?) - TransitionRoomStatus/TransitionRoomStatusMulti 메서드 추가 - ErrStatusConflict sentinel error Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
296 lines
9.6 KiB
Go
296 lines
9.6 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
|
|
}
|
|
|
|
// --- 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
|
|
}
|