refactor: main.go 서버 초기화 로직을 internal/server/server.go로 분리
Fiber 앱 설정, 미들웨어, rate limiter를 server 패키지로 추출. main.go는 DB 연결, DI, 서버 시작, graceful shutdown만 담당. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
110
internal/server/server.go
Normal file
110
internal/server/server.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
125
main.go
125
main.go
@@ -4,7 +4,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,21 +13,15 @@ import (
|
|||||||
"a301_server/internal/chain"
|
"a301_server/internal/chain"
|
||||||
"a301_server/internal/download"
|
"a301_server/internal/download"
|
||||||
"a301_server/internal/player"
|
"a301_server/internal/player"
|
||||||
|
"a301_server/internal/server"
|
||||||
|
|
||||||
_ "a301_server/docs" // swagger docs
|
_ "a301_server/docs" // swagger docs
|
||||||
|
|
||||||
"github.com/tolelom/tolchain/core"
|
"github.com/tolelom/tolchain/core"
|
||||||
"a301_server/pkg/apperror"
|
|
||||||
"a301_server/pkg/config"
|
"a301_server/pkg/config"
|
||||||
"a301_server/pkg/database"
|
"a301_server/pkg/database"
|
||||||
"a301_server/pkg/metrics"
|
|
||||||
"a301_server/pkg/middleware"
|
"a301_server/pkg/middleware"
|
||||||
"a301_server/routes"
|
"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
|
// @title One of the Plans API
|
||||||
@@ -68,12 +61,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("Redis 연결 성공")
|
log.Println("Redis 연결 성공")
|
||||||
|
|
||||||
// 의존성 주입
|
// ── 의존성 주입 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
authRepo := auth.NewRepository(db)
|
authRepo := auth.NewRepository(db)
|
||||||
authSvc := auth.NewService(authRepo, rdb)
|
authSvc := auth.NewService(authRepo, rdb)
|
||||||
authHandler := auth.NewHandler(authSvc)
|
authHandler := auth.NewHandler(authSvc)
|
||||||
|
|
||||||
// Chain (blockchain integration)
|
|
||||||
chainClient := chain.NewClient(config.C.ChainNodeURL)
|
chainClient := chain.NewClient(config.C.ChainNodeURL)
|
||||||
chainRepo := chain.NewRepository(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)
|
||||||
@@ -82,46 +75,35 @@ func main() {
|
|||||||
}
|
}
|
||||||
chainHandler := chain.NewHandler(chainSvc)
|
chainHandler := chain.NewHandler(chainSvc)
|
||||||
|
|
||||||
// username → userID 변환 (게임 서버 내부 API용)
|
userResolver := func(username string) (uint, error) {
|
||||||
chainSvc.SetUserResolver(func(username string) (uint, error) {
|
|
||||||
user, err := authRepo.FindByUsername(username)
|
user, err := authRepo.FindByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return user.ID, nil
|
return user.ID, nil
|
||||||
})
|
}
|
||||||
|
chainSvc.SetUserResolver(userResolver)
|
||||||
|
|
||||||
// 회원가입 시 블록체인 월렛 자동 생성
|
|
||||||
authSvc.SetWalletCreator(func(userID uint) error {
|
authSvc.SetWalletCreator(func(userID uint) error {
|
||||||
_, err := chainSvc.CreateWallet(userID)
|
_, err := chainSvc.CreateWallet(userID)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
// Player Profile
|
|
||||||
playerRepo := player.NewRepository(db)
|
playerRepo := player.NewRepository(db)
|
||||||
playerSvc := player.NewService(playerRepo)
|
playerSvc := player.NewService(playerRepo)
|
||||||
playerSvc.SetUserResolver(func(username string) (uint, error) {
|
playerSvc.SetUserResolver(userResolver)
|
||||||
user, err := authRepo.FindByUsername(username)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return user.ID, nil
|
|
||||||
})
|
|
||||||
playerHandler := player.NewHandler(playerSvc)
|
playerHandler := player.NewHandler(playerSvc)
|
||||||
|
|
||||||
// 회원가입 시 플레이어 프로필 자동 생성
|
|
||||||
authSvc.SetProfileCreator(func(userID uint) error {
|
authSvc.SetProfileCreator(func(userID uint) error {
|
||||||
return playerSvc.CreateProfile(userID)
|
return playerSvc.CreateProfile(userID)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 초기 admin 계정 생성 (콜백 등록 후 실행)
|
|
||||||
if err := authSvc.EnsureAdmin(config.C.AdminUsername, config.C.AdminPassword); err != nil {
|
if err := authSvc.EnsureAdmin(config.C.AdminUsername, config.C.AdminPassword); err != nil {
|
||||||
log.Printf("admin 계정 생성 실패: %v", err)
|
log.Printf("admin 계정 생성 실패: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("admin 계정 확인 완료: %s", config.C.AdminUsername)
|
log.Printf("admin 계정 확인 완료: %s", config.C.AdminUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boss Raid
|
|
||||||
brRepo := bossraid.NewRepository(db)
|
brRepo := bossraid.NewRepository(db)
|
||||||
brSvc := bossraid.NewService(brRepo, 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 {
|
||||||
@@ -145,91 +127,20 @@ func main() {
|
|||||||
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)
|
// ── 서버 + 라우트 설정 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
app := server.New()
|
||||||
|
|
||||||
authMw := middleware.Auth(rdb, config.C.JWTSecret)
|
authMw := middleware.Auth(rdb, config.C.JWTSecret)
|
||||||
serverAuthMw := middleware.ServerAuth(config.C.InternalAPIKey)
|
serverAuthMw := middleware.ServerAuth(config.C.InternalAPIKey)
|
||||||
idempotencyReqMw := middleware.IdempotencyRequired(rdb)
|
idempotencyReqMw := middleware.IdempotencyRequired(rdb)
|
||||||
|
|
||||||
app := fiber.New(fiber.Config{
|
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler,
|
||||||
StreamRequestBody: true,
|
server.AuthLimiter(), server.APILimiter(), server.HealthCheck(), server.ReadyCheck(db, rdb),
|
||||||
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
|
server.ChainUserLimiter(), authMw, serverAuthMw, idempotencyReqMw)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 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() {
|
go func() {
|
||||||
ticker := time.NewTicker(15 * time.Second)
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -238,7 +149,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Background: retry failed rewards
|
|
||||||
rewardWorker := bossraid.NewRewardWorker(
|
rewardWorker := bossraid.NewRewardWorker(
|
||||||
brRepo,
|
brRepo,
|
||||||
func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
|
func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
|
||||||
@@ -251,7 +161,8 @@ func main() {
|
|||||||
)
|
)
|
||||||
rewardWorker.Start()
|
rewardWorker.Start()
|
||||||
|
|
||||||
// Graceful shutdown
|
// ── Graceful shutdown ────────────────────────────────────────────
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -261,7 +172,6 @@ func main() {
|
|||||||
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
|
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
|
||||||
log.Printf("서버 종료 실패: %v", err)
|
log.Printf("서버 종료 실패: %v", err)
|
||||||
}
|
}
|
||||||
// Redis 연결 정리
|
|
||||||
if rdb != nil {
|
if rdb != nil {
|
||||||
if err := rdb.Close(); err != nil {
|
if err := rdb.Close(); err != nil {
|
||||||
log.Printf("Redis 종료 실패: %v", err)
|
log.Printf("Redis 종료 실패: %v", err)
|
||||||
@@ -269,7 +179,6 @@ func main() {
|
|||||||
log.Println("Redis 연결 종료 완료")
|
log.Println("Redis 연결 종료 완료")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MySQL 연결 정리
|
|
||||||
if sqlDB, err := 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user