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>
This commit is contained in:
2026-03-18 10:48:28 +09:00
parent 844a5b264b
commit b16eb6cc7a
6 changed files with 191 additions and 94 deletions

View File

@@ -237,6 +237,56 @@ func (r *Repository) UpdateRoomStatus(sessionName string, status RoomStatus) err
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