CRITICAL: - graceful shutdown 레이스 수정 — Listen을 goroutine으로 이동 - Register 레이스 컨디션 — sentinel error + MySQL duplicate key 처리 HIGH: - 멱등성 키에 method+path 포함 — 엔드포인트 간 캐시 충돌 방지 - 입장 토큰 생성 실패 시 방/슬롯 롤백 추가 MEDIUM: - RequestEntry 슬롯 없음 시 503 반환 - chain ExportWallet/GetWalletInfo/GrantReward 에러 처리 개선 - resolveUsername 에러 타입 구분 (duplicate key vs 기타) - 공지사항 길이 검증 byte→rune (한국어 256자 허용) - Level 검증 범위 MaxLevel(50)로 통일 - admin 자기 강등 방지 - CORS ExposeHeaders 추가 - MySQL DSN loc=Local→loc=UTC - hashGameExeFromZip 100MB 초과 절단 감지 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package bossraid
|
|
|
|
import (
|
|
"log"
|
|
"strings"
|
|
|
|
"a301_server/pkg/apperror"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type Handler struct {
|
|
svc *Service
|
|
}
|
|
|
|
func NewHandler(svc *Service) *Handler {
|
|
return &Handler{svc: svc}
|
|
}
|
|
|
|
func bossError(status int, userMsg string, err error) *apperror.AppError {
|
|
log.Printf("bossraid error: %s: %v", userMsg, err)
|
|
code := "internal_error"
|
|
switch status {
|
|
case 400:
|
|
code = "bad_request"
|
|
case 404:
|
|
code = "not_found"
|
|
case 409:
|
|
code = "conflict"
|
|
}
|
|
return apperror.New(code, userMsg, status)
|
|
}
|
|
|
|
// 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"`
|
|
BossID int `json:"bossId"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
if len(req.Usernames) == 0 || req.BossID <= 0 {
|
|
return apperror.BadRequest("usernames와 bossId는 필수입니다")
|
|
}
|
|
for _, u := range req.Usernames {
|
|
if len(u) == 0 || len(u) > 50 {
|
|
return apperror.BadRequest("유효하지 않은 username입니다")
|
|
}
|
|
}
|
|
|
|
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
|
|
if err != nil {
|
|
status := fiber.StatusConflict
|
|
if strings.Contains(err.Error(), "이용 가능한") {
|
|
status = fiber.StatusServiceUnavailable
|
|
}
|
|
return bossError(status, "보스 레이드 입장에 실패했습니다", err)
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"roomId": room.ID,
|
|
"sessionName": room.SessionName,
|
|
"bossId": room.BossID,
|
|
"players": req.Usernames,
|
|
"status": room.Status,
|
|
"tokens": tokens,
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
if req.SessionName == "" {
|
|
return apperror.BadRequest("sessionName은 필수입니다")
|
|
}
|
|
|
|
room, err := h.svc.StartRaid(req.SessionName)
|
|
if err != nil {
|
|
return bossError(fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"roomId": room.ID,
|
|
"sessionName": room.SessionName,
|
|
"status": room.Status,
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
Rewards []PlayerReward `json:"rewards"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
if req.SessionName == "" {
|
|
return apperror.BadRequest("sessionName은 필수입니다")
|
|
}
|
|
|
|
room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards)
|
|
if err != nil {
|
|
return bossError(fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"roomId": room.ID,
|
|
"sessionName": room.SessionName,
|
|
"status": room.Status,
|
|
"rewardResults": results,
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
if req.SessionName == "" {
|
|
return apperror.BadRequest("sessionName은 필수입니다")
|
|
}
|
|
|
|
room, err := h.svc.FailRaid(req.SessionName)
|
|
if err != nil {
|
|
return bossError(fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"roomId": room.ID,
|
|
"sessionName": room.SessionName,
|
|
"status": room.Status,
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
if req.EntryToken == "" {
|
|
return apperror.BadRequest("entryToken은 필수입니다")
|
|
}
|
|
|
|
username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken)
|
|
if err != nil {
|
|
return apperror.Unauthorized(err.Error())
|
|
}
|
|
|
|
// 방 정보에서 파티 인원 수 조회
|
|
expectedPlayers := 0
|
|
room, roomErr := h.svc.GetRoom(sessionName)
|
|
if roomErr == nil && room != nil {
|
|
expectedPlayers = room.MaxPlayers
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"valid": true,
|
|
"username": username,
|
|
"sessionName": sessionName,
|
|
"expectedPlayers": expectedPlayers,
|
|
})
|
|
}
|
|
|
|
// 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 == "" {
|
|
return apperror.BadRequest("sessionName은 필수입니다")
|
|
}
|
|
|
|
room, err := h.svc.GetRoom(sessionName)
|
|
if err != nil {
|
|
return bossError(fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
|
|
}
|
|
|
|
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 apperror.ErrBadRequest
|
|
}
|
|
if req.ServerName == "" || req.InstanceID == "" {
|
|
return apperror.BadRequest("serverName과 instanceId는 필수입니다")
|
|
}
|
|
|
|
sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms)
|
|
if err != nil {
|
|
return bossError(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 apperror.ErrBadRequest
|
|
}
|
|
if req.InstanceID == "" {
|
|
return apperror.BadRequest("instanceId는 필수입니다")
|
|
}
|
|
|
|
if err := h.svc.Heartbeat(req.InstanceID); err != nil {
|
|
return bossError(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 apperror.ErrBadRequest
|
|
}
|
|
if req.SessionName == "" {
|
|
return apperror.BadRequest("sessionName은 필수입니다")
|
|
}
|
|
|
|
if err := h.svc.ResetRoom(req.SessionName); err != nil {
|
|
return bossError(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 apperror.BadRequest("serverName은 필수입니다")
|
|
}
|
|
|
|
server, slots, err := h.svc.GetServerStatus(serverName)
|
|
if err != nil {
|
|
return bossError(fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"server": server,
|
|
"slots": slots,
|
|
})
|
|
}
|