feat: Swagger API 문서 추가 + 보스레이드/플레이어 레벨 시스템
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 12m3s
Server CI/CD / deploy (push) Has been cancelled

- 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:
2026-03-16 17:51:37 +09:00
parent ee2cf332fb
commit befea9dd68
19 changed files with 12692 additions and 62 deletions

View File

@@ -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
}