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

@@ -16,7 +16,16 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// GetProfile 자신의 프로필 조회 (JWT 인증)
// GetProfile godoc
// @Summary 내 프로필 조회
// @Description 현재 유저의 플레이어 프로필을 조회합니다
// @Tags Player
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.PlayerProfileResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/player/profile [get]
func (h *Handler) GetProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
@@ -28,10 +37,22 @@ func (h *Handler) GetProfile(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(profile)
return c.JSON(profileWithNextExp(profile))
}
// UpdateProfile 자신의 프로필 수정 (JWT 인증)
// UpdateProfile godoc
// @Summary 프로필 수정
// @Description 현재 유저의 닉네임을 수정합니다
// @Tags Player
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body docs.UpdateProfileRequest true "수정할 프로필"
// @Success 200 {object} player.PlayerProfile
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/player/profile [put]
func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
@@ -67,7 +88,17 @@ func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
return c.JSON(profile)
}
// InternalGetProfile 내부 API: username 쿼리 파라미터로 프로필 조회
// InternalGetProfile godoc
// @Summary 프로필 조회 (내부 API)
// @Description username으로 플레이어 프로필을 조회합니다 (게임 서버용)
// @Tags Internal - Player
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Success 200 {object} docs.PlayerProfileResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/player/profile [get]
func (h *Handler) InternalGetProfile(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
@@ -79,10 +110,50 @@ func (h *Handler) InternalGetProfile(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(profile)
return c.JSON(profileWithNextExp(profile))
}
// InternalSaveGameData 내부 API: username 쿼리 파라미터로 게임 데이터 저장
// profileWithNextExp wraps a PlayerProfile with nextExp for JSON response.
func profileWithNextExp(p *PlayerProfile) fiber.Map {
nextExp := 0
if p.Level < MaxLevel {
nextExp = RequiredExp(p.Level)
}
return fiber.Map{
"id": p.ID,
"createdAt": p.CreatedAt,
"updatedAt": p.UpdatedAt,
"userId": p.UserID,
"nickname": p.Nickname,
"level": p.Level,
"experience": p.Experience,
"nextExp": nextExp,
"maxHp": p.MaxHP,
"maxMp": p.MaxMP,
"attackPower": p.AttackPower,
"attackRange": p.AttackRange,
"sprintMultiplier": p.SprintMultiplier,
"lastPosX": p.LastPosX,
"lastPosY": p.LastPosY,
"lastPosZ": p.LastPosZ,
"lastRotY": p.LastRotY,
"totalPlayTime": p.TotalPlayTime,
}
}
// InternalSaveGameData godoc
// @Summary 게임 데이터 저장 (내부 API)
// @Description username으로 게임 데이터를 저장합니다 (게임 서버용)
// @Tags Internal - Player
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Param body body docs.GameDataRequest true "게임 데이터"
// @Success 200 {object} docs.MessageResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/player/save [post]
func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {

71
internal/player/level.go Normal file
View File

@@ -0,0 +1,71 @@
package player
// MaxLevel is the maximum player level.
const MaxLevel = 50
// RequiredExp returns the experience needed to reach the next level.
// Formula: level^2 * 100
func RequiredExp(level int) int {
return level * level * 100
}
// CalcStatsForLevel computes combat stats for a given level.
func CalcStatsForLevel(level int) (maxHP, maxMP, attackPower float64) {
maxHP = 100 + float64(level-1)*10
maxMP = 50 + float64(level-1)*5
attackPower = 10 + float64(level-1)*2
return
}
// LevelUpResult holds the result of applying experience gain.
type LevelUpResult struct {
OldLevel int `json:"oldLevel"`
NewLevel int `json:"newLevel"`
Experience int `json:"experience"`
NextExp int `json:"nextExp"`
MaxHP float64 `json:"maxHp"`
MaxMP float64 `json:"maxMp"`
AttackPower float64 `json:"attackPower"`
LeveledUp bool `json:"leveledUp"`
}
// ApplyExperience adds exp to current level/exp and applies level ups.
// Returns the result including new stats if leveled up.
func ApplyExperience(currentLevel, currentExp, gainedExp int) LevelUpResult {
level := currentLevel
exp := currentExp + gainedExp
// Process level ups
for level < MaxLevel {
required := RequiredExp(level)
if exp < required {
break
}
exp -= required
level++
}
// Cap at max level
if level >= MaxLevel {
level = MaxLevel
exp = 0 // No more exp needed at max level
}
maxHP, maxMP, attackPower := CalcStatsForLevel(level)
nextExp := 0
if level < MaxLevel {
nextExp = RequiredExp(level)
}
return LevelUpResult{
OldLevel: currentLevel,
NewLevel: level,
Experience: exp,
NextExp: nextExp,
MaxHP: maxHP,
MaxMP: maxMP,
AttackPower: attackPower,
LeveledUp: level > currentLevel,
}
}

View File

@@ -165,6 +165,43 @@ func (s *Service) SaveGameDataByUsername(username string, data *GameDataRequest)
return s.SaveGameData(userID, data)
}
// GrantExperience adds experience to a player and handles level ups + stat recalculation.
func (s *Service) GrantExperience(userID uint, exp int) (*LevelUpResult, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("프로필이 존재하지 않습니다")
}
result := ApplyExperience(profile.Level, profile.Experience, exp)
updates := map[string]interface{}{
"level": result.NewLevel,
"experience": result.Experience,
"max_hp": result.MaxHP,
"max_mp": result.MaxMP,
"attack_power": result.AttackPower,
}
if err := s.repo.UpdateStats(userID, updates); err != nil {
return nil, fmt.Errorf("레벨업 저장 실패: %w", err)
}
return &result, nil
}
// GrantExperienceByUsername grants experience to a player by username.
func (s *Service) GrantExperienceByUsername(username string, exp int) error {
if s.userResolver == nil {
return fmt.Errorf("userResolver가 설정되지 않았습니다")
}
userID, err := s.userResolver(username)
if err != nil {
return fmt.Errorf("존재하지 않는 유저입니다")
}
_, err = s.GrantExperience(userID, exp)
return err
}
// GameDataRequest 게임 데이터 저장 요청 (nil 필드는 변경하지 않음).
type GameDataRequest struct {
Level *int `json:"level,omitempty"`