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

@@ -127,40 +127,27 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
}
// StartRaid marks a room as in_progress and updates the slot status.
// Uses row-level locking to prevent concurrent state transitions.
// Uses optimistic locking (WHERE status = 'waiting') to prevent concurrent state transitions.
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
var resultRoom *BossRoom
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting {
return fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusInProgress
room.StartedAt = &now
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Update slot status to in_progress
slot, err := txRepo.FindRoomSlotBySession(sessionName)
if err == nil {
slot.Status = SlotInProgress
txRepo.UpdateRoomSlot(slot)
}
resultRoom = room
return nil
now := time.Now()
err := s.repo.TransitionRoomStatus(sessionName, StatusWaiting, StatusInProgress, map[string]interface{}{
"started_at": now,
})
if err != nil {
return nil, err
if err == ErrStatusConflict {
return nil, fmt.Errorf("시작할 수 없는 상태입니다 (이미 변경됨)")
}
return resultRoom, nil
if err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Update slot status to in_progress (non-fatal if fails)
s.repo.TransitionSlotStatus(sessionName, SlotWaiting, SlotInProgress)
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
return room, nil
}
// PlayerReward describes the reward for a single player.
@@ -179,48 +166,46 @@ type RewardResult struct {
}
// CompleteRaid marks a room as completed and grants rewards via blockchain.
// Uses a database transaction with row-level locking to prevent double-completion.
// Uses optimistic locking (WHERE status = 'in_progress') to prevent double-completion.
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
var resultRoom *BossRoom
var resultRewards []RewardResult
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusInProgress {
return fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
}
// Validate reward recipients are room players
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
playerSet[p] = true
}
for _, r := range rewards {
if !playerSet[r.Username] {
return fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
}
}
// Mark room completed
now := time.Now()
room.Status = StatusCompleted
room.CompletedAt = &now
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
resultRoom = room
return nil
})
// Validate reward recipients are room players before transitioning
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
playerSet[p] = true
}
for _, r := range rewards {
if !playerSet[r.Username] {
return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
}
}
// Atomically transition status: in_progress → completed
now := time.Now()
err = s.repo.TransitionRoomStatus(sessionName, StatusInProgress, StatusCompleted, map[string]interface{}{
"completed_at": now,
})
if err == ErrStatusConflict {
return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다 (이미 변경됨)")
}
if err != nil {
return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Re-fetch the updated room
resultRoom, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
// Grant rewards outside the transaction to avoid holding the lock during RPC calls
@@ -239,9 +224,9 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
}
}
// 보상 실패가 있으면 상태를 reward_failed로 업데이트
// 보상 실패가 있으면 상태를 reward_failed로 업데이트 (completed → reward_failed)
if hasRewardFailure {
if err := s.repo.UpdateRoomStatus(sessionName, StatusRewardFailed); err != nil {
if err := s.repo.TransitionRoomStatus(sessionName, StatusCompleted, StatusRewardFailed, nil); err != nil {
log.Printf("보상 실패 상태 업데이트 실패: %s: %v", sessionName, err)
}
}
@@ -266,30 +251,19 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
}
// FailRaid marks a room as failed and resets the slot.
// Uses row-level locking to prevent concurrent state transitions.
// Uses optimistic locking (WHERE status IN ('waiting','in_progress')) to prevent concurrent state transitions.
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
var resultRoom *BossRoom
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting && room.Status != StatusInProgress {
return fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusFailed
room.CompletedAt = &now
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
resultRoom = room
return nil
})
now := time.Now()
err := s.repo.TransitionRoomStatusMulti(sessionName,
[]RoomStatus{StatusWaiting, StatusInProgress},
StatusFailed,
map[string]interface{}{"completed_at": now},
)
if err == ErrStatusConflict {
return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다 (이미 변경됨)")
}
if err != nil {
return nil, err
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Reset slot to idle so it can accept new raids
@@ -297,7 +271,11 @@ func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
log.Printf("슬롯 리셋 실패 (fail): %s: %v", sessionName, err)
}
return resultRoom, nil
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
return room, nil
}
// GetRoom returns a room by session name.