Fix: 보상 이중 지급 방지, 에러 응답 개선, Rate Limit 조정
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 32s
Server CI/CD / deploy (push) Has been skipped

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:01:45 +09:00
parent dc2bcb1c5d
commit 9883985968
7 changed files with 110 additions and 34 deletions

View File

@@ -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)