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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
41
main.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user