package chain import ( "errors" "log" "log/slog" "strconv" "strings" "a301_server/pkg/apperror" "github.com/gofiber/fiber/v2" "github.com/tolelom/tolchain/core" "gorm.io/gorm" ) const maxLimit = 200 const maxIDLength = 256 // max length for string IDs (assetId, listingId, etc.) type Handler struct { svc *Service } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } func getUserID(c *fiber.Ctx) (uint, error) { uid, ok := c.Locals("userID").(uint) if !ok { return 0, apperror.ErrUnauthorized } return uid, nil } func parsePagination(c *fiber.Ctx) (int, int) { offset, _ := strconv.Atoi(c.Query("offset", "0")) limit, _ := strconv.Atoi(c.Query("limit", "50")) if offset < 0 { offset = 0 } if limit <= 0 { limit = 50 } else if limit > maxLimit { limit = maxLimit } return offset, limit } func validID(s string) bool { return s != "" && len(s) <= maxIDLength } func validUsername(s string) bool { return len(s) >= 3 && len(s) <= 50 } // chainError classifies chain errors into appropriate HTTP responses. // TxError (on-chain execution failure) maps to 422 with the chain's error detail. // Other errors (network, timeout, build failures) remain 500. func chainError(userMsg string, err error) *apperror.AppError { log.Printf("chain error: %s: %v", userMsg, err) var txErr *TxError if errors.As(err, &txErr) { msg := classifyTxError(txErr.Message) return apperror.New("tx_failed", msg, 422) } return apperror.Internal(userMsg) } // classifyTxError translates raw chain error messages into user-friendly Korean messages. func classifyTxError(chainMsg string) string { lower := strings.ToLower(chainMsg) switch { case strings.Contains(lower, "insufficient balance"): return "잔액이 부족합니다" case strings.Contains(lower, "unauthorized"): return "권한이 없습니다" case strings.Contains(lower, "already listed"): return "이미 마켓에 등록된 아이템입니다" case strings.Contains(lower, "already exists"): return "이미 존재합니다" case strings.Contains(lower, "not found"): return "리소스를 찾을 수 없습니다" case strings.Contains(lower, "not tradeable"): return "거래할 수 없는 아이템입니다" case strings.Contains(lower, "equipped"): return "장착 중인 아이템입니다" case strings.Contains(lower, "not active"): return "활성 상태가 아닌 매물입니다" case strings.Contains(lower, "not open"): return "진행 중이 아닌 세션입니다" case strings.Contains(lower, "invalid nonce"): return "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요" default: return "블록체인 트랜잭션이 실패했습니다" } } // ---- 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 { return err } w, err := h.svc.GetWallet(userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return apperror.NotFound("지갑을 찾을 수 없습니다") } return apperror.Internal("지갑 조회에 실패했습니다") } return c.JSON(fiber.Map{ "address": w.Address, "pubKeyHex": w.PubKeyHex, }) } // 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 { return err } result, err := h.svc.GetBalance(userID) if err != nil { return chainError("잔액 조회에 실패했습니다", err) } 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 { return err } offset, limit := parsePagination(c) result, err := h.svc.GetAssets(userID, offset, limit) if err != nil { return chainError("에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") 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) { return apperror.BadRequest("유효한 asset id가 필요합니다") } result, err := h.svc.GetAsset(assetID) if err != nil { return chainError("에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") 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 { return err } result, err := h.svc.GetInventory(userID) if err != nil { return chainError("인벤토리 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") 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) if err != nil { return chainError("마켓 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") 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) { return apperror.BadRequest("유효한 listing id가 필요합니다") } result, err := h.svc.GetListing(listingID) if err != nil { return chainError("마켓 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) } // ---- 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 { return err } var req struct { To string `json:"to"` Amount uint64 `json:"amount"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.To) || req.Amount == 0 { return apperror.BadRequest("to와 amount는 필수입니다") } result, err := h.svc.Transfer(userID, req.To, req.Amount) if err != nil { return chainError("전송에 실패했습니다", err) } 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 { return err } var req struct { AssetID string `json:"assetId"` To string `json:"to"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.AssetID) || !validID(req.To) { return apperror.BadRequest("assetId와 to는 필수입니다") } result, err := h.svc.TransferAsset(userID, req.AssetID, req.To) if err != nil { return chainError("에셋 전송에 실패했습니다", err) } 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 { return err } var req struct { AssetID string `json:"assetId"` Price uint64 `json:"price"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.AssetID) || req.Price == 0 { return apperror.BadRequest("assetId와 price는 필수입니다") } result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price) if err != nil { return chainError("마켓 등록에 실패했습니다", err) } 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 { return err } var req struct { ListingID string `json:"listingId"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.ListingID) { return apperror.BadRequest("listingId는 필수입니다") } result, err := h.svc.BuyFromMarket(userID, req.ListingID) if err != nil { return chainError("마켓 구매에 실패했습니다", err) } 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 { return err } var req struct { ListingID string `json:"listingId"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.ListingID) { return apperror.BadRequest("listingId는 필수입니다") } result, err := h.svc.CancelListing(userID, req.ListingID) if err != nil { return chainError("마켓 취소에 실패했습니다", err) } 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 { return err } var req struct { AssetID string `json:"assetId"` Slot string `json:"slot"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.AssetID) || !validID(req.Slot) { return apperror.BadRequest("assetId와 slot은 필수입니다") } result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot) if err != nil { return chainError("장착에 실패했습니다", err) } 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 { return err } var req struct { AssetID string `json:"assetId"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.AssetID) { return apperror.BadRequest("assetId는 필수입니다") } result, err := h.svc.UnequipItem(userID, req.AssetID) if err != nil { return chainError("장착 해제에 실패했습니다", err) } return c.JSON(result) } // ---- 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"` OwnerPubKey string `json:"ownerPubKey"` Properties map[string]any `json:"properties"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.TemplateID) || !validID(req.OwnerPubKey) { return apperror.BadRequest("templateId와 ownerPubKey는 필수입니다") } result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties) if err != nil { return chainError("에셋 발행에 실패했습니다", err) } 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"` TokenAmount uint64 `json:"tokenAmount"` Assets []core.MintAssetPayload `json:"assets"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.RecipientPubKey) { return apperror.BadRequest("recipientPubKey는 필수입니다") } if req.TokenAmount == 0 && len(req.Assets) == 0 { return apperror.BadRequest("tokenAmount 또는 assets가 필요합니다") } result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets) if err != nil { return chainError("보상 지급에 실패했습니다", err) } 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"` Name string `json:"name"` Schema map[string]any `json:"schema"` Tradeable bool `json:"tradeable"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.ID) || !validID(req.Name) { return apperror.BadRequest("id와 name은 필수입니다") } result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable) if err != nil { return chainError("템플릿 등록에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } // ExportWallet godoc // @Summary 개인키 내보내기 // @Description 비밀번호 확인 후 현재 유저의 지갑 개인키를 반환합니다 // @Tags Chain // @Accept json // @Produce json // @Security BearerAuth // @Param body body exportRequest true "비밀번호" // @Success 200 {object} map[string]string // @Failure 400 {object} docs.ErrorResponse // @Failure 401 {object} docs.ErrorResponse // @Router /api/chain/wallet/export [post] type exportRequest struct { Password string `json:"password"` } func (h *Handler) ExportWallet(c *fiber.Ctx) error { userID, err := getUserID(c) if err != nil { return err } var req exportRequest if err := c.BodyParser(&req); err != nil || req.Password == "" { return apperror.BadRequest("password는 필수입니다") } slog.Warn("wallet export requested", "userID", userID, "ip", c.IP()) privKeyHex, err := h.svc.ExportPrivKey(userID, req.Password) if err != nil { return apperror.Unauthorized("비밀번호가 올바르지 않습니다") } return c.JSON(fiber.Map{"privateKey": privKeyHex}) } // ---- Internal Handlers (game server, username-based) ---- // 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"` TokenAmount uint64 `json:"tokenAmount"` Assets []core.MintAssetPayload `json:"assets"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validUsername(req.Username) { return apperror.BadRequest("username은 3~50자여야 합니다") } result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets) if err != nil { return chainError("보상 지급에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } // 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"` Username string `json:"username"` Properties map[string]any `json:"properties"` } if err := c.BodyParser(&req); err != nil { return apperror.ErrBadRequest } if !validID(req.TemplateID) || !validUsername(req.Username) { return apperror.BadRequest("templateId와 username은 필수입니다 (username: 3~50자)") } result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties) if err != nil { return chainError("에셋 발행에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } // 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 !validUsername(username) { return apperror.BadRequest("username은 3~50자여야 합니다") } result, err := h.svc.GetBalanceByUsername(username) if err != nil { return chainError("잔액 조회에 실패했습니다", err) } return c.JSON(result) } // 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 !validUsername(username) { return apperror.BadRequest("username은 3~50자여야 합니다") } offset, limit := parsePagination(c) result, err := h.svc.GetAssetsByUsername(username, offset, limit) if err != nil { return chainError("에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) } // 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 !validUsername(username) { return apperror.BadRequest("username은 3~50자여야 합니다") } result, err := h.svc.GetInventoryByUsername(username) if err != nil { return chainError("인벤토리 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) }