feat: 게임 파일 직접 업로드 방식으로 전환
All checks were successful
Server CI/CD / deploy (push) Successful in 35s

- zip 스트리밍 업로드 (StreamRequestBody) → /data/game/game.zip 저장
- A301.exe SHA256 해시 자동 추출 (zip 분석)
- 버전·파일명·크기 파일명 및 용량에서 자동 추출
- GET /api/download/file 엔드포인트 추가
- BASE_URL, GAME_DIR 환경변수 추가
- Dockerfile에 /data/game 디렉토리 생성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:25:07 +09:00
parent 003eb4c1c2
commit 18c39bd4c5
7 changed files with 132 additions and 26 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ coverage.html
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Game files
*.zip

View File

@@ -9,6 +9,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: Run # Stage 2: Run
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add tzdata ca-certificates RUN apk --no-cache add tzdata ca-certificates
RUN mkdir -p /data/game
WORKDIR /app WORKDIR /app
COPY --from=builder /app/server . COPY --from=builder /app/server .
EXPOSE 8080 EXPOSE 8080

View File

@@ -1,13 +1,19 @@
package download package download
import "github.com/gofiber/fiber/v2" import (
"os"
"strings"
"github.com/gofiber/fiber/v2"
)
type Handler struct { type Handler struct {
svc *Service svc *Service
baseURL string
} }
func NewHandler(svc *Service) *Handler { func NewHandler(svc *Service, baseURL string) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc, baseURL: baseURL}
} }
func (h *Handler) GetInfo(c *fiber.Ctx) error { func (h *Handler) GetInfo(c *fiber.Ctx) error {
@@ -18,20 +24,26 @@ func (h *Handler) GetInfo(c *fiber.Ctx) error {
return c.JSON(info) return c.JSON(info)
} }
func (h *Handler) Upsert(c *fiber.Ctx) error { // Upload accepts a raw binary body (application/octet-stream).
var body struct { // The filename is passed as a query parameter: ?filename=A301_v1.0.zip
URL string `json:"url"` func (h *Handler) Upload(c *fiber.Ctx) error {
Version string `json:"version"` filename := strings.TrimSpace(c.Query("filename", "game.zip"))
FileName string `json:"fileName"` if !strings.HasSuffix(strings.ToLower(filename), ".zip") {
FileSize string `json:"fileSize"` return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zip 파일만 업로드 가능합니다"})
FileHash string `json:"fileHash"`
} }
if err := c.BodyParser(&body); err != nil || body.URL == "" || body.Version == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url과 version은 필수입니다"}) body := c.Request().BodyStream()
} info, err := h.svc.Upload(filename, body, h.baseURL)
info, err := h.svc.Upsert(body.URL, body.Version, body.FileName, body.FileSize, body.FileHash)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업데이트에 실패했습니다"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업로드 실패: " + err.Error()})
} }
return c.JSON(info) return c.JSON(info)
} }
func (h *Handler) ServeFile(c *fiber.Ctx) error {
path := h.svc.GameFilePath()
if _, err := os.Stat(path); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"})
}
return c.SendFile(path)
}

View File

@@ -1,26 +1,109 @@
package download package download
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
var versionRe = regexp.MustCompile(`v\d+[\.\d]*`)
type Service struct { type Service struct {
repo *Repository repo *Repository
gameDir string
} }
func NewService(repo *Repository) *Service { func NewService(repo *Repository, gameDir string) *Service {
return &Service{repo: repo} return &Service{repo: repo, gameDir: gameDir}
} }
func (s *Service) GetInfo() (*Info, error) { func (s *Service) GetInfo() (*Info, error) {
return s.repo.GetLatest() return s.repo.GetLatest()
} }
func (s *Service) Upsert(url, version, fileName, fileSize, fileHash string) (*Info, error) { func (s *Service) GameFilePath() string {
return filepath.Join(s.gameDir, "game.zip")
}
// Upload streams the body directly to disk, then extracts metadata from the zip.
func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info, error) {
if err := os.MkdirAll(s.gameDir, 0755); err != nil {
return nil, fmt.Errorf("디렉토리 생성 실패: %w", err)
}
finalPath := s.GameFilePath()
tmpPath := finalPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return nil, fmt.Errorf("파일 생성 실패: %w", err)
}
n, err := io.Copy(f, body)
f.Close()
if err != nil {
os.Remove(tmpPath)
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
version := ""
if m := versionRe.FindString(filename); m != "" {
version = m
}
fileSize := ""
if n > 0 {
mb := float64(n) / 1024 / 1024
if mb >= 1000 {
fileSize = fmt.Sprintf("%.1f GB", mb/1024)
} else {
fileSize = fmt.Sprintf("%.1f MB", mb)
}
}
fileHash := hashGameExeFromZip(finalPath)
info, err := s.repo.GetLatest() info, err := s.repo.GetLatest()
if err != nil { if err != nil {
info = &Info{} info = &Info{}
} }
info.URL = url info.URL = baseURL + "/api/download/file"
info.Version = version info.Version = version
info.FileName = fileName info.FileName = filename
info.FileSize = fileSize info.FileSize = fileSize
info.FileHash = fileHash info.FileHash = fileHash
return info, s.repo.Save(info) return info, s.repo.Save(info)
} }
func hashGameExeFromZip(zipPath string) string {
r, err := zip.OpenReader(zipPath)
if err != nil {
return ""
}
defer r.Close()
for _, f := range r.File {
if strings.EqualFold(filepath.Base(f.Name), "A301.exe") {
rc, err := f.Open()
if err != nil {
return ""
}
h := sha256.New()
io.Copy(h, rc)
rc.Close()
return hex.EncodeToString(h.Sum(nil))
}
}
return ""
}

View File

@@ -47,10 +47,12 @@ func main() {
annHandler := announcement.NewHandler(annSvc) annHandler := announcement.NewHandler(annSvc)
dlRepo := download.NewRepository(database.DB) dlRepo := download.NewRepository(database.DB)
dlSvc := download.NewService(dlRepo) dlSvc := download.NewService(dlRepo, config.C.GameDir)
dlHandler := download.NewHandler(dlSvc) dlHandler := download.NewHandler(dlSvc, config.C.BaseURL)
app := fiber.New() app := fiber.New(fiber.Config{
StreamRequestBody: true,
})
app.Use(logger.New()) app.Use(logger.New())
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: "https://a301.tolelom.xyz", AllowOrigins: "https://a301.tolelom.xyz",

View File

@@ -20,6 +20,8 @@ type Config struct {
JWTExpiryHours int JWTExpiryHours int
AdminUsername string AdminUsername string
AdminPassword string AdminPassword string
BaseURL string
GameDir string
} }
var C Config var C Config
@@ -41,6 +43,8 @@ func Load() {
JWTExpiryHours: hours, JWTExpiryHours: hours,
AdminUsername: getEnv("ADMIN_USERNAME", "admin"), AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"), AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
GameDir: getEnv("GAME_DIR", "/data/game"),
} }
} }

View File

@@ -38,5 +38,6 @@ func Register(
// Download // Download
dl := api.Group("/download") dl := api.Group("/download")
dl.Get("/info", dlH.GetInfo) dl.Get("/info", dlH.GetInfo)
dl.Put("/info", middleware.Auth, middleware.AdminOnly, dlH.Upsert) dl.Get("/file", dlH.ServeFile)
dl.Post("/upload", middleware.Auth, middleware.AdminOnly, dlH.Upload)
} }