feat: DB DI 전환 + download 하위 호환성 + race condition 수정

- middleware(Auth, Idempotency)를 클로저 팩토리 패턴으로 DI 전환
- database.DB/RDB 전역 변수 제거, ConnectMySQL/Redis 값 반환으로 변경
- download API X-API-Version 헤더 + 하위 호환성 규칙 문서화
- SaveGameData PlayTimeDelta 원자적 UPDATE (race condition 해소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:58:36 +09:00
parent f4d862b47f
commit 0dfa744c16
9 changed files with 226 additions and 199 deletions

View File

@@ -12,6 +12,19 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// Download API 하위 호환성 규칙:
// - 기존 필드 삭제 금지 (런처 바이너리가 필드에 의존)
// - 기존 필드 타입 변경 금지
// - 기존 필드명(JSON key) 변경 금지
// - 신규 필드 추가만 허용 (기존 런처는 unknown 필드를 무시)
// - 스키마 변경 시 downloadAPIVersion 값을 올릴 것
//
// 현재 /api/download/info 응답 필드 (v1):
// id, createdAt, updatedAt, url, version, fileName, fileSize,
// fileHash, launcherUrl, launcherSize, launcherHash
const downloadAPIVersion = "1"
type Handler struct { type Handler struct {
svc *Service svc *Service
baseURL string baseURL string
@@ -34,6 +47,7 @@ func (h *Handler) GetInfo(c *fiber.Ctx) error {
if err != nil { if err != nil {
return apperror.NotFound("다운로드 정보가 없습니다") return apperror.NotFound("다운로드 정보가 없습니다")
} }
c.Set("X-API-Version", downloadAPIVersion)
return c.JSON(info) return c.JSON(info)
} }
@@ -89,6 +103,7 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error {
if info != nil && info.FileName != "" { if info != nil && info.FileName != "" {
filename = info.FileName filename = info.FileName
} }
c.Set("X-API-Version", downloadAPIVersion)
c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
return c.SendFile(path) return c.SendFile(path)
} }
@@ -128,6 +143,7 @@ func (h *Handler) ServeLauncher(c *fiber.Ctx) error {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
return apperror.NotFound("파일이 없습니다") return apperror.NotFound("파일이 없습니다")
} }
c.Set("X-API-Version", downloadAPIVersion)
c.Set("Content-Disposition", `attachment; filename="launcher.exe"`) c.Set("Content-Disposition", `attachment; filename="launcher.exe"`)
return c.SendFile(path) return c.SendFile(path)
} }

View File

@@ -141,12 +141,8 @@ func (s *Service) SaveGameData(userID uint, data *GameDataRequest) error {
updates["last_rot_y"] = *data.LastRotY updates["last_rot_y"] = *data.LastRotY
} }
if data.PlayTimeDelta != nil { if data.PlayTimeDelta != nil {
// 플레이 시간은 delta로 누적 // 원자적 SQL 업데이트로 동시 요청 시 race condition 방지
profile, err := s.repo.FindByUserID(userID) updates["total_play_time"] = gorm.Expr("total_play_time + ?", *data.PlayTimeDelta)
if err != nil {
return fmt.Errorf("프로필이 존재하지 않습니다")
}
updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta
} }
if len(updates) == 0 { if len(updates) == 0 {

View File

@@ -98,11 +98,9 @@ func (s *testableService) SaveGameData(userID uint, data *GameDataRequest) error
updates["last_rot_y"] = *data.LastRotY updates["last_rot_y"] = *data.LastRotY
} }
if data.PlayTimeDelta != nil { if data.PlayTimeDelta != nil {
profile, err := s.repo.FindByUserID(userID) // Mirror the real service: atomic increment via delta value.
if err != nil { // The mock UpdateStats handles this by adding to the existing value.
return fmt.Errorf("프로필이 존재하지 않습니다") updates["total_play_time_delta"] = *data.PlayTimeDelta
}
updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta
} }
if len(updates) == 0 { if len(updates) == 0 {
@@ -211,6 +209,9 @@ func (m *mockRepo) UpdateStats(userID uint, updates map[string]interface{}) erro
p.LastRotY = val.(float64) p.LastRotY = val.(float64)
case "total_play_time": case "total_play_time":
p.TotalPlayTime = val.(int64) p.TotalPlayTime = val.(int64)
case "total_play_time_delta":
// Simulates SQL: total_play_time = total_play_time + delta
p.TotalPlayTime += val.(int64)
} }
} }
return nil return nil

41
main.go
View File

@@ -51,29 +51,31 @@ func main() {
config.Load() config.Load()
config.WarnInsecureDefaults() config.WarnInsecureDefaults()
if err := database.ConnectMySQL(); err != nil { db, err := database.ConnectMySQL()
if err != nil {
log.Fatalf("MySQL 연결 실패: %v", err) log.Fatalf("MySQL 연결 실패: %v", err)
} }
log.Println("MySQL 연결 성공") log.Println("MySQL 연결 성공")
// AutoMigrate // AutoMigrate
if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &bossraid.RewardFailure{}, &player.PlayerProfile{}); err != nil { if err := db.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &bossraid.RewardFailure{}, &player.PlayerProfile{}); err != nil {
log.Fatalf("AutoMigrate 실패: %v", err) log.Fatalf("AutoMigrate 실패: %v", err)
} }
if err := database.ConnectRedis(); err != nil { rdb, err := database.ConnectRedis()
if err != nil {
log.Fatalf("Redis 연결 실패: %v", err) log.Fatalf("Redis 연결 실패: %v", err)
} }
log.Println("Redis 연결 성공") log.Println("Redis 연결 성공")
// 의존성 주입 // 의존성 주입
authRepo := auth.NewRepository(database.DB) authRepo := auth.NewRepository(db)
authSvc := auth.NewService(authRepo, database.RDB) authSvc := auth.NewService(authRepo, rdb)
authHandler := auth.NewHandler(authSvc) authHandler := auth.NewHandler(authSvc)
// Chain (blockchain integration) // Chain (blockchain integration)
chainClient := chain.NewClient(config.C.ChainNodeURL) chainClient := chain.NewClient(config.C.ChainNodeURL)
chainRepo := chain.NewRepository(database.DB) chainRepo := chain.NewRepository(db)
chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey) chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey)
if err != nil { if err != nil {
log.Fatalf("chain service init failed: %v", err) log.Fatalf("chain service init failed: %v", err)
@@ -96,7 +98,7 @@ func main() {
}) })
// Player Profile // Player Profile
playerRepo := player.NewRepository(database.DB) playerRepo := player.NewRepository(db)
playerSvc := player.NewService(playerRepo) playerSvc := player.NewService(playerRepo)
playerSvc.SetUserResolver(func(username string) (uint, error) { playerSvc.SetUserResolver(func(username string) (uint, error) {
user, err := authRepo.FindByUsername(username) user, err := authRepo.FindByUsername(username)
@@ -120,8 +122,8 @@ func main() {
} }
// Boss Raid // Boss Raid
brRepo := bossraid.NewRepository(database.DB) brRepo := bossraid.NewRepository(db)
brSvc := bossraid.NewService(brRepo, database.RDB) brSvc := bossraid.NewService(brRepo, rdb)
brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) _, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err return err
@@ -135,14 +137,19 @@ func main() {
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled") log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
} }
annRepo := announcement.NewRepository(database.DB) annRepo := announcement.NewRepository(db)
annSvc := announcement.NewService(annRepo) annSvc := announcement.NewService(annRepo)
annHandler := announcement.NewHandler(annSvc) annHandler := announcement.NewHandler(annSvc)
dlRepo := download.NewRepository(database.DB) dlRepo := download.NewRepository(db)
dlSvc := download.NewService(dlRepo, config.C.GameDir) dlSvc := download.NewService(dlRepo, config.C.GameDir)
dlHandler := download.NewHandler(dlSvc, config.C.BaseURL) dlHandler := download.NewHandler(dlSvc, config.C.BaseURL)
// 미들웨어 인스턴스 생성 (DI)
authMw := middleware.Auth(rdb, config.C.JWTSecret)
serverAuthMw := middleware.ServerAuth(config.C.InternalAPIKey)
idempotencyReqMw := middleware.IdempotencyRequired(rdb)
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
StreamRequestBody: true, StreamRequestBody: true,
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
@@ -192,14 +199,14 @@ func main() {
return c.JSON(fiber.Map{"status": "ok"}) return c.JSON(fiber.Map{"status": "ok"})
} }
readyCheck := func(c *fiber.Ctx) error { readyCheck := func(c *fiber.Ctx) error {
sqlDB, err := database.DB.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db pool"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db pool"})
} }
if err := sqlDB.Ping(); err != nil { if err := sqlDB.Ping(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db"})
} }
if err := database.RDB.Ping(c.Context()).Err(); err != nil { if err := rdb.Ping(c.Context()).Err(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "redis"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "redis"})
} }
return c.JSON(fiber.Map{"status": "ok"}) return c.JSON(fiber.Map{"status": "ok"})
@@ -220,7 +227,7 @@ func main() {
}, },
}) })
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler, authLimiter, apiLimiter, healthCheck, readyCheck, chainUserLimiter) routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler, authLimiter, apiLimiter, healthCheck, readyCheck, chainUserLimiter, authMw, serverAuthMw, idempotencyReqMw)
// Background: stale dedicated server detection // Background: stale dedicated server detection
go func() { go func() {
@@ -255,15 +262,15 @@ func main() {
log.Printf("서버 종료 실패: %v", err) log.Printf("서버 종료 실패: %v", err)
} }
// Redis 연결 정리 // Redis 연결 정리
if database.RDB != nil { if rdb != nil {
if err := database.RDB.Close(); err != nil { if err := rdb.Close(); err != nil {
log.Printf("Redis 종료 실패: %v", err) log.Printf("Redis 종료 실패: %v", err)
} else { } else {
log.Println("Redis 연결 종료 완료") log.Println("Redis 연결 종료 완료")
} }
} }
// MySQL 연결 정리 // MySQL 연결 정리
if sqlDB, err := database.DB.DB(); err == nil { if sqlDB, err := db.DB(); err == nil {
if err := sqlDB.Close(); err != nil { if err := sqlDB.Close(); err != nil {
log.Printf("MySQL 종료 실패: %v", err) log.Printf("MySQL 종료 실패: %v", err)
} else { } else {

View File

@@ -9,28 +9,23 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// TODO: Consider injecting DB as a dependency instead of using a package-level global func ConnectMySQL() (*gorm.DB, error) {
// to improve testability. Currently, middleware directly accesses this global.
var DB *gorm.DB
func ConnectMySQL() error {
c := config.C c := config.C
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName,
) )
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil { if err != nil {
return err return nil, err
} }
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return fmt.Errorf("sql.DB 획득 실패: %w", err) return nil, fmt.Errorf("sql.DB 획득 실패: %w", err)
} }
sqlDB.SetMaxOpenConns(25) sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute) sqlDB.SetConnMaxLifetime(5 * time.Minute)
DB = db return db, nil
return nil
} }

View File

@@ -7,14 +7,13 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
// TODO: Consider injecting RDB as a dependency instead of using a package-level global func ConnectRedis() (*redis.Client, error) {
// to improve testability. Currently, middleware directly accesses this global. rdb := redis.NewClient(&redis.Options{
var RDB *redis.Client
func ConnectRedis() error {
RDB = redis.NewClient(&redis.Options{
Addr: config.C.RedisAddr, Addr: config.C.RedisAddr,
Password: config.C.RedisPassword, Password: config.C.RedisPassword,
}) })
return RDB.Ping(context.Background()).Err() if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, err
}
return rdb, nil
} }

View File

@@ -8,60 +8,63 @@ import (
"strings" "strings"
"a301_server/pkg/apperror" "a301_server/pkg/apperror"
"a301_server/pkg/config"
"a301_server/pkg/database"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
) )
func Auth(c *fiber.Ctx) error { // Auth returns a middleware that validates JWT tokens and checks Redis sessions.
header := c.Get("Authorization") func Auth(rdb *redis.Client, jwtSecret string) fiber.Handler {
if !strings.HasPrefix(header, "Bearer ") { secretBytes := []byte(jwtSecret)
return apperror.ErrUnauthorized return func(c *fiber.Ctx) error {
} header := c.Get("Authorization")
tokenStr := strings.TrimPrefix(header, "Bearer ") if !strings.HasPrefix(header, "Bearer ") {
return apperror.ErrUnauthorized
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
} }
return []byte(config.C.JWTSecret), nil tokenStr := strings.TrimPrefix(header, "Bearer ")
})
if err != nil || !token.Valid {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
claims, ok := token.Claims.(jwt.MapClaims) token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다") return nil, fmt.Errorf("unexpected signing method")
} }
userIDFloat, ok := claims["user_id"].(float64) return secretBytes, nil
if !ok { })
return apperror.Unauthorized("유효하지 않은 토큰입니다") if err != nil || !token.Valid {
} return apperror.Unauthorized("유효하지 않은 토큰입니다")
username, ok := claims["username"].(string) }
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
role, ok := claims["role"].(string)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
userID := uint(userIDFloat)
// Redis 세션 확인 claims, ok := token.Claims.(jwt.MapClaims)
ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) if !ok {
defer cancel() return apperror.Unauthorized("유효하지 않은 토큰입니다")
key := fmt.Sprintf("session:%d", userID) }
stored, err := database.RDB.Get(ctx, key).Result() userIDFloat, ok := claims["user_id"].(float64)
if err != nil || stored != tokenStr { if !ok {
return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다") return apperror.Unauthorized("유효하지 않은 토큰입니다")
} }
username, ok := claims["username"].(string)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
role, ok := claims["role"].(string)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
userID := uint(userIDFloat)
c.Locals("userID", userID) // Redis 세션 확인
c.Locals("username", username) ctx, cancel := context.WithTimeout(context.Background(), redisTimeout)
c.Locals("role", role) defer cancel()
return c.Next() key := fmt.Sprintf("session:%d", userID)
stored, err := rdb.Get(ctx, key).Result()
if err != nil || stored != tokenStr {
return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다")
}
c.Locals("userID", userID)
c.Locals("username", username)
c.Locals("role", role)
return c.Next()
}
} }
func AdminOnly(c *fiber.Ctx) error { func AdminOnly(c *fiber.Ctx) error {
@@ -71,14 +74,16 @@ func AdminOnly(c *fiber.Ctx) error {
return c.Next() return c.Next()
} }
// ServerAuth validates X-API-Key header for server-to-server communication. // ServerAuth returns a middleware that validates X-API-Key header for server-to-server communication.
// Uses constant-time comparison to prevent timing attacks. // Uses constant-time comparison to prevent timing attacks.
func ServerAuth(c *fiber.Ctx) error { func ServerAuth(apiKey string) fiber.Handler {
key := c.Get("X-API-Key") expectedBytes := []byte(apiKey)
expected := config.C.InternalAPIKey return func(c *fiber.Ctx) error {
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 { key := c.Get("X-API-Key")
log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path()) if key == "" || len(expectedBytes) == 0 || subtle.ConstantTimeCompare([]byte(key), expectedBytes) != 1 {
return apperror.Unauthorized("유효하지 않은 API 키입니다") log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path())
return apperror.Unauthorized("유효하지 않은 API 키입니다")
}
return c.Next()
} }
return c.Next()
} }

View File

@@ -8,8 +8,8 @@ import (
"time" "time"
"a301_server/pkg/apperror" "a301_server/pkg/apperror"
"a301_server/pkg/database"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
) )
const idempotencyTTL = 10 * time.Minute const idempotencyTTL = 10 * time.Minute
@@ -20,95 +20,100 @@ type cachedResponse struct {
Body json.RawMessage `json:"b"` Body json.RawMessage `json:"b"`
} }
// IdempotencyRequired rejects requests without an Idempotency-Key header, // IdempotencyRequired returns a middleware that rejects requests without an Idempotency-Key header,
// then delegates to Idempotency for cache/replay logic. // then delegates to idempotency cache/replay logic.
func IdempotencyRequired(c *fiber.Ctx) error { func IdempotencyRequired(rdb *redis.Client) fiber.Handler {
if c.Get("Idempotency-Key") == "" { idempotency := Idempotency(rdb)
return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다") return func(c *fiber.Ctx) error {
if c.Get("Idempotency-Key") == "" {
return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다")
}
return idempotency(c)
} }
return Idempotency(c)
} }
// Idempotency checks the Idempotency-Key header to prevent duplicate transactions. // Idempotency returns a middleware that checks the Idempotency-Key header to prevent duplicate transactions.
// If the same key is seen again within the TTL, the cached response is returned. // If the same key is seen again within the TTL, the cached response is returned.
func Idempotency(c *fiber.Ctx) error { func Idempotency(rdb *redis.Client) fiber.Handler {
key := c.Get("Idempotency-Key") return func(c *fiber.Ctx) error {
if key == "" { key := c.Get("Idempotency-Key")
return c.Next() if key == "" {
} return c.Next()
if len(key) > 256 { }
return apperror.BadRequest("Idempotency-Key가 너무 깁니다") if len(key) > 256 {
} return apperror.BadRequest("Idempotency-Key가 너무 깁니다")
}
// userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지 // userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지
redisKey := "idempotency:" redisKey := "idempotency:"
if uid, ok := c.Locals("userID").(uint); ok { if uid, ok := c.Locals("userID").(uint); ok {
redisKey += fmt.Sprintf("u%d:", uid) redisKey += fmt.Sprintf("u%d:", uid)
} }
redisKey += key redisKey += key
ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) ctx, cancel := context.WithTimeout(context.Background(), redisTimeout)
defer cancel() defer cancel()
// Atomically claim the key using SET NX (only succeeds if key doesn't exist) // Atomically claim the key using SET NX (only succeeds if key doesn't exist)
set, err := database.RDB.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result() set, err := rdb.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — let the request through rather than blocking
log.Printf("WARNING: idempotency SetNX failed (key=%s): %v", key, err)
return c.Next()
}
if !set {
// Key already exists — either processing or completed
getCtx, getCancel := context.WithTimeout(context.Background(), redisTimeout)
defer getCancel()
cached, err := database.RDB.Get(getCtx, redisKey).Bytes()
if err != nil { if err != nil {
// Redis error — let the request through rather than blocking
log.Printf("WARNING: idempotency SetNX failed (key=%s): %v", key, err)
return c.Next()
}
if !set {
// Key already exists — either processing or completed
getCtx, getCancel := context.WithTimeout(context.Background(), redisTimeout)
defer getCancel()
cached, err := rdb.Get(getCtx, redisKey).Bytes()
if err != nil {
return apperror.Conflict("요청이 처리 중입니다")
}
if string(cached) == "processing" {
return apperror.Conflict("요청이 처리 중입니다")
}
var cr cachedResponse
if json.Unmarshal(cached, &cr) == nil {
c.Set("Content-Type", "application/json")
c.Set("X-Idempotent-Replay", "true")
return c.Status(cr.StatusCode).Send(cr.Body)
}
return apperror.Conflict("요청이 처리 중입니다") return apperror.Conflict("요청이 처리 중입니다")
} }
if string(cached) == "processing" {
return apperror.Conflict("요청이 처리 중입니다")
}
var cr cachedResponse
if json.Unmarshal(cached, &cr) == nil {
c.Set("Content-Type", "application/json")
c.Set("X-Idempotent-Replay", "true")
return c.Status(cr.StatusCode).Send(cr.Body)
}
return apperror.Conflict("요청이 처리 중입니다")
}
// We claimed the key — process the request // We claimed the key — process the request
if err := c.Next(); err != nil { if err := c.Next(); err != nil {
// Processing failed — remove the key so it can be retried // Processing failed — remove the key so it can be retried
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout) delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel() defer delCancel()
if delErr := database.RDB.Del(delCtx, redisKey).Err(); delErr != nil { if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr) log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
return err
} }
return err
}
// Cache successful responses (2xx), otherwise remove the key for retry // Cache successful responses (2xx), otherwise remove the key for retry
status := c.Response().StatusCode() status := c.Response().StatusCode()
if status >= 200 && status < 300 { if status >= 200 && status < 300 {
cr := cachedResponse{StatusCode: status, Body: c.Response().Body()} cr := cachedResponse{StatusCode: status, Body: c.Response().Body()}
if data, err := json.Marshal(cr); err == nil { if data, err := json.Marshal(cr); err == nil {
writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout) writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout)
defer writeCancel() defer writeCancel()
if err := database.RDB.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil { if err := rdb.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil {
log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err) log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err)
}
}
} else {
// Non-success — allow retry by removing the key
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
} }
} }
} else {
// Non-success — allow retry by removing the key
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
if delErr := database.RDB.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
}
return nil return nil
}
} }

View File

@@ -25,6 +25,9 @@ func Register(
healthCheck fiber.Handler, healthCheck fiber.Handler,
readyCheck fiber.Handler, readyCheck fiber.Handler,
chainUserLimiter fiber.Handler, chainUserLimiter fiber.Handler,
authMw fiber.Handler,
serverAuthMw fiber.Handler,
idempotencyReqMw fiber.Handler,
) { ) {
// Swagger UI // Swagger UI
app.Get("/swagger/*", swagger.HandlerDefault) app.Get("/swagger/*", swagger.HandlerDefault)
@@ -38,13 +41,13 @@ func Register(
// ── Internal API (Rate Limit 제외, API Key 인증만) ────────────── // ── Internal API (Rate Limit 제외, API Key 인증만) ──────────────
// 반드시 /api 그룹보다 먼저 등록해야 apiLimiter를 우회함 // 반드시 /api 그룹보다 먼저 등록해야 apiLimiter를 우회함
internalApi := app.Group("/api/internal", apiBodyLimit, middleware.ServerAuth) internalApi := app.Group("/api/internal", apiBodyLimit, serverAuthMw)
// Internal - Boss Raid // Internal - Boss Raid
br := internalApi.Group("/bossraid") br := internalApi.Group("/bossraid")
br.Post("/entry", brH.RequestEntry) br.Post("/entry", brH.RequestEntry)
br.Post("/start", brH.StartRaid) br.Post("/start", brH.StartRaid)
br.Post("/complete", middleware.IdempotencyRequired, brH.CompleteRaid) br.Post("/complete", idempotencyReqMw, brH.CompleteRaid)
br.Post("/fail", brH.FailRaid) br.Post("/fail", brH.FailRaid)
br.Get("/room", brH.GetRoom) br.Get("/room", brH.GetRoom)
br.Post("/validate-entry", brH.ValidateEntryToken) br.Post("/validate-entry", brH.ValidateEntryToken)
@@ -64,8 +67,8 @@ func Register(
// Internal - Chain // Internal - Chain
internalChain := internalApi.Group("/chain") internalChain := internalApi.Group("/chain")
internalChain.Post("/reward", middleware.IdempotencyRequired, chainH.InternalGrantReward) internalChain.Post("/reward", idempotencyReqMw, chainH.InternalGrantReward)
internalChain.Post("/mint", middleware.IdempotencyRequired, chainH.InternalMintAsset) internalChain.Post("/mint", idempotencyReqMw, chainH.InternalMintAsset)
internalChain.Get("/balance", chainH.InternalGetBalance) internalChain.Get("/balance", chainH.InternalGetBalance)
internalChain.Get("/assets", chainH.InternalGetAssets) internalChain.Get("/assets", chainH.InternalGetAssets)
internalChain.Get("/inventory", chainH.InternalGetInventory) internalChain.Get("/inventory", chainH.InternalGetInventory)
@@ -78,15 +81,15 @@ func Register(
a.Post("/register", authLimiter, authH.Register) a.Post("/register", authLimiter, authH.Register)
a.Post("/login", authLimiter, authH.Login) a.Post("/login", authLimiter, authH.Login)
a.Post("/refresh", authLimiter, authH.Refresh) a.Post("/refresh", authLimiter, authH.Refresh)
a.Post("/logout", middleware.Auth, authH.Logout) a.Post("/logout", authMw, authH.Logout)
// /verify moved to internal API (ServerAuth) — see internal section below // /verify moved to internal API (ServerAuth) — see internal section below
a.Get("/ssafy/login", authH.SSAFYLoginURL) a.Get("/ssafy/login", authH.SSAFYLoginURL)
a.Post("/ssafy/callback", authLimiter, authH.SSAFYCallback) a.Post("/ssafy/callback", authLimiter, authH.SSAFYCallback)
a.Post("/launch-ticket", middleware.Auth, authH.CreateLaunchTicket) a.Post("/launch-ticket", authMw, authH.CreateLaunchTicket)
a.Post("/redeem-ticket", authLimiter, authH.RedeemLaunchTicket) a.Post("/redeem-ticket", authLimiter, authH.RedeemLaunchTicket)
// Users (admin only) // Users (admin only)
u := api.Group("/users", middleware.Auth, middleware.AdminOnly) u := api.Group("/users", authMw, middleware.AdminOnly)
u.Get("/", authH.GetAllUsers) u.Get("/", authH.GetAllUsers)
u.Patch("/:id/role", authH.UpdateRole) u.Patch("/:id/role", authH.UpdateRole)
u.Delete("/:id", authH.DeleteUser) u.Delete("/:id", authH.DeleteUser)
@@ -94,20 +97,20 @@ func Register(
// Announcements // Announcements
ann := api.Group("/announcements") ann := api.Group("/announcements")
ann.Get("/", annH.GetAll) ann.Get("/", annH.GetAll)
ann.Post("/", middleware.Auth, middleware.AdminOnly, annH.Create) ann.Post("/", authMw, middleware.AdminOnly, annH.Create)
ann.Put("/:id", middleware.Auth, middleware.AdminOnly, annH.Update) ann.Put("/:id", authMw, middleware.AdminOnly, annH.Update)
ann.Delete("/:id", middleware.Auth, middleware.AdminOnly, annH.Delete) ann.Delete("/:id", authMw, middleware.AdminOnly, annH.Delete)
// Download // Download
dl := api.Group("/download") dl := api.Group("/download")
dl.Get("/info", dlH.GetInfo) dl.Get("/info", dlH.GetInfo)
dl.Get("/file", dlH.ServeFile) dl.Get("/file", dlH.ServeFile)
dl.Get("/launcher", dlH.ServeLauncher) dl.Get("/launcher", dlH.ServeLauncher)
dl.Post("/upload/game", middleware.Auth, middleware.AdminOnly, dlH.Upload) dl.Post("/upload/game", authMw, middleware.AdminOnly, dlH.Upload)
dl.Post("/upload/launcher", middleware.Auth, middleware.AdminOnly, dlH.UploadLauncher) dl.Post("/upload/launcher", authMw, middleware.AdminOnly, dlH.UploadLauncher)
// Chain - Queries (authenticated) // Chain - Queries (authenticated)
ch := api.Group("/chain", middleware.Auth) ch := api.Group("/chain", authMw)
ch.Get("/wallet", chainH.GetWalletInfo) ch.Get("/wallet", chainH.GetWalletInfo)
ch.Get("/balance", chainH.GetBalance) ch.Get("/balance", chainH.GetBalance)
ch.Get("/assets", chainH.GetAssets) ch.Get("/assets", chainH.GetAssets)
@@ -117,22 +120,22 @@ func Register(
ch.Get("/market/:id", chainH.GetMarketListing) ch.Get("/market/:id", chainH.GetMarketListing)
// Chain - User Transactions (authenticated, per-user rate limited, idempotency-protected) // Chain - User Transactions (authenticated, per-user rate limited, idempotency-protected)
ch.Post("/transfer", chainUserLimiter, middleware.IdempotencyRequired, chainH.Transfer) ch.Post("/transfer", chainUserLimiter, idempotencyReqMw, chainH.Transfer)
ch.Post("/asset/transfer", chainUserLimiter, middleware.IdempotencyRequired, chainH.TransferAsset) ch.Post("/asset/transfer", chainUserLimiter, idempotencyReqMw, chainH.TransferAsset)
ch.Post("/market/list", chainUserLimiter, middleware.IdempotencyRequired, chainH.ListOnMarket) ch.Post("/market/list", chainUserLimiter, idempotencyReqMw, chainH.ListOnMarket)
ch.Post("/market/buy", chainUserLimiter, middleware.IdempotencyRequired, chainH.BuyFromMarket) ch.Post("/market/buy", chainUserLimiter, idempotencyReqMw, chainH.BuyFromMarket)
ch.Post("/market/cancel", chainUserLimiter, middleware.IdempotencyRequired, chainH.CancelListing) ch.Post("/market/cancel", chainUserLimiter, idempotencyReqMw, chainH.CancelListing)
ch.Post("/inventory/equip", chainUserLimiter, middleware.IdempotencyRequired, chainH.EquipItem) ch.Post("/inventory/equip", chainUserLimiter, idempotencyReqMw, chainH.EquipItem)
ch.Post("/inventory/unequip", chainUserLimiter, middleware.IdempotencyRequired, chainH.UnequipItem) ch.Post("/inventory/unequip", chainUserLimiter, idempotencyReqMw, chainH.UnequipItem)
// Chain - Admin Transactions (admin only, idempotency-protected) // Chain - Admin Transactions (admin only, idempotency-protected)
chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly) chainAdmin := api.Group("/chain/admin", authMw, middleware.AdminOnly)
chainAdmin.Post("/mint", middleware.IdempotencyRequired, chainH.MintAsset) chainAdmin.Post("/mint", idempotencyReqMw, chainH.MintAsset)
chainAdmin.Post("/reward", middleware.IdempotencyRequired, chainH.GrantReward) chainAdmin.Post("/reward", idempotencyReqMw, chainH.GrantReward)
chainAdmin.Post("/template", middleware.IdempotencyRequired, chainH.RegisterTemplate) chainAdmin.Post("/template", idempotencyReqMw, chainH.RegisterTemplate)
// Player Profile (authenticated) // Player Profile (authenticated)
p := api.Group("/player", middleware.Auth) p := api.Group("/player", authMw)
p.Get("/profile", playerH.GetProfile) p.Get("/profile", playerH.GetProfile)
p.Put("/profile", playerH.UpdateProfile) p.Put("/profile", playerH.UpdateProfile)