feat: Swagger API 문서 추가 + 보스레이드/플레이어 레벨 시스템
- swaggo/swag 기반 전체 API 엔드포인트 Swagger 어노테이션 (59개) - /swagger/ 경로에 Swagger UI 제공 - 보스레이드 데디서버 관리 (등록, 하트비트, 슬롯 리셋) - 플레이어 레벨/경험치 시스템 및 스탯 성장 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ type Service struct {
|
||||
repo *Repository
|
||||
rdb *redis.Client
|
||||
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
|
||||
expGrant func(username string, exp int) error
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, rdb *redis.Client) *Service {
|
||||
@@ -45,7 +46,13 @@ func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64,
|
||||
s.rewardGrant = fn
|
||||
}
|
||||
|
||||
// SetExpGranter sets the callback for granting experience to players.
|
||||
func (s *Service) SetExpGranter(fn func(username string, exp int) error) {
|
||||
s.expGrant = fn
|
||||
}
|
||||
|
||||
// RequestEntry creates a new boss room for a party.
|
||||
// Allocates an idle room slot from a registered dedicated server.
|
||||
// Returns the room with assigned session name.
|
||||
func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error) {
|
||||
if len(usernames) == 0 {
|
||||
@@ -69,18 +76,17 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
|
||||
return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err)
|
||||
}
|
||||
|
||||
sessionName := fmt.Sprintf("BossRaid_%d_%d", bossID, time.Now().UnixNano())
|
||||
var room *BossRoom
|
||||
|
||||
room := &BossRoom{
|
||||
SessionName: sessionName,
|
||||
BossID: bossID,
|
||||
Status: StatusWaiting,
|
||||
MaxPlayers: defaultMaxPlayers,
|
||||
Players: string(playersJSON),
|
||||
}
|
||||
|
||||
// Wrap active-room check + creation in a transaction to prevent TOCTOU race.
|
||||
// Wrap slot allocation + active-room check + creation in a transaction.
|
||||
err = s.repo.Transaction(func(txRepo *Repository) error {
|
||||
// Find an idle room slot from a live dedicated server instance
|
||||
staleThreshold := time.Now().Add(-30 * time.Second)
|
||||
slot, err := txRepo.FindIdleRoomSlot(staleThreshold)
|
||||
if err != nil {
|
||||
return fmt.Errorf("현재 이용 가능한 보스 레이드 방이 없습니다")
|
||||
}
|
||||
|
||||
for _, username := range usernames {
|
||||
count, err := txRepo.CountActiveByUsername(username)
|
||||
if err != nil {
|
||||
@@ -90,9 +96,24 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
|
||||
return fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username)
|
||||
}
|
||||
}
|
||||
|
||||
room = &BossRoom{
|
||||
SessionName: slot.SessionName,
|
||||
BossID: bossID,
|
||||
Status: StatusWaiting,
|
||||
MaxPlayers: defaultMaxPlayers,
|
||||
Players: string(playersJSON),
|
||||
}
|
||||
if err := txRepo.Create(room); err != nil {
|
||||
return fmt.Errorf("방 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
// Mark slot as waiting and link to the boss room
|
||||
slot.Status = SlotWaiting
|
||||
slot.BossRoomID = &room.ID
|
||||
if err := txRepo.UpdateRoomSlot(slot); err != nil {
|
||||
return fmt.Errorf("슬롯 상태 업데이트 실패: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -102,7 +123,7 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
|
||||
return room, nil
|
||||
}
|
||||
|
||||
// StartRaid marks a room as in_progress.
|
||||
// StartRaid marks a room as in_progress and updates the slot status.
|
||||
// Uses row-level locking to prevent concurrent state transitions.
|
||||
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
|
||||
var resultRoom *BossRoom
|
||||
@@ -122,6 +143,14 @@ func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
|
||||
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
|
||||
})
|
||||
@@ -136,6 +165,7 @@ type PlayerReward struct {
|
||||
Username string `json:"username"`
|
||||
TokenAmount uint64 `json:"tokenAmount"`
|
||||
Assets []core.MintAssetPayload `json:"assets"`
|
||||
Experience int `json:"experience"` // 경험치 보상
|
||||
}
|
||||
|
||||
// RewardResult holds the result of granting a reward to one player.
|
||||
@@ -204,10 +234,26 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
|
||||
}
|
||||
}
|
||||
|
||||
// Grant experience to players
|
||||
if s.expGrant != nil {
|
||||
for _, r := range rewards {
|
||||
if r.Experience > 0 {
|
||||
if expErr := s.expGrant(r.Username, r.Experience); expErr != nil {
|
||||
log.Printf("경험치 지급 실패: %s: %v", r.Username, expErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset slot to idle so it can accept new raids
|
||||
if err := s.repo.ResetRoomSlot(sessionName); err != nil {
|
||||
log.Printf("슬롯 리셋 실패 (complete): %s: %v", sessionName, err)
|
||||
}
|
||||
|
||||
return resultRoom, resultRewards, nil
|
||||
}
|
||||
|
||||
// FailRaid marks a room as failed.
|
||||
// FailRaid marks a room as failed and resets the slot.
|
||||
// Uses row-level locking to prevent concurrent state transitions.
|
||||
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
|
||||
var resultRoom *BossRoom
|
||||
@@ -233,6 +279,12 @@ func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset slot to idle so it can accept new raids
|
||||
if err := s.repo.ResetRoomSlot(sessionName); err != nil {
|
||||
log.Printf("슬롯 리셋 실패 (fail): %s: %v", sessionName, err)
|
||||
}
|
||||
|
||||
return resultRoom, nil
|
||||
}
|
||||
|
||||
@@ -345,3 +397,85 @@ func (s *Service) RequestEntryWithTokens(usernames []string, bossID int) (*BossR
|
||||
|
||||
return room, tokens, nil
|
||||
}
|
||||
|
||||
// --- Dedicated Server Management ---
|
||||
|
||||
const staleTimeout = 30 * time.Second
|
||||
|
||||
// RegisterServer registers a dedicated server instance (container).
|
||||
// Creates the server group + slots if needed, then assigns a slot to this instance.
|
||||
// Returns the assigned sessionName.
|
||||
func (s *Service) RegisterServer(serverName, instanceID string, maxRooms int) (string, error) {
|
||||
if serverName == "" || instanceID == "" {
|
||||
return "", fmt.Errorf("serverName과 instanceId는 필수입니다")
|
||||
}
|
||||
if maxRooms <= 0 {
|
||||
maxRooms = 10
|
||||
}
|
||||
|
||||
// Ensure server group exists
|
||||
server := &DedicatedServer{
|
||||
ServerName: serverName,
|
||||
MaxRooms: maxRooms,
|
||||
}
|
||||
if err := s.repo.UpsertDedicatedServer(server); err != nil {
|
||||
return "", fmt.Errorf("서버 그룹 등록 실패: %w", err)
|
||||
}
|
||||
|
||||
// Re-fetch to get the ID
|
||||
server, err := s.repo.FindDedicatedServerByName(serverName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("서버 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
// Ensure all room slots exist
|
||||
if err := s.repo.EnsureRoomSlots(server.ID, serverName, maxRooms); err != nil {
|
||||
return "", fmt.Errorf("슬롯 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
// Assign a slot to this instance
|
||||
staleThreshold := time.Now().Add(-staleTimeout)
|
||||
slot, err := s.repo.AssignSlotToInstance(server.ID, instanceID, staleThreshold)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("슬롯 배정 실패: %w", err)
|
||||
}
|
||||
|
||||
return slot.SessionName, nil
|
||||
}
|
||||
|
||||
// Heartbeat updates the heartbeat for a container instance.
|
||||
func (s *Service) Heartbeat(instanceID string) error {
|
||||
return s.repo.UpdateHeartbeat(instanceID)
|
||||
}
|
||||
|
||||
// CheckStaleSlots resets slots whose instances have gone silent.
|
||||
func (s *Service) CheckStaleSlots() {
|
||||
threshold := time.Now().Add(-staleTimeout)
|
||||
count, err := s.repo.ResetStaleSlots(threshold)
|
||||
if err != nil {
|
||||
log.Printf("스태일 슬롯 체크 실패: %v", err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
log.Printf("스태일 슬롯 %d개 리셋", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetRoom resets a room slot back to idle.
|
||||
// Called by the dedicated server after a raid ends and the runner is recycled.
|
||||
func (s *Service) ResetRoom(sessionName string) error {
|
||||
return s.repo.ResetRoomSlot(sessionName)
|
||||
}
|
||||
|
||||
// GetServerStatus returns a server group and its room slots.
|
||||
func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSlot, error) {
|
||||
server, err := s.repo.FindDedicatedServerByName(serverName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("서버를 찾을 수 없습니다: %w", err)
|
||||
}
|
||||
slots, err := s.repo.GetRoomSlotsByServer(server.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("슬롯 조회 실패: %w", err)
|
||||
}
|
||||
return server, slots, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user