- 입력 검증 강화 (로그인/체인 핸들러 전체) - boss raid 비관적 잠금으로 동시성 문제 해결 - SSAFY 사용자명 sanitize + 트랜잭션 처리 - constant-time API 키 비교, 보안 헤더, graceful shutdown - 안전하지 않은 기본값 경고 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
@@ -30,6 +31,23 @@ func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) {
|
||||
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
|
||||
|
||||
@@ -76,24 +76,32 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
|
||||
}
|
||||
|
||||
// StartRaid marks a room as in_progress.
|
||||
// Uses row-level locking to prevent concurrent state transitions.
|
||||
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
|
||||
room, err := s.repo.FindBySessionName(sessionName)
|
||||
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)
|
||||
}
|
||||
resultRoom = room
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if room.Status != StatusWaiting {
|
||||
return nil, fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
room.Status = StatusInProgress
|
||||
room.StartedAt = &now
|
||||
|
||||
if err := s.repo.Update(room); err != nil {
|
||||
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
|
||||
}
|
||||
|
||||
return room, nil
|
||||
return resultRoom, nil
|
||||
}
|
||||
|
||||
// PlayerReward describes the reward for a single player.
|
||||
@@ -111,40 +119,52 @@ 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.
|
||||
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
|
||||
room, err := s.repo.FindBySessionName(sessionName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
||||
}
|
||||
if room.Status != StatusInProgress {
|
||||
return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
|
||||
}
|
||||
var resultRoom *BossRoom
|
||||
var resultRewards []RewardResult
|
||||
|
||||
// Validate reward recipients are room players
|
||||
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)
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Mark room completed
|
||||
now := time.Now()
|
||||
room.Status = StatusCompleted
|
||||
room.CompletedAt = &now
|
||||
if err := s.repo.Update(room); err != nil {
|
||||
return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err)
|
||||
}
|
||||
|
||||
// Grant rewards
|
||||
results := make([]RewardResult, 0, len(rewards))
|
||||
// Grant rewards outside the transaction to avoid holding the lock during RPC calls
|
||||
resultRewards = make([]RewardResult, 0, len(rewards))
|
||||
if s.rewardGrant != nil {
|
||||
for _, r := range rewards {
|
||||
grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets)
|
||||
@@ -153,32 +173,40 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
|
||||
result.Error = grantErr.Error()
|
||||
log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr)
|
||||
}
|
||||
results = append(results, result)
|
||||
resultRewards = append(resultRewards, result)
|
||||
}
|
||||
}
|
||||
|
||||
return room, results, nil
|
||||
return resultRoom, resultRewards, nil
|
||||
}
|
||||
|
||||
// FailRaid marks a room as failed.
|
||||
// Uses row-level locking to prevent concurrent state transitions.
|
||||
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
|
||||
room, err := s.repo.FindBySessionName(sessionName)
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if room.Status != StatusWaiting && room.Status != StatusInProgress {
|
||||
return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
room.Status = StatusFailed
|
||||
room.CompletedAt = &now
|
||||
|
||||
if err := s.repo.Update(room); err != nil {
|
||||
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
|
||||
}
|
||||
|
||||
return room, nil
|
||||
return resultRoom, nil
|
||||
}
|
||||
|
||||
// GetRoom returns a room by session name.
|
||||
|
||||
Reference in New Issue
Block a user