Files
a301_server/internal/bossraid/repository.go
tolelom b16eb6cc7a feat: 에러 처리 표준화 + BossRaid 낙관적 잠금
에러 표준화:
- 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>
2026-03-18 10:48:28 +09:00

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
}