feat: 인프라 개선 — 헬스체크, 로깅, 보안, CI 검증
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 1m13s
Server CI/CD / deploy (push) Has been skipped

- /health + /ready 엔드포인트 추가 (DB/Redis 상태 확인)
- RequestID 미들웨어 + 구조화 JSON 로깅
- 체인 트랜잭션 per-user rate limit (20 req/min)
- DB 커넥션 풀 설정 (MaxOpen 25, MaxIdle 10, MaxLifetime 5m)
- Graceful Shutdown 시 Redis/MySQL 연결 정리
- Dockerfile HEALTHCHECK 추가
- CI에 go vet + 빌드 검증 단계 추가 (deploy 전 실행)
- 보스 레이드 클라이언트 입장 API (JWT 인증)
- Player 프로필 모듈 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 03:41:34 +09:00
parent d597ef2d46
commit cc8368dfba
19 changed files with 759 additions and 33 deletions

View File

@@ -129,6 +129,114 @@ 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.
func (h *Handler) RequestEntryAuth(c *fiber.Ctx) error {
var req struct {
Usernames []string `json:"usernames"`
BossID int `json:"bossId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
// 인증된 유저의 username
authUsername, _ := c.Locals("username").(string)
if authUsername == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 없습니다"})
}
// 빈 usernames이면 솔로 입장 — 본인만 포함
if len(req.Usernames) == 0 {
req.Usernames = []string{authUsername}
}
if req.BossID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bossId는 필수입니다"})
}
// 인증된 유저가 요청 목록에 포함되어 있는지 검증
found := false
for _, u := range req.Usernames {
if u == authUsername {
found = true
break
}
}
if !found {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "본인이 입장 목록에 포함되어야 합니다"})
}
for _, u := range req.Usernames {
if len(u) == 0 || len(u) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 username입니다"})
}
}
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
if err != nil {
return bossError(c, fiber.StatusConflict, err.Error(), err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"bossId": room.BossID,
"players": req.Usernames,
"status": room.Status,
"entryToken": tokens[authUsername],
})
}
// 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.
func (h *Handler) GetMyEntryToken(c *fiber.Ctx) error {
username, _ := c.Locals("username").(string)
if username == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 없습니다"})
}
sessionName, entryToken, err := h.svc.GetMyEntryToken(username)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"sessionName": sessionName,
"entryToken": entryToken,
})
}
// 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).
func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
var req struct {
EntryToken string `json:"entryToken"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.EntryToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "entryToken은 필수입니다"})
}
username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"valid": false,
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"valid": true,
"username": username,
"sessionName": sessionName,
})
}
// GetRoom handles GET /api/internal/bossraid/room
// Query param: sessionName
func (h *Handler) GetRoom(c *fiber.Ctx) error {