- 보상 지급 실패 시 즉시 재시도(3회 backoff) + DB 기록 + 백그라운드 워커 재시도 - WaitForTx 폴링으로 블록체인 TX 확정 대기, SendTxAndWait 편의 메서드 - chain 트랜잭션 코드 중복 제거 (userTx/operatorTx 헬퍼, 50% 감소) - AppError 기반 에러 응답 포맷 통일 (8개 코드, 전 핸들러 마이그레이션) - TX 에러 분류 + 한국어 사용자 메시지 매핑 (11가지 패턴) - player 서비스 테스트 20개 + chain WaitForTx 테스트 10개 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
178 lines
5.4 KiB
Go
178 lines
5.4 KiB
Go
package player
|
|
|
|
import (
|
|
"log"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"a301_server/pkg/apperror"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type Handler struct {
|
|
svc *Service
|
|
}
|
|
|
|
func NewHandler(svc *Service) *Handler {
|
|
return &Handler{svc: svc}
|
|
}
|
|
|
|
// 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 {
|
|
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
|
|
}
|
|
|
|
profile, err := h.svc.GetProfile(userID)
|
|
if err != nil {
|
|
return apperror.NotFound(err.Error())
|
|
}
|
|
|
|
return c.JSON(profileWithNextExp(profile))
|
|
}
|
|
|
|
// 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 {
|
|
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
|
|
}
|
|
|
|
var req struct {
|
|
Nickname string `json:"nickname"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
|
|
req.Nickname = strings.TrimSpace(req.Nickname)
|
|
if req.Nickname != "" {
|
|
nicknameRunes := []rune(req.Nickname)
|
|
if len(nicknameRunes) < 2 || len(nicknameRunes) > 30 {
|
|
return apperror.BadRequest("닉네임은 2~30자여야 합니다")
|
|
}
|
|
for _, r := range nicknameRunes {
|
|
if unicode.IsControl(r) {
|
|
return apperror.BadRequest("닉네임에 허용되지 않는 문자가 포함되어 있습니다")
|
|
}
|
|
}
|
|
}
|
|
|
|
profile, err := h.svc.UpdateProfile(userID, req.Nickname)
|
|
if err != nil {
|
|
log.Printf("프로필 수정 실패 (userID=%d): %v", userID, err)
|
|
return apperror.ErrInternal
|
|
}
|
|
|
|
return c.JSON(profile)
|
|
}
|
|
|
|
// 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 == "" {
|
|
return apperror.BadRequest("username 파라미터가 필요합니다")
|
|
}
|
|
|
|
profile, err := h.svc.GetProfileByUsername(username)
|
|
if err != nil {
|
|
return apperror.NotFound(err.Error())
|
|
}
|
|
|
|
return c.JSON(profileWithNextExp(profile))
|
|
}
|
|
|
|
// 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 == "" {
|
|
return apperror.BadRequest("username 파라미터가 필요합니다")
|
|
}
|
|
|
|
var req GameDataRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return apperror.ErrBadRequest
|
|
}
|
|
|
|
if err := h.svc.SaveGameDataByUsername(username, &req); err != nil {
|
|
// Username from internal API (ServerAuth protected) — low risk of injection
|
|
log.Printf("게임 데이터 저장 실패 (username=%s): %v", username, err)
|
|
return apperror.ErrInternal
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "게임 데이터가 저장되었습니다"})
|
|
}
|