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:
@@ -3,6 +3,8 @@ package bossraid
|
||||
import (
|
||||
"log"
|
||||
|
||||
"a301_server/pkg/apperror"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
@@ -14,9 +16,18 @@ func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func bossError(c *fiber.Ctx, status int, userMsg string, err error) error {
|
||||
func bossError(status int, userMsg string, err error) *apperror.AppError {
|
||||
log.Printf("bossraid error: %s: %v", userMsg, err)
|
||||
return c.Status(status).JSON(fiber.Map{"error": userMsg})
|
||||
code := "internal_error"
|
||||
switch status {
|
||||
case 400:
|
||||
code = "bad_request"
|
||||
case 404:
|
||||
code = "not_found"
|
||||
case 409:
|
||||
code = "conflict"
|
||||
}
|
||||
return apperror.New(code, userMsg, status)
|
||||
}
|
||||
|
||||
// RequestEntry godoc
|
||||
@@ -37,20 +48,20 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error {
|
||||
BossID int `json:"bossId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if len(req.Usernames) == 0 || req.BossID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usernames와 bossId는 필수입니다"})
|
||||
return apperror.BadRequest("usernames와 bossId는 필수입니다")
|
||||
}
|
||||
for _, u := range req.Usernames {
|
||||
if len(u) == 0 || len(u) > 50 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 username입니다"})
|
||||
return apperror.BadRequest("유효하지 않은 username입니다")
|
||||
}
|
||||
}
|
||||
|
||||
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
|
||||
return bossError(fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
@@ -79,15 +90,15 @@ func (h *Handler) StartRaid(c *fiber.Ctx) error {
|
||||
SessionName string `json:"sessionName"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.SessionName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
||||
return apperror.BadRequest("sessionName은 필수입니다")
|
||||
}
|
||||
|
||||
room, err := h.svc.StartRaid(req.SessionName)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
|
||||
return bossError(fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -115,15 +126,15 @@ func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
|
||||
Rewards []PlayerReward `json:"rewards"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.SessionName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
||||
return apperror.BadRequest("sessionName은 필수입니다")
|
||||
}
|
||||
|
||||
room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
|
||||
return bossError(fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -150,15 +161,15 @@ func (h *Handler) FailRaid(c *fiber.Ctx) error {
|
||||
SessionName string `json:"sessionName"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.SessionName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
||||
return apperror.BadRequest("sessionName은 필수입니다")
|
||||
}
|
||||
|
||||
room, err := h.svc.FailRaid(req.SessionName)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
|
||||
return bossError(fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -185,18 +196,15 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
|
||||
EntryToken string `json:"entryToken"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.EntryToken == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "entryToken은 필수입니다"})
|
||||
return apperror.BadRequest("entryToken은 필수입니다")
|
||||
}
|
||||
|
||||
username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"valid": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return apperror.Unauthorized(err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -220,12 +228,12 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
|
||||
func (h *Handler) GetRoom(c *fiber.Ctx) error {
|
||||
sessionName := c.Query("sessionName")
|
||||
if sessionName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
||||
return apperror.BadRequest("sessionName은 필수입니다")
|
||||
}
|
||||
|
||||
room, err := h.svc.GetRoom(sessionName)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
|
||||
return bossError(fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(room)
|
||||
@@ -250,15 +258,15 @@ func (h *Handler) RegisterServer(c *fiber.Ctx) error {
|
||||
MaxRooms int `json:"maxRooms"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.ServerName == "" || req.InstanceID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName과 instanceId는 필수입니다"})
|
||||
return apperror.BadRequest("serverName과 instanceId는 필수입니다")
|
||||
}
|
||||
|
||||
sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusConflict, "서버 등록에 실패했습니다", err)
|
||||
return bossError(fiber.StatusConflict, "서버 등록에 실패했습니다", err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
@@ -284,14 +292,14 @@ func (h *Handler) Heartbeat(c *fiber.Ctx) error {
|
||||
InstanceID string `json:"instanceId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.InstanceID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "instanceId는 필수입니다"})
|
||||
return apperror.BadRequest("instanceId는 필수입니다")
|
||||
}
|
||||
|
||||
if err := h.svc.Heartbeat(req.InstanceID); err != nil {
|
||||
return bossError(c, fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err)
|
||||
return bossError(fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
@@ -314,14 +322,14 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error {
|
||||
SessionName string `json:"sessionName"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if req.SessionName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
||||
return apperror.BadRequest("sessionName은 필수입니다")
|
||||
}
|
||||
|
||||
if err := h.svc.ResetRoom(req.SessionName); err != nil {
|
||||
return bossError(c, fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err)
|
||||
return bossError(fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok", "sessionName": req.SessionName})
|
||||
@@ -341,12 +349,12 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error {
|
||||
func (h *Handler) GetServerStatus(c *fiber.Ctx) error {
|
||||
serverName := c.Query("serverName")
|
||||
if serverName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName은 필수입니다"})
|
||||
return apperror.BadRequest("serverName은 필수입니다")
|
||||
}
|
||||
|
||||
server, slots, err := h.svc.GetServerStatus(serverName)
|
||||
if err != nil {
|
||||
return bossError(c, fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
|
||||
return bossError(fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
|
||||
@@ -75,3 +75,18 @@ type RoomSlot struct {
|
||||
InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"`
|
||||
LastHeartbeat *time.Time `json:"lastHeartbeat"`
|
||||
}
|
||||
|
||||
// RewardFailure records a failed reward grant for later retry.
|
||||
// A record is "pending" when ResolvedAt is nil and RetryCount < maxRetries (10).
|
||||
type RewardFailure struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
SessionName string `json:"sessionName" gorm:"type:varchar(100);index;not null"`
|
||||
Username string `json:"username" gorm:"type:varchar(100);index;not null"`
|
||||
TokenAmount uint64 `json:"tokenAmount" gorm:"not null"`
|
||||
Assets string `json:"assets" gorm:"type:text"`
|
||||
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"`
|
||||
ResolvedAt *time.Time `json:"resolvedAt" gorm:"index"`
|
||||
}
|
||||
|
||||
@@ -293,3 +293,39 @@ func (r *Repository) GetRoomSlotsByServer(serverID uint) ([]RoomSlot, error) {
|
||||
err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error
|
||||
return slots, err
|
||||
}
|
||||
|
||||
// --- RewardFailure ---
|
||||
|
||||
// SaveRewardFailure inserts a new reward failure record.
|
||||
func (r *Repository) SaveRewardFailure(rf *RewardFailure) error {
|
||||
return r.db.Create(rf).Error
|
||||
}
|
||||
|
||||
// GetPendingRewardFailures returns unresolved failures that haven't exceeded 10 retries.
|
||||
func (r *Repository) GetPendingRewardFailures(limit int) ([]RewardFailure, error) {
|
||||
var failures []RewardFailure
|
||||
err := r.db.
|
||||
Where("resolved_at IS NULL AND retry_count < 10").
|
||||
Order("created_at ASC").
|
||||
Limit(limit).
|
||||
Find(&failures).Error
|
||||
return failures, err
|
||||
}
|
||||
|
||||
// ResolveRewardFailure marks a reward failure as resolved by setting ResolvedAt.
|
||||
func (r *Repository) ResolveRewardFailure(id uint) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&RewardFailure{}).
|
||||
Where("id = ?", id).
|
||||
Update("resolved_at", now).Error
|
||||
}
|
||||
|
||||
// IncrementRetryCount increments the retry count and updates the error message.
|
||||
func (r *Repository) IncrementRetryCount(id uint, errMsg string) error {
|
||||
return r.db.Model(&RewardFailure{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"retry_count": gorm.Expr("retry_count + 1"),
|
||||
"error": errMsg,
|
||||
}).Error
|
||||
}
|
||||
|
||||
112
internal/bossraid/reward_worker.go
Normal file
112
internal/bossraid/reward_worker.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package bossraid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/tolelom/tolchain/core"
|
||||
)
|
||||
|
||||
// RewardWorker periodically retries failed reward grants.
|
||||
type RewardWorker struct {
|
||||
repo *Repository
|
||||
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
|
||||
expGrant func(username string, exp int) error
|
||||
interval time.Duration
|
||||
stopCh chan 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,
|
||||
expGrant func(username string, exp int) error,
|
||||
) *RewardWorker {
|
||||
return &RewardWorker{
|
||||
repo: repo,
|
||||
rewardGrant: rewardGrant,
|
||||
expGrant: expGrant,
|
||||
interval: 1 * time.Minute,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the background polling loop in a goroutine.
|
||||
func (w *RewardWorker) Start() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
log.Println("보상 재시도 워커 종료")
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.processFailures()
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Println("보상 재시도 워커 시작")
|
||||
}
|
||||
|
||||
// Stop gracefully stops the worker.
|
||||
func (w *RewardWorker) Stop() {
|
||||
close(w.stopCh)
|
||||
}
|
||||
|
||||
func (w *RewardWorker) processFailures() {
|
||||
failures, err := w.repo.GetPendingRewardFailures(10)
|
||||
if err != nil {
|
||||
log.Printf("보상 재시도 조회 실패: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rf := range failures {
|
||||
w.retryOne(rf)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RewardWorker) retryOne(rf RewardFailure) {
|
||||
var retryErr error
|
||||
|
||||
// 블록체인 보상 재시도 (토큰 또는 에셋이 있는 경우)
|
||||
if (rf.TokenAmount > 0 || rf.Assets != "[]") && w.rewardGrant != nil {
|
||||
var assets []core.MintAssetPayload
|
||||
if rf.Assets != "" && rf.Assets != "[]" {
|
||||
if err := json.Unmarshal([]byte(rf.Assets), &assets); err != nil {
|
||||
log.Printf("보상 재시도 에셋 파싱 실패: ID=%d: %v", rf.ID, err)
|
||||
// 파싱 불가능한 경우 resolved 처리
|
||||
if err := w.repo.ResolveRewardFailure(rf.ID); err != nil {
|
||||
log.Printf("보상 실패 resolve 실패: ID=%d: %v", rf.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
retryErr = w.rewardGrant(rf.Username, rf.TokenAmount, assets)
|
||||
}
|
||||
|
||||
// 경험치 재시도 (블록체인 보상이 없거나 성공한 경우)
|
||||
if retryErr == nil && rf.Experience > 0 && w.expGrant != nil {
|
||||
retryErr = w.expGrant(rf.Username, rf.Experience)
|
||||
}
|
||||
|
||||
if retryErr == nil {
|
||||
if err := w.repo.ResolveRewardFailure(rf.ID); err != nil {
|
||||
log.Printf("보상 재시도 성공 기록 실패: ID=%d: %v", rf.ID, err)
|
||||
} else {
|
||||
log.Printf("보상 재시도 성공: ID=%d, %s", rf.ID, rf.Username)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 재시도 실패 — retry_count 증가
|
||||
if err := w.repo.IncrementRetryCount(rf.ID, retryErr.Error()); err != nil {
|
||||
log.Printf("보상 재시도 카운트 증가 실패: ID=%d: %v", rf.ID, err)
|
||||
}
|
||||
newCount := rf.RetryCount + 1
|
||||
if newCount >= 10 {
|
||||
log.Printf("보상 재시도 포기 (최대 횟수 초과): ID=%d, %s", rf.ID, rf.Username)
|
||||
} else {
|
||||
log.Printf("보상 재시도 실패 (%d/10): ID=%d, %s: %v", newCount, rf.ID, rf.Username, retryErr)
|
||||
}
|
||||
}
|
||||
@@ -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