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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
41
main.go
41
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ 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 {
|
||||
// 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
|
||||
@@ -25,7 +27,7 @@ func Auth(c *fiber.Ctx) error {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return []byte(config.C.JWTSecret), nil
|
||||
return secretBytes, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return apperror.Unauthorized("유효하지 않은 토큰입니다")
|
||||
@@ -53,7 +55,7 @@ func Auth(c *fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), redisTimeout)
|
||||
defer cancel()
|
||||
key := fmt.Sprintf("session:%d", userID)
|
||||
stored, err := database.RDB.Get(ctx, key).Result()
|
||||
stored, err := rdb.Get(ctx, key).Result()
|
||||
if err != nil || stored != tokenStr {
|
||||
return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다")
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func Auth(c *fiber.Ctx) error {
|
||||
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 {
|
||||
func ServerAuth(apiKey string) fiber.Handler {
|
||||
expectedBytes := []byte(apiKey)
|
||||
return func(c *fiber.Ctx) error {
|
||||
key := c.Get("X-API-Key")
|
||||
expected := config.C.InternalAPIKey
|
||||
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +20,22 @@ 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 {
|
||||
// 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 {
|
||||
func Idempotency(rdb *redis.Client) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
key := c.Get("Idempotency-Key")
|
||||
if key == "" {
|
||||
return c.Next()
|
||||
@@ -51,7 +55,7 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
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()
|
||||
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)
|
||||
@@ -63,7 +67,7 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
getCtx, getCancel := context.WithTimeout(context.Background(), redisTimeout)
|
||||
defer getCancel()
|
||||
|
||||
cached, err := database.RDB.Get(getCtx, redisKey).Bytes()
|
||||
cached, err := rdb.Get(getCtx, redisKey).Bytes()
|
||||
if err != nil {
|
||||
return apperror.Conflict("요청이 처리 중입니다")
|
||||
}
|
||||
@@ -84,7 +88,7 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
// 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 {
|
||||
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
|
||||
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
|
||||
}
|
||||
return err
|
||||
@@ -97,7 +101,7 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
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 {
|
||||
if err := rdb.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil {
|
||||
log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err)
|
||||
}
|
||||
}
|
||||
@@ -105,10 +109,11 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
// 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 {
|
||||
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
|
||||
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user