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
|
||||
|
||||
Reference in New Issue
Block a user