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:
@@ -208,17 +208,21 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
|
||||
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
// Grant rewards outside the transaction to avoid holding the lock during RPC calls
|
||||
// Grant rewards outside the transaction to avoid holding the lock during RPC calls.
|
||||
// Each reward is attempted up to 3 times with exponential backoff before being
|
||||
// recorded as a RewardFailure for background retry.
|
||||
resultRewards = make([]RewardResult, 0, len(rewards))
|
||||
hasRewardFailure := false
|
||||
if s.rewardGrant != nil {
|
||||
for _, r := range rewards {
|
||||
grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets)
|
||||
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)
|
||||
log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr)
|
||||
hasRewardFailure = true
|
||||
// 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함
|
||||
s.saveRewardFailure(sessionName, r, grantErr)
|
||||
}
|
||||
resultRewards = append(resultRewards, result)
|
||||
}
|
||||
@@ -231,12 +235,18 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
|
||||
}
|
||||
}
|
||||
|
||||
// Grant experience to players
|
||||
// Grant experience to players (with retry)
|
||||
if s.expGrant != nil {
|
||||
for _, r := range rewards {
|
||||
if r.Experience > 0 {
|
||||
if expErr := s.expGrant(r.Username, r.Experience); expErr != nil {
|
||||
log.Printf("경험치 지급 실패: %s: %v", r.Username, expErr)
|
||||
expErr := s.grantExpWithRetry(r.Username, r.Experience)
|
||||
if expErr != nil {
|
||||
log.Printf("경험치 지급 실패 (재시도 소진): %s: %v", r.Username, expErr)
|
||||
// 경험치 실패도 RewardFailure에 기록 (토큰/에셋 없이 경험치만)
|
||||
s.saveRewardFailure(sessionName, PlayerReward{
|
||||
Username: r.Username,
|
||||
Experience: r.Experience,
|
||||
}, expErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,3 +483,62 @@ func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSl
|
||||
}
|
||||
return server, slots, nil
|
||||
}
|
||||
|
||||
// --- Reward retry helpers ---
|
||||
|
||||
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 {
|
||||
delays := []time.Duration{1 * time.Second, 2 * time.Second}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < immediateRetries; attempt++ {
|
||||
lastErr = s.rewardGrant(username, tokenAmount, assets)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt < len(delays) {
|
||||
log.Printf("보상 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr)
|
||||
time.Sleep(delays[attempt])
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// grantExpWithRetry attempts the experience grant up to 3 times with backoff (1s, 2s).
|
||||
func (s *Service) grantExpWithRetry(username string, exp int) error {
|
||||
delays := []time.Duration{1 * time.Second, 2 * time.Second}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < immediateRetries; attempt++ {
|
||||
lastErr = s.expGrant(username, exp)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt < len(delays) {
|
||||
log.Printf("경험치 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr)
|
||||
time.Sleep(delays[attempt])
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// saveRewardFailure records a failed reward in the DB for background retry.
|
||||
func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error) {
|
||||
assets := "[]"
|
||||
if len(r.Assets) > 0 {
|
||||
if data, err := json.Marshal(r.Assets); err == nil {
|
||||
assets = string(data)
|
||||
}
|
||||
}
|
||||
rf := &RewardFailure{
|
||||
SessionName: sessionName,
|
||||
Username: r.Username,
|
||||
TokenAmount: r.TokenAmount,
|
||||
Assets: assets,
|
||||
Experience: r.Experience,
|
||||
Error: grantErr.Error(),
|
||||
}
|
||||
if err := s.repo.SaveRewardFailure(rf); err != nil {
|
||||
log.Printf("보상 실패 기록 저장 실패: %s/%s: %v", sessionName, r.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user