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,6 +16,16 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// GetAll godoc
// @Summary 공지사항 목록 조회
// @Description 공지사항 목록을 조회합니다
// @Tags Announcements
// @Produce json
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(20)
// @Success 200 {array} docs.AnnouncementResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/ [get]
func (h *Handler) GetAll(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
limit := c.QueryInt("limit", 20)
@@ -32,6 +42,20 @@ func (h *Handler) GetAll(c *fiber.Ctx) error {
return c.JSON(list)
}
// Create godoc
// @Summary 공지사항 생성 (관리자)
// @Description 새 공지사항을 생성합니다
// @Tags Announcements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body docs.CreateAnnouncementRequest true "공지사항 내용"
// @Success 201 {object} docs.AnnouncementResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/ [post]
func (h *Handler) Create(c *fiber.Ctx) error {
var body struct {
Title string `json:"title"`
@@ -53,6 +77,22 @@ func (h *Handler) Create(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(a)
}
// Update godoc
// @Summary 공지사항 수정 (관리자)
// @Description 공지사항을 수정합니다
// @Tags Announcements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "공지사항 ID"
// @Param body body docs.UpdateAnnouncementRequest true "수정할 내용"
// @Success 200 {object} docs.AnnouncementResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/{id} [put]
func (h *Handler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
@@ -85,6 +125,19 @@ func (h *Handler) Update(c *fiber.Ctx) error {
return c.JSON(a)
}
// Delete godoc
// @Summary 공지사항 삭제 (관리자)
// @Description 공지사항을 삭제합니다
// @Tags Announcements
// @Security BearerAuth
// @Param id path int true "공지사항 ID"
// @Success 204
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/{id} [delete]
func (h *Handler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {

View File

@@ -20,6 +20,18 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// Register godoc
// @Summary 회원가입
// @Description 새로운 사용자 계정을 생성합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.RegisterRequest true "회원가입 정보"
// @Success 201 {object} docs.MessageResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/register [post]
func (h *Handler) Register(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
@@ -50,6 +62,17 @@ func (h *Handler) Register(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "회원가입이 완료되었습니다"})
}
// Login godoc
// @Summary 로그인
// @Description 사용자 인증 후 JWT 토큰을 발급합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.LoginRequest true "로그인 정보"
// @Success 200 {object} docs.LoginResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/login [post]
func (h *Handler) Login(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
@@ -91,6 +114,17 @@ func (h *Handler) Login(c *fiber.Ctx) error {
})
}
// Refresh godoc
// @Summary 토큰 갱신
// @Description Refresh 토큰으로 새 Access 토큰을 발급합니다 (쿠키 또는 body)
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.RefreshRequest false "Refresh 토큰 (쿠키 우선)"
// @Success 200 {object} docs.RefreshResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/refresh [post]
func (h *Handler) Refresh(c *fiber.Ctx) error {
refreshTokenStr := c.Cookies("refresh_token")
if refreshTokenStr == "" {
@@ -126,6 +160,16 @@ func (h *Handler) Refresh(c *fiber.Ctx) error {
})
}
// Logout godoc
// @Summary 로그아웃
// @Description 현재 세션을 무효화합니다
// @Tags Auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.MessageResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/logout [post]
func (h *Handler) Logout(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
@@ -146,6 +190,19 @@ func (h *Handler) Logout(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"})
}
// GetAllUsers godoc
// @Summary 전체 유저 목록 (관리자)
// @Description 모든 유저 목록을 조회합니다
// @Tags Users
// @Produce json
// @Security BearerAuth
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {array} docs.UserResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/users/ [get]
func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
limit := c.QueryInt("limit", 50)
@@ -162,6 +219,21 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
return c.JSON(users)
}
// UpdateRole godoc
// @Summary 유저 권한 변경 (관리자)
// @Description 유저의 역할을 admin 또는 user로 변경합니다
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "유저 ID"
// @Param body body docs.UpdateRoleRequest true "변경할 역할"
// @Success 200 {object} docs.MessageResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/users/{id}/role [patch]
func (h *Handler) UpdateRole(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
@@ -182,6 +254,18 @@ func (h *Handler) UpdateRole(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "권한이 변경되었습니다"})
}
// VerifyToken godoc
// @Summary 토큰 검증 (내부 API)
// @Description JWT 토큰을 검증하고 username을 반환합니다
// @Tags Internal - Auth
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.VerifyTokenRequest true "검증할 토큰"
// @Success 200 {object} docs.VerifyTokenResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/internal/auth/verify [post]
func (h *Handler) VerifyToken(c *fiber.Ctx) error {
var req struct {
Token string `json:"token"`
@@ -200,6 +284,14 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error {
})
}
// SSAFYLoginURL godoc
// @Summary SSAFY 로그인 URL
// @Description SSAFY OAuth 로그인 URL을 생성합니다
// @Tags Auth
// @Produce json
// @Success 200 {object} docs.SSAFYLoginURLResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/ssafy/login [get]
func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error {
loginURL, err := h.svc.GetSSAFYLoginURL()
if err != nil {
@@ -208,6 +300,17 @@ func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"url": loginURL})
}
// SSAFYCallback godoc
// @Summary SSAFY OAuth 콜백
// @Description SSAFY 인가 코드를 교환하여 로그인합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.SSAFYCallbackRequest true "인가 코드"
// @Success 200 {object} docs.LoginResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/ssafy/callback [post]
func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
var req struct {
Code string `json:"code"`
@@ -242,8 +345,16 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
})
}
// CreateLaunchTicket issues a one-time ticket for the game launcher.
// The launcher uses this ticket instead of receiving the JWT directly in the URL.
// CreateLaunchTicket godoc
// @Summary 런처 티켓 발급
// @Description 게임 런처용 일회성 티켓을 발급합니다
// @Tags Auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.LaunchTicketResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/launch-ticket [post]
func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
@@ -256,8 +367,17 @@ func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ticket": ticket})
}
// RedeemLaunchTicket exchanges a one-time ticket for an access token.
// Called by the game launcher, not the web browser.
// RedeemLaunchTicket godoc
// @Summary 런처 티켓 교환
// @Description 일회성 티켓을 Access 토큰으로 교환합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.RedeemTicketRequest true "티켓"
// @Success 200 {object} docs.RedeemTicketResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/redeem-ticket [post]
func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error {
var req struct {
Ticket string `json:"ticket"`
@@ -273,6 +393,18 @@ func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"token": token})
}
// DeleteUser godoc
// @Summary 유저 삭제 (관리자)
// @Description 유저를 삭제합니다
// @Tags Users
// @Security BearerAuth
// @Param id path int true "유저 ID"
// @Success 204
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/users/{id} [delete]
func (h *Handler) DeleteUser(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {

View File

@@ -19,8 +19,18 @@ func bossError(c *fiber.Ctx, status int, userMsg string, err error) error {
return c.Status(status).JSON(fiber.Map{"error": userMsg})
}
// RequestEntry handles POST /api/internal/bossraid/entry
// Called by MMO server when a party requests boss raid entry.
// RequestEntry godoc
// @Summary 보스 레이드 입장 요청 (내부 API)
// @Description MMO 서버에서 파티의 보스 레이드 입장을 요청합니다. 모든 플레이어의 entry token을 반환합니다.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.RequestEntryRequest true "입장 정보"
// @Success 201 {object} docs.InternalRequestEntryResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/entry [post]
func (h *Handler) RequestEntry(c *fiber.Ctx) error {
var req struct {
Usernames []string `json:"usernames"`
@@ -38,7 +48,7 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error {
}
}
room, err := h.svc.RequestEntry(req.Usernames, req.BossID)
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
if err != nil {
return bossError(c, fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
}
@@ -49,11 +59,21 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error {
"bossId": room.BossID,
"players": req.Usernames,
"status": room.Status,
"tokens": tokens,
})
}
// StartRaid handles POST /api/internal/bossraid/start
// Called by dedicated server when the Fusion session begins.
// StartRaid godoc
// @Summary 레이드 시작 (내부 API)
// @Description Fusion 세션이 시작될 때 데디케이티드 서버에서 호출합니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.SessionNameRequest true "세션 정보"
// @Success 200 {object} docs.RoomStatusResponse
// @Failure 400 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/start [post]
func (h *Handler) StartRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
@@ -77,8 +97,18 @@ func (h *Handler) StartRaid(c *fiber.Ctx) error {
})
}
// CompleteRaid handles POST /api/internal/bossraid/complete
// Called by dedicated server when the boss is killed. Distributes rewards.
// CompleteRaid godoc
// @Summary 레이드 완료 (내부 API)
// @Description 보스 처치 시 데디케이티드 서버에서 호출합니다. 보상을 분배합니다.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.CompleteRaidRequest true "완료 정보 및 보상"
// @Success 200 {object} docs.CompleteRaidResponse
// @Failure 400 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/complete [post]
func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
@@ -104,8 +134,17 @@ func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
})
}
// FailRaid handles POST /api/internal/bossraid/fail
// Called by dedicated server on timeout or party wipe.
// FailRaid godoc
// @Summary 레이드 실패 (내부 API)
// @Description 타임아웃 또는 전멸 시 데디케이티드 서버에서 호출합니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.SessionNameRequest true "세션 정보"
// @Success 200 {object} docs.RoomStatusResponse
// @Failure 400 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/fail [post]
func (h *Handler) FailRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
@@ -129,9 +168,20 @@ func (h *Handler) FailRaid(c *fiber.Ctx) error {
})
}
// RequestEntryAuth handles POST /api/bossraid/entry (JWT authenticated).
// Called by the game client to request boss raid entry.
// The authenticated user must be included in the usernames list.
// RequestEntryAuth godoc
// @Summary 보스 레이드 입장 요청
// @Description 게임 클라이언트에서 보스 레이드 입장을 요청합니다. 인증된 유저가 입장 목록에 포함되어야 합니다.
// @Tags Boss Raid
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body docs.RequestEntryAuthRequest true "입장 정보"
// @Success 201 {object} docs.RequestEntryResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Router /api/bossraid/entry [post]
func (h *Handler) RequestEntryAuth(c *fiber.Ctx) error {
var req struct {
Usernames []string `json:"usernames"`
@@ -188,9 +238,16 @@ func (h *Handler) RequestEntryAuth(c *fiber.Ctx) error {
})
}
// GetMyEntryToken handles GET /api/bossraid/my-entry-token (JWT authenticated).
// Returns the pending entry token for the authenticated user.
// Called by party members after the leader requests entry.
// GetMyEntryToken godoc
// @Summary 내 입장 토큰 조회
// @Description 현재 유저의 대기 중인 입장 토큰을 조회합니다
// @Tags Boss Raid
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.MyEntryTokenResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/bossraid/my-entry-token [get]
func (h *Handler) GetMyEntryToken(c *fiber.Ctx) error {
username, _ := c.Locals("username").(string)
if username == "" {
@@ -208,9 +265,18 @@ func (h *Handler) GetMyEntryToken(c *fiber.Ctx) error {
})
}
// ValidateEntryToken handles POST /api/internal/bossraid/validate-entry (ServerAuth).
// Called by the dedicated server to validate a player's entry token.
// Consumes the token (one-time use).
// ValidateEntryToken godoc
// @Summary 입장 토큰 검증 (내부 API)
// @Description 데디케이티드 서버에서 플레이어의 입장 토큰을 검증합니다. 일회성 소모.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.ValidateEntryTokenRequest true "토큰"
// @Success 200 {object} docs.ValidateEntryTokenResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/validate-entry [post]
func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
var req struct {
EntryToken string `json:"entryToken"`
@@ -237,8 +303,17 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
})
}
// GetRoom handles GET /api/internal/bossraid/room
// Query param: sessionName
// GetRoom godoc
// @Summary 방 정보 조회 (내부 API)
// @Description sessionName으로 보스 레이드 방 정보를 조회합니다
// @Tags Internal - Boss Raid
// @Produce json
// @Security ApiKeyAuth
// @Param sessionName query string true "세션 이름"
// @Success 200 {object} bossraid.BossRoom
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/room [get]
func (h *Handler) GetRoom(c *fiber.Ctx) error {
sessionName := c.Query("sessionName")
if sessionName == "" {
@@ -252,3 +327,127 @@ func (h *Handler) GetRoom(c *fiber.Ctx) error {
return c.JSON(room)
}
// RegisterServer godoc
// @Summary 데디케이티드 서버 등록 (내부 API)
// @Description 데디케이티드 서버 컨테이너가 시작 시 호출합니다. 룸 슬롯을 자동 할당합니다.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.RegisterServerRequest true "서버 정보"
// @Success 201 {object} docs.RegisterServerResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/register [post]
func (h *Handler) RegisterServer(c *fiber.Ctx) error {
var req struct {
ServerName string `json:"serverName"`
InstanceID string `json:"instanceId"`
MaxRooms int `json:"maxRooms"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ServerName == "" || req.InstanceID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName과 instanceId는 필수입니다"})
}
sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms)
if err != nil {
return bossError(c, fiber.StatusConflict, "서버 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"sessionName": sessionName,
"instanceId": req.InstanceID,
})
}
// Heartbeat godoc
// @Summary 하트비트 (내부 API)
// @Description 데디케이티드 서버 컨테이너가 주기적으로 호출합니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.HeartbeatRequest true "인스턴스 정보"
// @Success 200 {object} docs.StatusResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/heartbeat [post]
func (h *Handler) Heartbeat(c *fiber.Ctx) error {
var req struct {
InstanceID string `json:"instanceId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.InstanceID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "instanceId는 필수입니다"})
}
if err := h.svc.Heartbeat(req.InstanceID); err != nil {
return bossError(c, fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
// ResetRoom godoc
// @Summary 룸 슬롯 리셋 (내부 API)
// @Description 레이드 종료 후 슬롯을 idle 상태로 되돌립니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.ResetRoomRequest true "세션 정보"
// @Success 200 {object} docs.ResetRoomResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/reset-room [post]
func (h *Handler) ResetRoom(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
}
if err := h.svc.ResetRoom(req.SessionName); err != nil {
return bossError(c, fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err)
}
return c.JSON(fiber.Map{"status": "ok", "sessionName": req.SessionName})
}
// GetServerStatus godoc
// @Summary 서버 상태 조회 (내부 API)
// @Description 데디케이티드 서버의 정보와 룸 슬롯 목록을 조회합니다
// @Tags Internal - Boss Raid
// @Produce json
// @Security ApiKeyAuth
// @Param serverName query string true "서버 이름"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/server-status [get]
func (h *Handler) GetServerStatus(c *fiber.Ctx) error {
serverName := c.Query("serverName")
if serverName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName은 필수입니다"})
}
server, slots, err := h.svc.GetServerStatus(serverName)
if err != nil {
return bossError(c, fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
}
return c.JSON(fiber.Map{
"server": server,
"slots": slots,
})
}

View File

@@ -32,3 +32,41 @@ type BossRoom struct {
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
// SlotStatus represents the status of a dedicated server room slot.
type SlotStatus string
const (
SlotIdle SlotStatus = "idle"
SlotWaiting SlotStatus = "waiting"
SlotInProgress SlotStatus = "in_progress"
)
// DedicatedServer represents a server group (e.g., "Dedi1").
// Multiple containers (replicas) share the same server group name.
type DedicatedServer struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ServerName string `json:"serverName" gorm:"type:varchar(100);uniqueIndex;not null"`
MaxRooms int `json:"maxRooms" gorm:"default:10;not null"`
}
// RoomSlot represents a room slot on a dedicated server.
// Each slot has a stable session name that the Fusion NetworkRunner uses.
// InstanceID tracks which container process currently owns this slot.
type RoomSlot struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;not null"`
SlotIndex int `json:"slotIndex" gorm:"not null"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
Status SlotStatus `json:"status" gorm:"type:varchar(20);index;default:idle;not null"`
BossRoomID *uint `json:"bossRoomId" gorm:"index"`
InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"`
LastHeartbeat *time.Time `json:"lastHeartbeat"`
}

View File

@@ -1,7 +1,9 @@
package bossraid
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -61,3 +63,165 @@ func (r *Repository) CountActiveByUsername(username string) (int64, error) {
).Count(&count).Error
return count, err
}
// --- DedicatedServer & RoomSlot ---
// UpsertDedicatedServer creates or updates a server group by name.
func (r *Repository) UpsertDedicatedServer(server *DedicatedServer) error {
var existing DedicatedServer
err := r.db.Where("server_name = ?", server.ServerName).First(&existing).Error
if err == gorm.ErrRecordNotFound {
return r.db.Create(server).Error
}
if err != nil {
return err
}
existing.MaxRooms = server.MaxRooms
return r.db.Save(&existing).Error
}
// FindDedicatedServerByName finds a server group by name.
func (r *Repository) FindDedicatedServerByName(serverName string) (*DedicatedServer, error) {
var server DedicatedServer
if err := r.db.Where("server_name = ?", serverName).First(&server).Error; err != nil {
return nil, err
}
return &server, nil
}
// EnsureRoomSlots ensures the correct number of room slots exist for a server.
func (r *Repository) EnsureRoomSlots(serverID uint, serverName string, maxRooms int) error {
for i := 0; i < maxRooms; i++ {
sessionName := fmt.Sprintf("%s_Room%d", serverName, i)
var existing RoomSlot
err := r.db.Where("session_name = ?", sessionName).First(&existing).Error
if err == gorm.ErrRecordNotFound {
slot := RoomSlot{
DedicatedServerID: serverID,
SlotIndex: i,
SessionName: sessionName,
Status: SlotIdle,
}
if err := r.db.Create(&slot).Error; err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
// AssignSlotToInstance finds an unassigned (or stale) slot and assigns it to the given instanceID.
// Returns the assigned slot with its sessionName.
func (r *Repository) AssignSlotToInstance(serverID uint, instanceID string, staleThreshold time.Time) (*RoomSlot, error) {
// First check if this instance already has a slot assigned
var existing RoomSlot
err := r.db.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dedicated_server_id = ? AND instance_id = ?", serverID, instanceID).
First(&existing).Error
if err == nil {
// Already assigned — refresh heartbeat
now := time.Now()
existing.LastHeartbeat = &now
r.db.Save(&existing)
return &existing, nil
}
// Find an unassigned slot (instance_id is empty or heartbeat is stale)
var slot RoomSlot
err = r.db.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dedicated_server_id = ? AND (instance_id = '' OR instance_id IS NULL OR last_heartbeat < ?)",
serverID, staleThreshold).
Order("slot_index ASC").
First(&slot).Error
if err != nil {
return nil, fmt.Errorf("사용 가능한 슬롯이 없습니다")
}
// Assign this instance to the slot
now := time.Now()
slot.InstanceID = instanceID
slot.LastHeartbeat = &now
slot.Status = SlotIdle
slot.BossRoomID = nil
if err := r.db.Save(&slot).Error; err != nil {
return nil, err
}
return &slot, nil
}
// UpdateHeartbeat updates the heartbeat for a specific instance.
func (r *Repository) UpdateHeartbeat(instanceID string) error {
now := time.Now()
result := r.db.Model(&RoomSlot{}).
Where("instance_id = ?", instanceID).
Update("last_heartbeat", now)
if result.RowsAffected == 0 {
return fmt.Errorf("인스턴스를 찾을 수 없습니다: %s", instanceID)
}
return result.Error
}
// FindIdleRoomSlot finds an idle room slot with a live instance (with row-level lock).
func (r *Repository) FindIdleRoomSlot(staleThreshold time.Time) (*RoomSlot, error) {
var slot RoomSlot
err := r.db.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ? AND instance_id != '' AND instance_id IS NOT NULL AND last_heartbeat >= ?",
SlotIdle, staleThreshold).
Order("id ASC").
First(&slot).Error
if err != nil {
return nil, err
}
return &slot, nil
}
// UpdateRoomSlot updates a room slot.
func (r *Repository) UpdateRoomSlot(slot *RoomSlot) error {
return r.db.Save(slot).Error
}
// FindRoomSlotBySession finds a room slot by its session name.
func (r *Repository) FindRoomSlotBySession(sessionName string) (*RoomSlot, error) {
var slot RoomSlot
if err := r.db.Where("session_name = ?", sessionName).First(&slot).Error; err != nil {
return nil, err
}
return &slot, nil
}
// ResetRoomSlot sets a room slot back to idle and clears its BossRoomID.
// Does NOT clear InstanceID — the container still owns the slot.
func (r *Repository) ResetRoomSlot(sessionName string) error {
result := r.db.Model(&RoomSlot{}).
Where("session_name = ?", sessionName).
Updates(map[string]interface{}{
"status": SlotIdle,
"boss_room_id": nil,
})
return result.Error
}
// ResetStaleSlots clears instanceID for slots with stale heartbeats
// and resets any active raids on those slots.
func (r *Repository) ResetStaleSlots(threshold time.Time) (int64, error) {
result := r.db.Model(&RoomSlot{}).
Where("instance_id != '' AND instance_id IS NOT NULL AND last_heartbeat < ?", threshold).
Updates(map[string]interface{}{
"instance_id": "",
"status": SlotIdle,
"boss_room_id": nil,
})
return result.RowsAffected, result.Error
}
// GetRoomSlotsByServer returns all room slots for a given server.
func (r *Repository) GetRoomSlotsByServer(serverID uint) ([]RoomSlot, error) {
var slots []RoomSlot
err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error
return slots, err
}

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
}

View File

@@ -52,6 +52,16 @@ func chainError(c *fiber.Ctx, status int, userMsg string, err error) error {
// ---- Query Handlers ----
// GetWalletInfo godoc
// @Summary 지갑 정보 조회
// @Description 현재 유저의 블록체인 지갑 정보를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.WalletInfoResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/chain/wallet [get]
func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -67,6 +77,16 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
})
}
// GetBalance godoc
// @Summary 잔액 조회
// @Description 현재 유저의 토큰 잔액을 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/balance [get]
func (h *Handler) GetBalance(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -79,6 +99,18 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error {
return c.JSON(result)
}
// GetAssets godoc
// @Summary 에셋 목록 조회
// @Description 현재 유저의 블록체인 에셋 목록을 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/assets [get]
func (h *Handler) GetAssets(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -93,6 +125,18 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
return c.Send(result)
}
// GetAsset godoc
// @Summary 에셋 상세 조회
// @Description 특정 에셋의 상세 정보를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param id path string true "에셋 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/asset/{id} [get]
func (h *Handler) GetAsset(c *fiber.Ctx) error {
assetID := c.Params("id")
if !validID(assetID) {
@@ -106,6 +150,16 @@ func (h *Handler) GetAsset(c *fiber.Ctx) error {
return c.Send(result)
}
// GetInventory godoc
// @Summary 인벤토리 조회
// @Description 현재 유저의 인벤토리를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/inventory [get]
func (h *Handler) GetInventory(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -119,6 +173,17 @@ func (h *Handler) GetInventory(c *fiber.Ctx) error {
return c.Send(result)
}
// GetMarketListings godoc
// @Summary 마켓 목록 조회
// @Description 마켓에 등록된 매물 목록을 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market [get]
func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
offset, limit := parsePagination(c)
result, err := h.svc.GetMarketListings(offset, limit)
@@ -129,6 +194,17 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
return c.Send(result)
}
// GetMarketListing godoc
// @Summary 마켓 매물 상세 조회
// @Description 특정 마켓 매물의 상세 정보를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param id path string true "매물 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/{id} [get]
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
listingID := c.Params("id")
if !validID(listingID) {
@@ -144,6 +220,20 @@ func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
// ---- User Transaction Handlers ----
// Transfer godoc
// @Summary 토큰 전송
// @Description 다른 유저에게 토큰을 전송합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.TransferRequest true "전송 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/transfer [post]
func (h *Handler) Transfer(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -166,6 +256,20 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
return c.JSON(result)
}
// TransferAsset godoc
// @Summary 에셋 전송
// @Description 다른 유저에게 에셋을 전송합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.TransferAssetRequest true "전송 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/asset/transfer [post]
func (h *Handler) TransferAsset(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -188,6 +292,20 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
return c.JSON(result)
}
// ListOnMarket godoc
// @Summary 마켓 등록
// @Description 에셋을 마켓에 등록합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.ListOnMarketRequest true "등록 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/list [post]
func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -210,6 +328,20 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
return c.JSON(result)
}
// BuyFromMarket godoc
// @Summary 마켓 구매
// @Description 마켓에서 매물을 구매합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.BuyFromMarketRequest true "구매 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/buy [post]
func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -231,6 +363,20 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
return c.JSON(result)
}
// CancelListing godoc
// @Summary 마켓 등록 취소
// @Description 마켓에 등록한 매물을 취소합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.CancelListingRequest true "취소 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/cancel [post]
func (h *Handler) CancelListing(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -252,6 +398,20 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
return c.JSON(result)
}
// EquipItem godoc
// @Summary 아이템 장착
// @Description 에셋을 장비 슬롯에 장착합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.EquipItemRequest true "장착 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/inventory/equip [post]
func (h *Handler) EquipItem(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -274,6 +434,20 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
return c.JSON(result)
}
// UnequipItem godoc
// @Summary 아이템 장착 해제
// @Description 에셋의 장비 슬롯 장착을 해제합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.UnequipItemRequest true "해제 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/inventory/unequip [post]
func (h *Handler) UnequipItem(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
@@ -297,6 +471,21 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
// ---- Operator (Admin) Transaction Handlers ----
// MintAsset godoc
// @Summary 에셋 발행 (관리자)
// @Description 새 에셋을 발행합니다
// @Tags Chain - Admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.MintAssetRequest true "발행 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/admin/mint [post]
func (h *Handler) MintAsset(c *fiber.Ctx) error {
var req struct {
TemplateID string `json:"templateId"`
@@ -316,6 +505,21 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(result)
}
// GrantReward godoc
// @Summary 보상 지급 (관리자)
// @Description 유저에게 토큰 및 에셋 보상을 지급합니다
// @Tags Chain - Admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.GrantRewardRequest true "보상 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/admin/reward [post]
func (h *Handler) GrantReward(c *fiber.Ctx) error {
var req struct {
RecipientPubKey string `json:"recipientPubKey"`
@@ -335,6 +539,21 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(result)
}
// RegisterTemplate godoc
// @Summary 템플릿 등록 (관리자)
// @Description 새 에셋 템플릿을 등록합니다
// @Tags Chain - Admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.RegisterTemplateRequest true "템플릿 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/admin/template [post]
func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
var req struct {
ID string `json:"id"`
@@ -357,7 +576,19 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
// ---- Internal Handlers (game server, username-based) ----
// InternalGrantReward grants reward by username. For game server use.
// InternalGrantReward godoc
// @Summary 보상 지급 (내부 API)
// @Description username으로 유저에게 보상을 지급합니다 (게임 서버용)
// @Tags Internal - Chain
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.InternalGrantRewardRequest true "보상 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/reward [post]
func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
@@ -377,7 +608,19 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(result)
}
// InternalMintAsset mints an asset by username. For game server use.
// InternalMintAsset godoc
// @Summary 에셋 발행 (내부 API)
// @Description username으로 에셋을 발행합니다 (게임 서버용)
// @Tags Internal - Chain
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.InternalMintAssetRequest true "발행 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/mint [post]
func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
var req struct {
TemplateID string `json:"templateId"`
@@ -397,7 +640,17 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(result)
}
// InternalGetBalance returns balance by username. For game server use.
// InternalGetBalance godoc
// @Summary 잔액 조회 (내부 API)
// @Description username으로 잔액을 조회합니다 (게임 서버용)
// @Tags Internal - Chain
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/balance [get]
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
@@ -410,7 +663,19 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
return c.JSON(result)
}
// InternalGetAssets returns assets by username. For game server use.
// InternalGetAssets godoc
// @Summary 에셋 목록 조회 (내부 API)
// @Description username으로 에셋 목록을 조회합니다 (게임 서버용)
// @Tags Internal - Chain
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/assets [get]
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
@@ -425,7 +690,17 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
return c.Send(result)
}
// InternalGetInventory returns inventory by username. For game server use.
// InternalGetInventory godoc
// @Summary 인벤토리 조회 (내부 API)
// @Description username으로 인벤토리를 조회합니다 (게임 서버용)
// @Tags Internal - Chain
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/inventory [get]
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {

View File

@@ -19,6 +19,14 @@ func NewHandler(svc *Service, baseURL string) *Handler {
return &Handler{svc: svc, baseURL: baseURL}
}
// GetInfo godoc
// @Summary 다운로드 정보 조회
// @Description 게임 및 런처 다운로드 정보를 조회합니다
// @Tags Download
// @Produce json
// @Success 200 {object} docs.DownloadInfoResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/download/info [get]
func (h *Handler) GetInfo(c *fiber.Ctx) error {
info, err := h.svc.GetInfo()
if err != nil {
@@ -27,8 +35,20 @@ func (h *Handler) GetInfo(c *fiber.Ctx) error {
return c.JSON(info)
}
// Upload accepts a raw binary body (application/octet-stream).
// The filename is passed as a query parameter: ?filename=A301_v1.0.zip
// Upload godoc
// @Summary 게임 파일 업로드 (관리자)
// @Description 게임 zip 파일을 스트리밍 업로드합니다. Body는 raw binary입니다.
// @Tags Download
// @Accept application/octet-stream
// @Produce json
// @Security BearerAuth
// @Param filename query string false "파일명" default(game.zip)
// @Success 200 {object} docs.DownloadInfoResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/download/upload/game [post]
func (h *Handler) Upload(c *fiber.Ctx) error {
filename := strings.TrimSpace(c.Query("filename", "game.zip"))
// 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용
@@ -49,6 +69,14 @@ func (h *Handler) Upload(c *fiber.Ctx) error {
return c.JSON(info)
}
// ServeFile godoc
// @Summary 게임 파일 다운로드
// @Description 게임 zip 파일을 다운로드합니다
// @Tags Download
// @Produce application/octet-stream
// @Success 200 {file} binary
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/download/file [get]
func (h *Handler) ServeFile(c *fiber.Ctx) error {
path := h.svc.GameFilePath()
if _, err := os.Stat(path); err != nil {
@@ -63,6 +91,18 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error {
return c.SendFile(path)
}
// UploadLauncher godoc
// @Summary 런처 업로드 (관리자)
// @Description 런처 실행 파일을 스트리밍 업로드합니다. Body는 raw binary입니다.
// @Tags Download
// @Accept application/octet-stream
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.DownloadInfoResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/download/upload/launcher [post]
func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
body := c.Request().BodyStream()
info, err := h.svc.UploadLauncher(body, h.baseURL)
@@ -73,6 +113,14 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
return c.JSON(info)
}
// ServeLauncher godoc
// @Summary 런처 다운로드
// @Description 런처 실행 파일을 다운로드합니다
// @Tags Download
// @Produce application/octet-stream
// @Success 200 {file} binary
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/download/launcher [get]
func (h *Handler) ServeLauncher(c *fiber.Ctx) error {
path := h.svc.LauncherFilePath()
if _, err := os.Stat(path); err != nil {

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"`