diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..84d3365 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,110 @@ +package server + +import ( + "strconv" + "time" + + "a301_server/pkg/apperror" + "a301_server/pkg/metrics" + "a301_server/pkg/middleware" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// New creates a configured Fiber app with all global middleware applied. +func New() *fiber.App { + app := fiber.New(fiber.Config{ + StreamRequestBody: true, + BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB + ErrorHandler: middleware.ErrorHandler, + }) + app.Use(middleware.RequestID) + app.Use(middleware.Metrics) + app.Get("/metrics", metrics.Handler) + app.Use(logger.New(logger.Config{ + Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n", + TimeFormat: "2006-01-02T15:04:05Z07:00", + })) + app.Use(middleware.SecurityHeaders) + app.Use(cors.New(cors.Config{ + AllowOrigins: "https://a301.tolelom.xyz", + AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key", + AllowMethods: "GET, POST, PUT, PATCH, DELETE", + AllowCredentials: true, + })) + return app +} + +// AuthLimiter returns a rate limiter for auth endpoints (10 req/min per IP). +func AuthLimiter() fiber.Handler { + return limiter.New(limiter.Config{ + Max: 10, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return apperror.ErrRateLimited + }, + }) +} + +// APILimiter returns a rate limiter for general API endpoints (60 req/min per IP). +func APILimiter() fiber.Handler { + return limiter.New(limiter.Config{ + Max: 60, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return apperror.ErrRateLimited + }, + }) +} + +// ChainUserLimiter returns a rate limiter for chain transactions (20 req/min per user). +func ChainUserLimiter() fiber.Handler { + return limiter.New(limiter.Config{ + Max: 20, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + if uid, ok := c.Locals("userID").(uint); ok { + return "chain_user:" + strconv.FormatUint(uint64(uid), 10) + } + return "chain_ip:" + c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return apperror.ErrRateLimited + }, + }) +} + +// HealthCheck returns a handler that reports server liveness. +func HealthCheck() fiber.Handler { + return func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok"}) + } +} + +// ReadyCheck returns a handler that verifies DB and Redis connectivity. +func ReadyCheck(db *gorm.DB, rdb *redis.Client) fiber.Handler { + return func(c *fiber.Ctx) error { + 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 := 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"}) + } +} diff --git a/main.go b/main.go index 19cf774..2da5f8e 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,6 @@ import ( "log" "os" "os/signal" - "strconv" "syscall" "time" @@ -14,21 +13,15 @@ import ( "a301_server/internal/chain" "a301_server/internal/download" "a301_server/internal/player" + "a301_server/internal/server" _ "a301_server/docs" // swagger docs "github.com/tolelom/tolchain/core" - "a301_server/pkg/apperror" "a301_server/pkg/config" "a301_server/pkg/database" - "a301_server/pkg/metrics" "a301_server/pkg/middleware" "a301_server/routes" - - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/limiter" - "github.com/gofiber/fiber/v2/middleware/logger" ) // @title One of the Plans API @@ -68,12 +61,12 @@ func main() { } log.Println("Redis 연결 성공") - // 의존성 주입 + // ── 의존성 주입 ────────────────────────────────────────────────── + 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(db) chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey) @@ -82,46 +75,35 @@ func main() { } chainHandler := chain.NewHandler(chainSvc) - // username → userID 변환 (게임 서버 내부 API용) - chainSvc.SetUserResolver(func(username string) (uint, error) { + userResolver := func(username string) (uint, error) { user, err := authRepo.FindByUsername(username) if err != nil { return 0, err } return user.ID, nil - }) + } + chainSvc.SetUserResolver(userResolver) - // 회원가입 시 블록체인 월렛 자동 생성 authSvc.SetWalletCreator(func(userID uint) error { _, err := chainSvc.CreateWallet(userID) return err }) - // Player Profile playerRepo := player.NewRepository(db) playerSvc := player.NewService(playerRepo) - playerSvc.SetUserResolver(func(username string) (uint, error) { - user, err := authRepo.FindByUsername(username) - if err != nil { - return 0, err - } - return user.ID, nil - }) + playerSvc.SetUserResolver(userResolver) playerHandler := player.NewHandler(playerSvc) - // 회원가입 시 플레이어 프로필 자동 생성 authSvc.SetProfileCreator(func(userID uint) error { return playerSvc.CreateProfile(userID) }) - // 초기 admin 계정 생성 (콜백 등록 후 실행) if err := authSvc.EnsureAdmin(config.C.AdminUsername, config.C.AdminPassword); err != nil { log.Printf("admin 계정 생성 실패: %v", err) } else { log.Printf("admin 계정 확인 완료: %s", config.C.AdminUsername) } - // Boss Raid brRepo := bossraid.NewRepository(db) brSvc := bossraid.NewService(brRepo, rdb) brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { @@ -145,91 +127,20 @@ func main() { dlSvc := download.NewService(dlRepo, config.C.GameDir) dlHandler := download.NewHandler(dlSvc, config.C.BaseURL) - // 미들웨어 인스턴스 생성 (DI) + // ── 서버 + 라우트 설정 ─────────────────────────────────────────── + + app := server.New() + 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 - ErrorHandler: middleware.ErrorHandler, - }) - app.Use(middleware.RequestID) - app.Use(middleware.Metrics) - app.Get("/metrics", metrics.Handler) - app.Use(logger.New(logger.Config{ - Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n", - TimeFormat: "2006-01-02T15:04:05Z07:00", - })) - app.Use(middleware.SecurityHeaders) - app.Use(cors.New(cors.Config{ - AllowOrigins: "https://a301.tolelom.xyz", - AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key", - AllowMethods: "GET, POST, PUT, PATCH, DELETE", - AllowCredentials: true, - })) + routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler, + server.AuthLimiter(), server.APILimiter(), server.HealthCheck(), server.ReadyCheck(db, rdb), + server.ChainUserLimiter(), authMw, serverAuthMw, idempotencyReqMw) - // Rate limiting: 인증 관련 엔드포인트 (로그인/회원가입/리프레시) - authLimiter := limiter.New(limiter.Config{ - Max: 10, - Expiration: 1 * time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { - return c.IP() - }, - LimitReached: func(c *fiber.Ctx) error { - return apperror.ErrRateLimited - }, - }) + // ── 백그라운드 워커 ────────────────────────────────────────────── - // Rate limiting: 일반 API - apiLimiter := limiter.New(limiter.Config{ - Max: 60, - Expiration: 1 * time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { - return c.IP() - }, - LimitReached: func(c *fiber.Ctx) error { - return apperror.ErrRateLimited - }, - }) - - // Health check handlers - healthCheck := func(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"status": "ok"}) - } - readyCheck := func(c *fiber.Ctx) error { - 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 := 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"}) - } - - // Rate limiting: 체인 트랜잭션 (유저별 분당 20회) - chainUserLimiter := limiter.New(limiter.Config{ - Max: 20, - Expiration: 1 * time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { - if uid, ok := c.Locals("userID").(uint); ok { - return "chain_user:" + strconv.FormatUint(uint64(uid), 10) - } - return "chain_ip:" + c.IP() - }, - LimitReached: func(c *fiber.Ctx) error { - return apperror.ErrRateLimited - }, - }) - - routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler, authLimiter, apiLimiter, healthCheck, readyCheck, chainUserLimiter, authMw, serverAuthMw, idempotencyReqMw) - - // Background: stale dedicated server detection go func() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() @@ -238,7 +149,6 @@ func main() { } }() - // Background: retry failed rewards rewardWorker := bossraid.NewRewardWorker( brRepo, func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { @@ -251,7 +161,8 @@ func main() { ) rewardWorker.Start() - // Graceful shutdown + // ── Graceful shutdown ──────────────────────────────────────────── + go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -261,7 +172,6 @@ func main() { if err := app.ShutdownWithTimeout(10 * time.Second); err != nil { log.Printf("서버 종료 실패: %v", err) } - // Redis 연결 정리 if rdb != nil { if err := rdb.Close(); err != nil { log.Printf("Redis 종료 실패: %v", err) @@ -269,7 +179,6 @@ func main() { log.Println("Redis 연결 종료 완료") } } - // MySQL 연결 정리 if sqlDB, err := db.DB(); err == nil { if err := sqlDB.Close(); err != nil { log.Printf("MySQL 종료 실패: %v", err)