Fix: 보상 이중 지급 방지, 에러 응답 개선, Rate Limit 조정
- 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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
25
main.go
25
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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user