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:
2026-03-18 16:58:36 +09:00
parent f4d862b47f
commit 0dfa744c16
9 changed files with 226 additions and 199 deletions

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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("만료되었거나 로그아웃된 세션입니다")
}
@@ -63,6 +65,7 @@ func Auth(c *fiber.Ctx) error {
c.Locals("role", role)
return c.Next()
}
}
func AdminOnly(c *fiber.Ctx) error {
if c.Locals("role") != "admin" {
@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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)