796 lines
27 KiB
Go
796 lines
27 KiB
Go
package chain
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"a301_server/pkg/apperror"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/tolelom/tolchain/core"
|
|
)
|
|
|
|
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 {
|
|
return apperror.NotFound("지갑을 찾을 수 없습니다")
|
|
}
|
|
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는 필수입니다")
|
|
}
|
|
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 c.Status(400).JSON(fiber.Map{"error": "password is required"})
|
|
}
|
|
slog.Warn("wallet export requested", "userID", userID, "ip", c.IP())
|
|
privKeyHex, err := h.svc.ExportPrivKey(userID, req.Password)
|
|
if err != nil {
|
|
return c.Status(401).JSON(fiber.Map{"error": "invalid password"})
|
|
}
|
|
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)
|
|
}
|