All checks were successful
Server CI/CD / deploy (push) Successful in 1m36s
- unsafe 타입 단언 → safe assertion (chain handler 11곳, auth Logout) - Repository 에러 시 nil 반환으로 통일 (chain, auth, announcement) - string ID → uint 파싱으로 타입 안전성 확보 (auth, announcement) - CORS AllowHeaders에 Idempotency-Key, X-API-Key 추가 - /verify 엔드포인트 rate limiter 적용 - Redis 호출에 context timeout 적용 (auth, idempotency 미들웨어) - chain handler 에러 응답에서 내부 정보 노출 방지 - f.Close() 에러 검사 추가 (download service 2곳) - 공지사항 Delete 404 응답 추가 - 회원가입 롤백 시 Delete 에러 로깅 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
436 lines
14 KiB
Go
436 lines
14 KiB
Go
package chain
|
|
|
|
import (
|
|
"log"
|
|
"strconv"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/tolelom/tolchain/core"
|
|
)
|
|
|
|
const maxLimit = 200
|
|
|
|
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, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다")
|
|
}
|
|
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 chainError(c *fiber.Ctx, status int, userMsg string, err error) error {
|
|
log.Printf("chain error: %s: %v", userMsg, err)
|
|
return c.Status(status).JSON(fiber.Map{"error": userMsg})
|
|
}
|
|
|
|
// ---- Query Handlers ----
|
|
|
|
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 c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
|
|
}
|
|
return c.JSON(fiber.Map{
|
|
"address": w.Address,
|
|
"pubKeyHex": w.PubKeyHex,
|
|
})
|
|
}
|
|
|
|
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(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|
|
|
|
func (h *Handler) GetAsset(c *fiber.Ctx) error {
|
|
assetID := c.Params("id")
|
|
if assetID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "asset id is required"})
|
|
}
|
|
result, err := h.svc.GetAsset(assetID)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|
|
|
|
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(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|
|
|
|
func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
|
|
offset, limit := parsePagination(c)
|
|
result, err := h.svc.GetMarketListings(offset, limit)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|
|
|
|
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
|
|
listingID := c.Params("id")
|
|
if listingID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listing id is required"})
|
|
}
|
|
result, err := h.svc.GetListing(listingID)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|
|
|
|
// ---- User Transaction Handlers ----
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.To == "" || req.Amount == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"})
|
|
}
|
|
result, err := h.svc.Transfer(userID, req.To, req.Amount)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.AssetID == "" || req.To == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"})
|
|
}
|
|
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.AssetID == "" || req.Price == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"})
|
|
}
|
|
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.ListingID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
|
|
}
|
|
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.ListingID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
|
|
}
|
|
result, err := h.svc.CancelListing(userID, req.ListingID)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.AssetID == "" || req.Slot == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"})
|
|
}
|
|
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.AssetID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"})
|
|
}
|
|
result, err := h.svc.UnequipItem(userID, req.AssetID)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
// ---- Operator (Admin) Transaction Handlers ----
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.TemplateID == "" || req.OwnerPubKey == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"})
|
|
}
|
|
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.RecipientPubKey == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"})
|
|
}
|
|
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(result)
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.ID == "" || req.Name == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"})
|
|
}
|
|
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err)
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(result)
|
|
}
|
|
|
|
// ---- Internal Handlers (game server, username-based) ----
|
|
|
|
// InternalGrantReward grants reward by username. For game server use.
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.Username == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
|
}
|
|
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(result)
|
|
}
|
|
|
|
// InternalMintAsset mints an asset by username. For game server use.
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
|
}
|
|
if req.TemplateID == "" || req.Username == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"})
|
|
}
|
|
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(result)
|
|
}
|
|
|
|
// InternalGetBalance returns balance by username. For game server use.
|
|
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
|
|
username := c.Query("username")
|
|
if username == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
|
}
|
|
result, err := h.svc.GetBalanceByUsername(username)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
// InternalGetAssets returns assets by username. For game server use.
|
|
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
|
|
username := c.Query("username")
|
|
if username == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
|
}
|
|
offset, limit := parsePagination(c)
|
|
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|
|
|
|
// InternalGetInventory returns inventory by username. For game server use.
|
|
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
|
|
username := c.Query("username")
|
|
if username == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
|
}
|
|
result, err := h.svc.GetInventoryByUsername(username)
|
|
if err != nil {
|
|
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
|
|
}
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Send(result)
|
|
}
|