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:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
1
main.go
1
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)
|
||||
|
||||
33
pkg/apperror/apperror.go
Normal file
33
pkg/apperror/apperror.go
Normal file
@@ -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}
|
||||
)
|
||||
31
pkg/middleware/error_handler.go
Normal file
31
pkg/middleware/error_handler.go
Normal file
@@ -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": "서버 오류가 발생했습니다",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user