All checks were successful
Server CI/CD / deploy (push) Successful in 1m31s
- 입력 검증 강화 (로그인/체인 핸들러 전체) - boss raid 비관적 잠금으로 동시성 문제 해결 - SSAFY 사용자명 sanitize + 트랜잭션 처리 - constant-time API 키 비교, 보안 헤더, graceful shutdown - 안전하지 않은 기본값 경고 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
6.2 KiB
Go
216 lines
6.2 KiB
Go
package bossraid
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/tolelom/tolchain/core"
|
|
)
|
|
|
|
type Service struct {
|
|
repo *Repository
|
|
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
|
|
}
|
|
|
|
func NewService(repo *Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// SetRewardGranter sets the callback for granting rewards via blockchain.
|
|
func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error) {
|
|
s.rewardGrant = fn
|
|
}
|
|
|
|
// RequestEntry creates a new boss room for a party.
|
|
// Returns the room with assigned session name.
|
|
func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error) {
|
|
if len(usernames) == 0 {
|
|
return nil, fmt.Errorf("플레이어 목록이 비어있습니다")
|
|
}
|
|
if len(usernames) > 3 {
|
|
return nil, fmt.Errorf("최대 3명까지 입장할 수 있습니다")
|
|
}
|
|
|
|
// 중복 플레이어 검증
|
|
seen := make(map[string]bool, len(usernames))
|
|
for _, u := range usernames {
|
|
if seen[u] {
|
|
return nil, fmt.Errorf("중복된 플레이어가 있습니다: %s", u)
|
|
}
|
|
seen[u] = true
|
|
}
|
|
|
|
// Check if any player is already in an active room
|
|
for _, username := range usernames {
|
|
count, err := s.repo.CountActiveByUsername(username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("플레이어 상태 확인 실패: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return nil, fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username)
|
|
}
|
|
}
|
|
|
|
playersJSON, err := json.Marshal(usernames)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err)
|
|
}
|
|
|
|
sessionName := fmt.Sprintf("BossRaid_%d_%d", bossID, time.Now().UnixNano())
|
|
|
|
room := &BossRoom{
|
|
SessionName: sessionName,
|
|
BossID: bossID,
|
|
Status: StatusWaiting,
|
|
MaxPlayers: 3,
|
|
Players: string(playersJSON),
|
|
}
|
|
|
|
if err := s.repo.Create(room); err != nil {
|
|
return nil, fmt.Errorf("방 생성 실패: %w", err)
|
|
}
|
|
|
|
return room, nil
|
|
}
|
|
|
|
// StartRaid marks a room as in_progress.
|
|
// Uses row-level locking 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)
|
|
}
|
|
resultRoom = room
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resultRoom, nil
|
|
}
|
|
|
|
// PlayerReward describes the reward for a single player.
|
|
type PlayerReward struct {
|
|
Username string `json:"username"`
|
|
TokenAmount uint64 `json:"tokenAmount"`
|
|
Assets []core.MintAssetPayload `json:"assets"`
|
|
}
|
|
|
|
// RewardResult holds the result of granting a reward to one player.
|
|
type RewardResult struct {
|
|
Username string `json:"username"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// 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)
|
|
result := RewardResult{Username: r.Username, Success: grantErr == nil}
|
|
if grantErr != nil {
|
|
result.Error = grantErr.Error()
|
|
log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr)
|
|
}
|
|
resultRewards = append(resultRewards, result)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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, err
|
|
}
|
|
return resultRoom, nil
|
|
}
|
|
|
|
// GetRoom returns a room by session name.
|
|
func (s *Service) GetRoom(sessionName string) (*BossRoom, error) {
|
|
return s.repo.FindBySessionName(sessionName)
|
|
}
|