From b16eb6cc7a10f876a5dfecbf04c9854376851bb9 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 10:48:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=ED=91=9C=EC=A4=80=ED=99=94=20+=20BossRaid=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에러 표준화: - 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) --- internal/bossraid/model.go | 4 + internal/bossraid/repository.go | 50 ++++++++++ internal/bossraid/service.go | 166 ++++++++++++++------------------ main.go | 1 + pkg/apperror/apperror.go | 33 +++++++ pkg/middleware/error_handler.go | 31 ++++++ 6 files changed, 191 insertions(+), 94 deletions(-) create mode 100644 pkg/apperror/apperror.go create mode 100644 pkg/middleware/error_handler.go diff --git a/internal/bossraid/model.go b/internal/bossraid/model.go index 9fc88ea..fb63745 100644 --- a/internal/bossraid/model.go +++ b/internal/bossraid/model.go @@ -1,11 +1,15 @@ package bossraid import ( + "errors" "time" "gorm.io/gorm" ) +// ErrStatusConflict indicates that a room's status was already changed by another request. +var ErrStatusConflict = errors.New("방 상태가 이미 변경되었습니다") + type RoomStatus string const ( diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go index ff5f398..c3b1546 100644 --- a/internal/bossraid/repository.go +++ b/internal/bossraid/repository.go @@ -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 diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index aec9c35..042f7a9 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -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. diff --git a/main.go b/main.go index 6721a0c..ef7a104 100644 --- a/main.go +++ b/main.go @@ -145,6 +145,7 @@ func main() { app := fiber.New(fiber.Config{ StreamRequestBody: true, BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB + ErrorHandler: middleware.ErrorHandler, }) app.Use(middleware.RequestID) app.Use(middleware.Metrics) diff --git a/pkg/apperror/apperror.go b/pkg/apperror/apperror.go new file mode 100644 index 0000000..fc45f8d --- /dev/null +++ b/pkg/apperror/apperror.go @@ -0,0 +1,33 @@ +package apperror + +import "fmt" + +// AppError is a structured application error with an HTTP status code. +type AppError struct { + Code string `json:"error"` + Message string `json:"message"` + Status int `json:"-"` +} + +func (e *AppError) Error() string { return e.Message } + +// New creates a new AppError. +func New(code string, message string, status int) *AppError { + return &AppError{Code: code, Message: message, Status: status} +} + +// Wrap creates a new AppError that wraps a cause error. +func Wrap(code string, message string, status int, cause error) *AppError { + return &AppError{Code: code, Message: fmt.Sprintf("%s: %v", message, cause), Status: status} +} + +// Common errors +var ( + ErrUnauthorized = &AppError{Code: "unauthorized", Message: "인증이 필요합니다", Status: 401} + ErrForbidden = &AppError{Code: "forbidden", Message: "권한이 없습니다", Status: 403} + ErrNotFound = &AppError{Code: "not_found", Message: "리소스를 찾을 수 없습니다", Status: 404} + ErrConflict = &AppError{Code: "conflict", Message: "이미 존재합니다", Status: 409} + ErrBadRequest = &AppError{Code: "bad_request", Message: "잘못된 요청입니다", Status: 400} + ErrInternal = &AppError{Code: "internal_error", Message: "서버 오류가 발생했습니다", Status: 500} + ErrRateLimited = &AppError{Code: "rate_limited", Message: "요청이 너무 많습니다", Status: 429} +) diff --git a/pkg/middleware/error_handler.go b/pkg/middleware/error_handler.go new file mode 100644 index 0000000..3cfa187 --- /dev/null +++ b/pkg/middleware/error_handler.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "errors" + + "a301_server/pkg/apperror" + + "github.com/gofiber/fiber/v2" +) + +// ErrorHandler is a Fiber error handler that returns structured JSON for AppError. +func ErrorHandler(c *fiber.Ctx, err error) error { + var appErr *apperror.AppError + if errors.As(err, &appErr) { + return c.Status(appErr.Status).JSON(appErr) + } + + // Default Fiber error handling + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + return c.Status(fiberErr.Code).JSON(fiber.Map{ + "error": "server_error", + "message": fiberErr.Message, + }) + } + + return c.Status(500).JSON(fiber.Map{ + "error": "internal_error", + "message": "서버 오류가 발생했습니다", + }) +}