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