From 0dfa744c161be8a678d05a9a0b1c58a76af2c6cd Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 16:58:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DB=20DI=20=EC=A0=84=ED=99=98=20+=20down?= =?UTF-8?q?load=20=ED=95=98=EC=9C=84=20=ED=98=B8=ED=99=98=EC=84=B1=20+=20r?= =?UTF-8?q?ace=20condition=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/download/handler.go | 16 ++++ internal/player/service.go | 8 +- internal/player/service_test.go | 11 ++- main.go | 41 +++++---- pkg/database/mysql.go | 13 +-- pkg/database/redis.go | 13 ++- pkg/middleware/auth.go | 113 ++++++++++++----------- pkg/middleware/idempotency.go | 157 ++++++++++++++++---------------- routes/routes.go | 53 ++++++----- 9 files changed, 226 insertions(+), 199 deletions(-) diff --git a/internal/download/handler.go b/internal/download/handler.go index 3f3d416..f0981e2 100644 --- a/internal/download/handler.go +++ b/internal/download/handler.go @@ -12,6 +12,19 @@ import ( "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 { svc *Service baseURL string @@ -34,6 +47,7 @@ func (h *Handler) GetInfo(c *fiber.Ctx) error { if err != nil { return apperror.NotFound("다운로드 정보가 없습니다") } + c.Set("X-API-Version", downloadAPIVersion) return c.JSON(info) } @@ -89,6 +103,7 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error { if info != nil && info.FileName != "" { filename = info.FileName } + c.Set("X-API-Version", downloadAPIVersion) c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) return c.SendFile(path) } @@ -128,6 +143,7 @@ func (h *Handler) ServeLauncher(c *fiber.Ctx) error { if _, err := os.Stat(path); err != nil { return apperror.NotFound("파일이 없습니다") } + c.Set("X-API-Version", downloadAPIVersion) c.Set("Content-Disposition", `attachment; filename="launcher.exe"`) return c.SendFile(path) } diff --git a/internal/player/service.go b/internal/player/service.go index f65a204..735f4a4 100644 --- a/internal/player/service.go +++ b/internal/player/service.go @@ -141,12 +141,8 @@ func (s *Service) SaveGameData(userID uint, data *GameDataRequest) error { updates["last_rot_y"] = *data.LastRotY } if data.PlayTimeDelta != nil { - // 플레이 시간은 delta로 누적 - profile, err := s.repo.FindByUserID(userID) - if err != nil { - return fmt.Errorf("프로필이 존재하지 않습니다") - } - updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta + // 원자적 SQL 업데이트로 동시 요청 시 race condition 방지 + updates["total_play_time"] = gorm.Expr("total_play_time + ?", *data.PlayTimeDelta) } if len(updates) == 0 { diff --git a/internal/player/service_test.go b/internal/player/service_test.go index bd7a554..f8ca34b 100644 --- a/internal/player/service_test.go +++ b/internal/player/service_test.go @@ -98,11 +98,9 @@ func (s *testableService) SaveGameData(userID uint, data *GameDataRequest) error updates["last_rot_y"] = *data.LastRotY } if data.PlayTimeDelta != nil { - profile, err := s.repo.FindByUserID(userID) - if err != nil { - return fmt.Errorf("프로필이 존재하지 않습니다") - } - updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta + // Mirror the real service: atomic increment via delta value. + // The mock UpdateStats handles this by adding to the existing value. + updates["total_play_time_delta"] = *data.PlayTimeDelta } if len(updates) == 0 { @@ -211,6 +209,9 @@ func (m *mockRepo) UpdateStats(userID uint, updates map[string]interface{}) erro p.LastRotY = val.(float64) case "total_play_time": p.TotalPlayTime = val.(int64) + case "total_play_time_delta": + // Simulates SQL: total_play_time = total_play_time + delta + p.TotalPlayTime += val.(int64) } } return nil diff --git a/main.go b/main.go index c3f016f..19cf774 100644 --- a/main.go +++ b/main.go @@ -51,29 +51,31 @@ func main() { config.Load() config.WarnInsecureDefaults() - if err := database.ConnectMySQL(); err != nil { + db, err := database.ConnectMySQL() + if err != nil { log.Fatalf("MySQL 연결 실패: %v", err) } log.Println("MySQL 연결 성공") // 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) } - if err := database.ConnectRedis(); err != nil { + rdb, err := database.ConnectRedis() + if err != nil { log.Fatalf("Redis 연결 실패: %v", err) } log.Println("Redis 연결 성공") // 의존성 주입 - authRepo := auth.NewRepository(database.DB) - authSvc := auth.NewService(authRepo, database.RDB) + authRepo := auth.NewRepository(db) + authSvc := auth.NewService(authRepo, rdb) authHandler := auth.NewHandler(authSvc) // Chain (blockchain integration) 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) if err != nil { log.Fatalf("chain service init failed: %v", err) @@ -96,7 +98,7 @@ func main() { }) // Player Profile - playerRepo := player.NewRepository(database.DB) + playerRepo := player.NewRepository(db) playerSvc := player.NewService(playerRepo) playerSvc.SetUserResolver(func(username string) (uint, error) { user, err := authRepo.FindByUsername(username) @@ -120,8 +122,8 @@ func main() { } // Boss Raid - brRepo := bossraid.NewRepository(database.DB) - brSvc := bossraid.NewService(brRepo, database.RDB) + 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 @@ -135,14 +137,19 @@ func main() { 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) annHandler := announcement.NewHandler(annSvc) - dlRepo := download.NewRepository(database.DB) + dlRepo := download.NewRepository(db) dlSvc := download.NewService(dlRepo, config.C.GameDir) 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{ StreamRequestBody: true, BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB @@ -192,14 +199,14 @@ func main() { return c.JSON(fiber.Map{"status": "ok"}) } readyCheck := func(c *fiber.Ctx) error { - sqlDB, err := database.DB.DB() + sqlDB, err := db.DB() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db pool"}) } if err := sqlDB.Ping(); err != nil { 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.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 go func() { @@ -255,15 +262,15 @@ func main() { log.Printf("서버 종료 실패: %v", err) } // Redis 연결 정리 - if database.RDB != nil { - if err := database.RDB.Close(); err != nil { + if rdb != nil { + if err := rdb.Close(); err != nil { log.Printf("Redis 종료 실패: %v", err) } else { log.Println("Redis 연결 종료 완료") } } // MySQL 연결 정리 - if sqlDB, err := database.DB.DB(); err == nil { + if sqlDB, err := db.DB(); err == nil { if err := sqlDB.Close(); err != nil { log.Printf("MySQL 종료 실패: %v", err) } else { diff --git a/pkg/database/mysql.go b/pkg/database/mysql.go index 2ede92b..6e09383 100644 --- a/pkg/database/mysql.go +++ b/pkg/database/mysql.go @@ -9,28 +9,23 @@ import ( "gorm.io/gorm" ) -// TODO: Consider injecting DB as a dependency instead of using a package-level global -// to improve testability. Currently, middleware directly accesses this global. -var DB *gorm.DB - -func ConnectMySQL() error { +func ConnectMySQL() (*gorm.DB, error) { c := config.C 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, ) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { - return err + return nil, err } sqlDB, err := db.DB() if err != nil { - return fmt.Errorf("sql.DB 획득 실패: %w", err) + return nil, fmt.Errorf("sql.DB 획득 실패: %w", err) } sqlDB.SetMaxOpenConns(25) sqlDB.SetMaxIdleConns(10) sqlDB.SetConnMaxLifetime(5 * time.Minute) - DB = db - return nil + return db, nil } diff --git a/pkg/database/redis.go b/pkg/database/redis.go index 93b7b4e..b0e616a 100644 --- a/pkg/database/redis.go +++ b/pkg/database/redis.go @@ -7,14 +7,13 @@ import ( "github.com/redis/go-redis/v9" ) -// TODO: Consider injecting RDB as a dependency instead of using a package-level global -// to improve testability. Currently, middleware directly accesses this global. -var RDB *redis.Client - -func ConnectRedis() error { - RDB = redis.NewClient(&redis.Options{ +func ConnectRedis() (*redis.Client, error) { + rdb := redis.NewClient(&redis.Options{ Addr: config.C.RedisAddr, 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 } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index f6ef865..e6b7c1f 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -8,60 +8,63 @@ import ( "strings" "a301_server/pkg/apperror" - "a301_server/pkg/config" - "a301_server/pkg/database" "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v5" + "github.com/redis/go-redis/v9" ) -func Auth(c *fiber.Ctx) error { - header := c.Get("Authorization") - if !strings.HasPrefix(header, "Bearer ") { - return apperror.ErrUnauthorized - } - tokenStr := strings.TrimPrefix(header, "Bearer ") - - 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") +// Auth returns a middleware that validates JWT tokens and checks Redis sessions. +func Auth(rdb *redis.Client, jwtSecret string) fiber.Handler { + secretBytes := []byte(jwtSecret) + return func(c *fiber.Ctx) error { + header := c.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + return apperror.ErrUnauthorized } - return []byte(config.C.JWTSecret), nil - }) - if err != nil || !token.Valid { - return apperror.Unauthorized("유효하지 않은 토큰입니다") - } + tokenStr := strings.TrimPrefix(header, "Bearer ") - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return apperror.Unauthorized("유효하지 않은 토큰입니다") - } - userIDFloat, ok := claims["user_id"].(float64) - if !ok { - 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) + 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 secretBytes, nil + }) + if err != nil || !token.Valid { + return apperror.Unauthorized("유효하지 않은 토큰입니다") + } - // Redis 세션 확인 - ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) - defer cancel() - key := fmt.Sprintf("session:%d", userID) - stored, err := database.RDB.Get(ctx, key).Result() - if err != nil || stored != tokenStr { - return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다") - } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return apperror.Unauthorized("유효하지 않은 토큰입니다") + } + userIDFloat, ok := claims["user_id"].(float64) + if !ok { + 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) - c.Locals("username", username) - c.Locals("role", role) - return c.Next() + // Redis 세션 확인 + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + 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 { @@ -71,14 +74,16 @@ func AdminOnly(c *fiber.Ctx) error { 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. -func ServerAuth(c *fiber.Ctx) error { - key := c.Get("X-API-Key") - expected := config.C.InternalAPIKey - if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 { - log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path()) - return apperror.Unauthorized("유효하지 않은 API 키입니다") +func ServerAuth(apiKey string) fiber.Handler { + expectedBytes := []byte(apiKey) + return func(c *fiber.Ctx) error { + key := c.Get("X-API-Key") + if key == "" || len(expectedBytes) == 0 || subtle.ConstantTimeCompare([]byte(key), expectedBytes) != 1 { + log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path()) + return apperror.Unauthorized("유효하지 않은 API 키입니다") + } + return c.Next() } - return c.Next() } diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go index da2caaa..89c2a1e 100644 --- a/pkg/middleware/idempotency.go +++ b/pkg/middleware/idempotency.go @@ -8,8 +8,8 @@ import ( "time" "a301_server/pkg/apperror" - "a301_server/pkg/database" "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" ) const idempotencyTTL = 10 * time.Minute @@ -20,95 +20,100 @@ type cachedResponse struct { Body json.RawMessage `json:"b"` } -// IdempotencyRequired rejects requests without an Idempotency-Key header, -// then delegates to Idempotency for cache/replay logic. -func IdempotencyRequired(c *fiber.Ctx) error { - if c.Get("Idempotency-Key") == "" { - return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다") +// IdempotencyRequired returns a middleware that rejects requests without an Idempotency-Key header, +// then delegates to idempotency cache/replay logic. +func IdempotencyRequired(rdb *redis.Client) fiber.Handler { + idempotency := Idempotency(rdb) + 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. -func Idempotency(c *fiber.Ctx) error { - key := c.Get("Idempotency-Key") - if key == "" { - return c.Next() - } - if len(key) > 256 { - return apperror.BadRequest("Idempotency-Key가 너무 깁니다") - } +func Idempotency(rdb *redis.Client) fiber.Handler { + return func(c *fiber.Ctx) error { + key := c.Get("Idempotency-Key") + if key == "" { + return c.Next() + } + if len(key) > 256 { + return apperror.BadRequest("Idempotency-Key가 너무 깁니다") + } - // userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지 - redisKey := "idempotency:" - if uid, ok := c.Locals("userID").(uint); ok { - redisKey += fmt.Sprintf("u%d:", uid) - } - redisKey += key + // userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지 + redisKey := "idempotency:" + if uid, ok := c.Locals("userID").(uint); ok { + redisKey += fmt.Sprintf("u%d:", uid) + } + redisKey += key - ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() - // Atomically claim the key using SET NX (only succeeds if key doesn't exist) - set, err := database.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() + // Atomically claim the key using SET NX (only succeeds if key doesn't exist) + 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 := 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("요청이 처리 중입니다") } - 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 - if err := c.Next(); err != nil { - // Processing failed — remove the key so it can be retried - 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) + // We claimed the key — process the request + if err := c.Next(); err != nil { + // Processing failed — remove the key so it can be retried + 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) + } + return err } - return err - } - // Cache successful responses (2xx), otherwise remove the key for retry - status := c.Response().StatusCode() - if status >= 200 && status < 300 { - cr := cachedResponse{StatusCode: status, Body: c.Response().Body()} - if data, err := json.Marshal(cr); err == nil { - writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout) - defer writeCancel() - if err := database.RDB.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil { - log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err) + // Cache successful responses (2xx), otherwise remove the key for retry + status := c.Response().StatusCode() + if status >= 200 && status < 300 { + cr := cachedResponse{StatusCode: status, Body: c.Response().Body()} + if data, err := json.Marshal(cr); err == nil { + writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout) + defer writeCancel() + if err := rdb.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil { + 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 + } } diff --git a/routes/routes.go b/routes/routes.go index 9af9493..1355125 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -25,6 +25,9 @@ func Register( healthCheck fiber.Handler, readyCheck fiber.Handler, chainUserLimiter fiber.Handler, + authMw fiber.Handler, + serverAuthMw fiber.Handler, + idempotencyReqMw fiber.Handler, ) { // Swagger UI app.Get("/swagger/*", swagger.HandlerDefault) @@ -38,13 +41,13 @@ func Register( // ── Internal API (Rate Limit 제외, API Key 인증만) ────────────── // 반드시 /api 그룹보다 먼저 등록해야 apiLimiter를 우회함 - internalApi := app.Group("/api/internal", apiBodyLimit, middleware.ServerAuth) + internalApi := app.Group("/api/internal", apiBodyLimit, serverAuthMw) // Internal - Boss Raid br := internalApi.Group("/bossraid") br.Post("/entry", brH.RequestEntry) br.Post("/start", brH.StartRaid) - br.Post("/complete", middleware.IdempotencyRequired, brH.CompleteRaid) + br.Post("/complete", idempotencyReqMw, brH.CompleteRaid) br.Post("/fail", brH.FailRaid) br.Get("/room", brH.GetRoom) br.Post("/validate-entry", brH.ValidateEntryToken) @@ -64,8 +67,8 @@ func Register( // Internal - Chain internalChain := internalApi.Group("/chain") - internalChain.Post("/reward", middleware.IdempotencyRequired, chainH.InternalGrantReward) - internalChain.Post("/mint", middleware.IdempotencyRequired, chainH.InternalMintAsset) + internalChain.Post("/reward", idempotencyReqMw, chainH.InternalGrantReward) + internalChain.Post("/mint", idempotencyReqMw, chainH.InternalMintAsset) internalChain.Get("/balance", chainH.InternalGetBalance) internalChain.Get("/assets", chainH.InternalGetAssets) internalChain.Get("/inventory", chainH.InternalGetInventory) @@ -78,15 +81,15 @@ func Register( a.Post("/register", authLimiter, authH.Register) a.Post("/login", authLimiter, authH.Login) 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 a.Get("/ssafy/login", authH.SSAFYLoginURL) 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) // Users (admin only) - u := api.Group("/users", middleware.Auth, middleware.AdminOnly) + u := api.Group("/users", authMw, middleware.AdminOnly) u.Get("/", authH.GetAllUsers) u.Patch("/:id/role", authH.UpdateRole) u.Delete("/:id", authH.DeleteUser) @@ -94,20 +97,20 @@ func Register( // Announcements ann := api.Group("/announcements") ann.Get("/", annH.GetAll) - ann.Post("/", middleware.Auth, middleware.AdminOnly, annH.Create) - ann.Put("/:id", middleware.Auth, middleware.AdminOnly, annH.Update) - ann.Delete("/:id", middleware.Auth, middleware.AdminOnly, annH.Delete) + ann.Post("/", authMw, middleware.AdminOnly, annH.Create) + ann.Put("/:id", authMw, middleware.AdminOnly, annH.Update) + ann.Delete("/:id", authMw, middleware.AdminOnly, annH.Delete) // Download dl := api.Group("/download") dl.Get("/info", dlH.GetInfo) dl.Get("/file", dlH.ServeFile) dl.Get("/launcher", dlH.ServeLauncher) - dl.Post("/upload/game", middleware.Auth, middleware.AdminOnly, dlH.Upload) - dl.Post("/upload/launcher", middleware.Auth, middleware.AdminOnly, dlH.UploadLauncher) + dl.Post("/upload/game", authMw, middleware.AdminOnly, dlH.Upload) + dl.Post("/upload/launcher", authMw, middleware.AdminOnly, dlH.UploadLauncher) // Chain - Queries (authenticated) - ch := api.Group("/chain", middleware.Auth) + ch := api.Group("/chain", authMw) ch.Get("/wallet", chainH.GetWalletInfo) ch.Get("/balance", chainH.GetBalance) ch.Get("/assets", chainH.GetAssets) @@ -117,22 +120,22 @@ func Register( ch.Get("/market/:id", chainH.GetMarketListing) // Chain - User Transactions (authenticated, per-user rate limited, idempotency-protected) - ch.Post("/transfer", chainUserLimiter, middleware.IdempotencyRequired, chainH.Transfer) - ch.Post("/asset/transfer", chainUserLimiter, middleware.IdempotencyRequired, chainH.TransferAsset) - ch.Post("/market/list", chainUserLimiter, middleware.IdempotencyRequired, chainH.ListOnMarket) - ch.Post("/market/buy", chainUserLimiter, middleware.IdempotencyRequired, chainH.BuyFromMarket) - ch.Post("/market/cancel", chainUserLimiter, middleware.IdempotencyRequired, chainH.CancelListing) - ch.Post("/inventory/equip", chainUserLimiter, middleware.IdempotencyRequired, chainH.EquipItem) - ch.Post("/inventory/unequip", chainUserLimiter, middleware.IdempotencyRequired, chainH.UnequipItem) + ch.Post("/transfer", chainUserLimiter, idempotencyReqMw, chainH.Transfer) + ch.Post("/asset/transfer", chainUserLimiter, idempotencyReqMw, chainH.TransferAsset) + ch.Post("/market/list", chainUserLimiter, idempotencyReqMw, chainH.ListOnMarket) + ch.Post("/market/buy", chainUserLimiter, idempotencyReqMw, chainH.BuyFromMarket) + ch.Post("/market/cancel", chainUserLimiter, idempotencyReqMw, chainH.CancelListing) + ch.Post("/inventory/equip", chainUserLimiter, idempotencyReqMw, chainH.EquipItem) + ch.Post("/inventory/unequip", chainUserLimiter, idempotencyReqMw, chainH.UnequipItem) // Chain - Admin Transactions (admin only, idempotency-protected) - chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly) - chainAdmin.Post("/mint", middleware.IdempotencyRequired, chainH.MintAsset) - chainAdmin.Post("/reward", middleware.IdempotencyRequired, chainH.GrantReward) - chainAdmin.Post("/template", middleware.IdempotencyRequired, chainH.RegisterTemplate) + chainAdmin := api.Group("/chain/admin", authMw, middleware.AdminOnly) + chainAdmin.Post("/mint", idempotencyReqMw, chainH.MintAsset) + chainAdmin.Post("/reward", idempotencyReqMw, chainH.GrantReward) + chainAdmin.Post("/template", idempotencyReqMw, chainH.RegisterTemplate) // Player Profile (authenticated) - p := api.Group("/player", middleware.Auth) + p := api.Group("/player", authMw) p.Get("/profile", playerH.GetProfile) p.Put("/profile", playerH.UpdateProfile)