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

23
main.go
View File

@@ -18,6 +18,7 @@ import (
_ "a301_server/docs" // swagger docs
"github.com/tolelom/tolchain/core"
"a301_server/pkg/apperror"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/pkg/metrics"
@@ -56,7 +57,7 @@ func main() {
log.Println("MySQL 연결 성공")
// AutoMigrate
if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &player.PlayerProfile{}); err != nil {
if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &bossraid.RewardFailure{}, &player.PlayerProfile{}); err != nil {
log.Fatalf("AutoMigrate 실패: %v", err)
}
@@ -170,7 +171,7 @@ func main() {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
return apperror.ErrRateLimited
},
})
@@ -182,7 +183,7 @@ func main() {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
return apperror.ErrRateLimited
},
})
@@ -215,7 +216,7 @@ func main() {
return "chain_ip:" + c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "트랜잭션 요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
return apperror.ErrRateLimited
},
})
@@ -230,12 +231,26 @@ func main() {
}
}()
// Background: retry failed rewards
rewardWorker := bossraid.NewRewardWorker(
brRepo,
func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err
},
func(username string, exp int) error {
return playerSvc.GrantExperienceByUsername(username, exp)
},
)
rewardWorker.Start()
// Graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("수신된 시그널: %v — 서버 종료 중...", sig)
rewardWorker.Stop()
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
log.Printf("서버 종료 실패: %v", err)
}