feat: 보상 재시도 + TX 확정 대기 + 에러 포맷 통일 + 품질 고도화

- 보상 지급 실패 시 즉시 재시도(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>
This commit is contained in:
2026-03-18 16:42:03 +09:00
parent 8da2bdab12
commit f4d862b47f
19 changed files with 1570 additions and 322 deletions

View File

@@ -1,8 +1,12 @@
package chain
import (
"errors"
"log"
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/tolelom/tolchain/core"
@@ -22,7 +26,7 @@ func NewHandler(svc *Service) *Handler {
func getUserID(c *fiber.Ctx) (uint, error) {
uid, ok := c.Locals("userID").(uint)
if !ok {
return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다")
return 0, apperror.ErrUnauthorized
}
return uid, nil
}
@@ -45,9 +49,47 @@ func validID(s string) bool {
return s != "" && len(s) <= maxIDLength
}
func chainError(c *fiber.Ctx, status int, userMsg string, err error) error {
// 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)
return c.Status(status).JSON(fiber.Map{"error": userMsg})
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 ----
@@ -69,7 +111,7 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
}
w, err := h.svc.GetWallet(userID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
return apperror.NotFound("지갑을 찾을 수 없습니다")
}
return c.JSON(fiber.Map{
"address": w.Address,
@@ -94,7 +136,7 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error {
}
result, err := h.svc.GetBalance(userID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
return chainError("잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -119,7 +161,7 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
offset, limit := parsePagination(c)
result, err := h.svc.GetAssets(userID, offset, limit)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -140,11 +182,11 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
func (h *Handler) GetAsset(c *fiber.Ctx) error {
assetID := c.Params("id")
if !validID(assetID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 asset id가 필요합니다"})
return apperror.BadRequest("유효한 asset id가 필요합니다")
}
result, err := h.svc.GetAsset(assetID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -167,7 +209,7 @@ func (h *Handler) GetInventory(c *fiber.Ctx) error {
}
result, err := h.svc.GetInventory(userID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
return chainError("인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -188,7 +230,7 @@ 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)
return chainError("마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -208,11 +250,11 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
listingID := c.Params("id")
if !validID(listingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 listing id가 필요합니다"})
return apperror.BadRequest("유효한 listing id가 필요합니다")
}
result, err := h.svc.GetListing(listingID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
return chainError("마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -244,14 +286,14 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
Amount uint64 `json:"amount"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.To) || req.Amount == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"})
return apperror.BadRequest("to와 amount는 필수입니다")
}
result, err := h.svc.Transfer(userID, req.To, req.Amount)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err)
return chainError("전송에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -280,14 +322,14 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
To string `json:"to"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || !validID(req.To) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"})
return apperror.BadRequest("assetId와 to는 필수입니다")
}
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err)
return chainError("에셋 전송에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -316,14 +358,14 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
Price uint64 `json:"price"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || req.Price == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"})
return apperror.BadRequest("assetId와 price는 필수입니다")
}
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err)
return chainError("마켓 등록에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -351,14 +393,14 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.ListingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
return apperror.BadRequest("listingId는 필수입니다")
}
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err)
return chainError("마켓 구매에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -386,14 +428,14 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.ListingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
return apperror.BadRequest("listingId는 필수입니다")
}
result, err := h.svc.CancelListing(userID, req.ListingID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err)
return chainError("마켓 취소에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -422,14 +464,14 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
Slot string `json:"slot"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || !validID(req.Slot) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"})
return apperror.BadRequest("assetId와 slot은 필수입니다")
}
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err)
return chainError("장착에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -457,14 +499,14 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
AssetID string `json:"assetId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"})
return apperror.BadRequest("assetId는 필수입니다")
}
result, err := h.svc.UnequipItem(userID, req.AssetID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err)
return chainError("장착 해제에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -493,14 +535,14 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.TemplateID) || !validID(req.OwnerPubKey) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"})
return apperror.BadRequest("templateId와 ownerPubKey는 필수입니다")
}
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
return chainError("에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -527,14 +569,14 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.RecipientPubKey) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"})
return apperror.BadRequest("recipientPubKey는 필수입니다")
}
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
return chainError("보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -562,14 +604,14 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
Tradeable bool `json:"tradeable"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.ID) || !validID(req.Name) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"})
return apperror.BadRequest("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 chainError("템플릿 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -596,14 +638,14 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
return chainError("보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -628,14 +670,14 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.TemplateID) || !validID(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"})
return apperror.BadRequest("templateId와 username은 필수입니다")
}
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
return chainError("에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -654,11 +696,11 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GetBalanceByUsername(username)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
return chainError("잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -679,12 +721,12 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
offset, limit := parsePagination(c)
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -704,11 +746,11 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GetInventoryByUsername(username)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
return chainError("인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)