From 988398596828f34aecb479968e5e4a6e5acfdad3 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 19 Mar 2026 21:01:45 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EB=B3=B4=EC=83=81=20=EC=9D=B4=EC=A4=91?= =?UTF-8?q?=20=EC=A7=80=EA=B8=89=20=EB=B0=A9=EC=A7=80,=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=EA=B0=9C=EC=84=A0,=20Rate=20L?= =?UTF-8?q?imit=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reward_worker에 txCheck 기반 이중 지급 방지 추가 (LastTxID 저장 후 재시도 전 확인) - RewardFailure 모델에 LastTxID 필드 추가 - grantWithRetry가 txID를 반환하도록 변경 - 10회 재시도 초과 시 CRITICAL 로그에 상세 정보 포함 - 경험치 실패도 hasRewardFailure에 반영하여 reward_failed 상태 전이 - 에러 응답에 requestId 필드 포함 (관측성 개선) - /api/auth/refresh를 authLimiter에서 분리 (NAT 환경 한도 초과 방지) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/bossraid/model.go | 1 + internal/bossraid/repository.go | 7 +++++ internal/bossraid/reward_worker.go | 36 +++++++++++++++++++++--- internal/bossraid/service.go | 45 ++++++++++++++++++------------ main.go | 25 +++++++++++++---- pkg/middleware/error_handler.go | 28 +++++++++++++++---- routes/routes.go | 2 +- 7 files changed, 110 insertions(+), 34 deletions(-) diff --git a/internal/bossraid/model.go b/internal/bossraid/model.go index 843b833..b763ef3 100644 --- a/internal/bossraid/model.go +++ b/internal/bossraid/model.go @@ -88,5 +88,6 @@ type RewardFailure struct { Experience int `json:"experience" gorm:"default:0;not null"` Error string `json:"error" gorm:"type:text"` RetryCount int `json:"retryCount" gorm:"default:0;not null"` + LastTxID string `json:"lastTxId" gorm:"type:varchar(100)"` // 마지막 시도한 블록체인 트랜잭션 ID (이중 지급 방지용) ResolvedAt *time.Time `json:"resolvedAt" gorm:"index"` } diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go index b0e3dae..52cfc7b 100644 --- a/internal/bossraid/repository.go +++ b/internal/bossraid/repository.go @@ -329,3 +329,10 @@ func (r *Repository) IncrementRetryCount(id uint, errMsg string) error { "error": errMsg, }).Error } + +// UpdateLastTxID saves the last attempted blockchain transaction ID for idempotency checking. +func (r *Repository) UpdateLastTxID(id uint, txID string) error { + return r.db.Model(&RewardFailure{}). + Where("id = ?", id). + Update("last_tx_id", txID).Error +} diff --git a/internal/bossraid/reward_worker.go b/internal/bossraid/reward_worker.go index a174abe..d48e702 100644 --- a/internal/bossraid/reward_worker.go +++ b/internal/bossraid/reward_worker.go @@ -11,8 +11,9 @@ import ( // RewardWorker periodically retries failed reward grants. type RewardWorker struct { repo *Repository - rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (txID string, err error) expGrant func(username string, exp int) error + txCheck func(txID string) (confirmed bool, err error) // 이중 지급 방지: tx 상태 확인 interval time.Duration stopCh chan struct{} } @@ -20,13 +21,15 @@ type RewardWorker struct { // NewRewardWorker creates a new RewardWorker. Default interval is 1 minute. func NewRewardWorker( repo *Repository, - rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error, + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (txID string, err error), expGrant func(username string, exp int) error, + txCheck func(txID string) (confirmed bool, err error), ) *RewardWorker { return &RewardWorker{ repo: repo, rewardGrant: rewardGrant, expGrant: expGrant, + txCheck: txCheck, interval: 1 * time.Minute, stopCh: make(chan struct{}), } @@ -71,6 +74,21 @@ func (w *RewardWorker) retryOne(rf RewardFailure) { // 블록체인 보상 재시도 (토큰 또는 에셋이 있는 경우) if (rf.TokenAmount > 0 || rf.Assets != "[]") && w.rewardGrant != nil { + // 이중 지급 방지: 마지막 tx가 이미 성공했는지 확인 + if rf.LastTxID != "" && w.txCheck != nil { + confirmed, checkErr := w.txCheck(rf.LastTxID) + if checkErr != nil { + log.Printf("보상 재시도 tx 상태 확인 실패: ID=%d, txID=%s: %v", rf.ID, rf.LastTxID, checkErr) + // 상태 확인 실패 시 안전하게 재시도 건너뜀 (다음 주기에 다시 확인) + return + } + if confirmed { + log.Printf("보상 재시도 건너뜀 (이전 tx 이미 성공): ID=%d, txID=%s, %s", rf.ID, rf.LastTxID, rf.Username) + // 블록체인 보상은 이미 지급됨 → 경험치만 확인 + goto expRetry + } + } + var assets []core.MintAssetPayload if rf.Assets != "" && rf.Assets != "[]" { if err := json.Unmarshal([]byte(rf.Assets), &assets); err != nil { @@ -82,9 +100,18 @@ func (w *RewardWorker) retryOne(rf RewardFailure) { return } } - retryErr = w.rewardGrant(rf.Username, rf.TokenAmount, assets) + txID, grantErr := w.rewardGrant(rf.Username, rf.TokenAmount, assets) + retryErr = grantErr + + // 시도한 txID 저장 (다음 재시도 시 이중 지급 방지용) + if txID != "" { + if err := w.repo.UpdateLastTxID(rf.ID, txID); err != nil { + log.Printf("보상 재시도 txID 저장 실패: ID=%d: %v", rf.ID, err) + } + } } +expRetry: // 경험치 재시도 (블록체인 보상이 없거나 성공한 경우) if retryErr == nil && rf.Experience > 0 && w.expGrant != nil { retryErr = w.expGrant(rf.Username, rf.Experience) @@ -105,7 +132,8 @@ func (w *RewardWorker) retryOne(rf RewardFailure) { } newCount := rf.RetryCount + 1 if newCount >= 10 { - log.Printf("보상 재시도 포기 (최대 횟수 초과): ID=%d, %s", rf.ID, rf.Username) + log.Printf("CRITICAL: 보상 재시도 포기 (최대 횟수 초과) — 수동 복구 필요: ID=%d, session=%s, user=%s, token=%d, exp=%d", + rf.ID, rf.SessionName, rf.Username, rf.TokenAmount, rf.Experience) } else { log.Printf("보상 재시도 실패 (%d/10): ID=%d, %s: %v", newCount, rf.ID, rf.Username, retryErr) } diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index 03d79f4..ffbe582 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -33,7 +33,7 @@ type entryTokenData struct { type Service struct { repo *Repository rdb *redis.Client - rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (txID string, err error) expGrant func(username string, exp int) error } @@ -42,7 +42,8 @@ func NewService(repo *Repository, rdb *redis.Client) *Service { } // SetRewardGranter sets the callback for granting rewards via blockchain. -func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error) { +// The callback returns the blockchain transaction ID and an error. +func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (txID string, err error)) { s.rewardGrant = fn } @@ -215,26 +216,19 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos hasRewardFailure := false if s.rewardGrant != nil { for _, r := range rewards { - grantErr := s.grantWithRetry(r.Username, r.TokenAmount, r.Assets) + lastTxID, grantErr := s.grantWithRetry(r.Username, r.TokenAmount, r.Assets) result := RewardResult{Username: r.Username, Success: grantErr == nil} if grantErr != nil { result.Error = grantErr.Error() log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr) hasRewardFailure = true // 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함 - s.saveRewardFailure(sessionName, r, grantErr) + s.saveRewardFailure(sessionName, r, grantErr, lastTxID) } resultRewards = append(resultRewards, result) } } - // 보상 실패가 있으면 상태를 reward_failed로 업데이트 (completed → reward_failed) - if hasRewardFailure { - if err := s.repo.TransitionRoomStatus(sessionName, StatusCompleted, StatusRewardFailed, nil); err != nil { - log.Printf("보상 실패 상태 업데이트 실패: %s: %v", sessionName, err) - } - } - // Grant experience to players (with retry) if s.expGrant != nil { for _, r := range rewards { @@ -242,16 +236,24 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos expErr := s.grantExpWithRetry(r.Username, r.Experience) if expErr != nil { log.Printf("경험치 지급 실패 (재시도 소진): %s: %v", r.Username, expErr) + hasRewardFailure = true // 경험치 실패도 RewardFailure에 기록 (토큰/에셋 없이 경험치만) s.saveRewardFailure(sessionName, PlayerReward{ Username: r.Username, Experience: r.Experience, - }, expErr) + }, expErr, "") } } } } + // 보상 실패(블록체인 또는 경험치)가 있으면 상태를 reward_failed로 업데이트 + if hasRewardFailure { + if err := s.repo.TransitionRoomStatus(sessionName, StatusCompleted, StatusRewardFailed, nil); err != nil { + log.Printf("보상 실패 상태 업데이트 실패: %s: %v", sessionName, err) + } + } + // BossRoom 삭제 후 슬롯 리셋 — 다음 파티가 즉시 슬롯 재사용 가능 if err := s.repo.DeleteRoomBySessionName(sessionName); err != nil { log.Printf("BossRoom 삭제 실패 (complete): %s: %v", sessionName, err) @@ -497,20 +499,26 @@ func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSl const immediateRetries = 3 // grantWithRetry attempts the reward grant up to 3 times with backoff (1s, 2s). -func (s *Service) grantWithRetry(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { +// Returns the last attempted transaction ID (may be empty) and the error. +func (s *Service) grantWithRetry(username string, tokenAmount uint64, assets []core.MintAssetPayload) (string, error) { delays := []time.Duration{1 * time.Second, 2 * time.Second} var lastErr error + var lastTxID string for attempt := 0; attempt < immediateRetries; attempt++ { - lastErr = s.rewardGrant(username, tokenAmount, assets) - if lastErr == nil { - return nil + txID, err := s.rewardGrant(username, tokenAmount, assets) + if txID != "" { + lastTxID = txID } + if err == nil { + return txID, nil + } + lastErr = err if attempt < len(delays) { log.Printf("보상 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr) time.Sleep(delays[attempt]) } } - return lastErr + return lastTxID, lastErr } // grantExpWithRetry attempts the experience grant up to 3 times with backoff (1s, 2s). @@ -531,7 +539,7 @@ func (s *Service) grantExpWithRetry(username string, exp int) error { } // saveRewardFailure records a failed reward in the DB for background retry. -func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error) { +func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error, lastTxID string) { assets := "[]" if len(r.Assets) > 0 { if data, err := json.Marshal(r.Assets); err == nil { @@ -545,6 +553,7 @@ func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr Assets: assets, Experience: r.Experience, Error: grantErr.Error(), + LastTxID: lastTxID, } if err := s.repo.SaveRewardFailure(rf); err != nil { log.Printf("보상 실패 기록 저장 실패: %s/%s: %v", sessionName, r.Username, err) diff --git a/main.go b/main.go index 9cae70f..5d98e1d 100644 --- a/main.go +++ b/main.go @@ -106,9 +106,12 @@ func main() { brRepo := bossraid.NewRepository(db) brSvc := bossraid.NewService(brRepo, rdb) - brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { - _, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) - return err + brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (string, error) { + result, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) + if result != nil { + return result.TxID, err + } + return "", err }) brSvc.SetExpGranter(func(username string, exp int) error { return playerSvc.GrantExperienceByUsername(username, exp) @@ -151,13 +154,23 @@ func main() { rewardWorker := bossraid.NewRewardWorker( brRepo, - func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { - _, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) - return err + func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (string, error) { + result, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) + if result != nil { + return result.TxID, err + } + return "", err }, func(username string, exp int) error { return playerSvc.GrantExperienceByUsername(username, exp) }, + func(txID string) (bool, error) { + result, err := chainClient.GetTxStatus(txID) + if err != nil { + return false, err + } + return result != nil && result.Success, nil + }, ) rewardWorker.Start() diff --git a/pkg/middleware/error_handler.go b/pkg/middleware/error_handler.go index 3cfa187..d23279a 100644 --- a/pkg/middleware/error_handler.go +++ b/pkg/middleware/error_handler.go @@ -9,23 +9,41 @@ import ( ) // ErrorHandler is a Fiber error handler that returns structured JSON for AppError. +// Includes requestID in error responses for log correlation. func ErrorHandler(c *fiber.Ctx, err error) error { + requestID, _ := c.Locals("requestID").(string) + var appErr *apperror.AppError if errors.As(err, &appErr) { - return c.Status(appErr.Status).JSON(appErr) + resp := fiber.Map{ + "error": appErr.Code, + "message": appErr.Message, + } + if requestID != "" { + resp["requestId"] = requestID + } + return c.Status(appErr.Status).JSON(resp) } // Default Fiber error handling var fiberErr *fiber.Error if errors.As(err, &fiberErr) { - return c.Status(fiberErr.Code).JSON(fiber.Map{ + resp := fiber.Map{ "error": "server_error", "message": fiberErr.Message, - }) + } + if requestID != "" { + resp["requestId"] = requestID + } + return c.Status(fiberErr.Code).JSON(resp) } - return c.Status(500).JSON(fiber.Map{ + resp := fiber.Map{ "error": "internal_error", "message": "서버 오류가 발생했습니다", - }) + } + if requestID != "" { + resp["requestId"] = requestID + } + return c.Status(500).JSON(resp) } diff --git a/routes/routes.go b/routes/routes.go index 1355125..38b759b 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -80,7 +80,7 @@ func Register( a := api.Group("/auth") a.Post("/register", authLimiter, authH.Register) a.Post("/login", authLimiter, authH.Login) - a.Post("/refresh", authLimiter, authH.Refresh) + a.Post("/refresh", authH.Refresh) // refresh는 유효한 쿠키 필요 — authLimiter 제외 (NAT 환경 한도 초과 방지) a.Post("/logout", authMw, authH.Logout) // /verify moved to internal API (ServerAuth) — see internal section below a.Get("/ssafy/login", authH.SSAFYLoginURL)