feat: implement async modpack import with websockets

This commit is contained in:
2026-01-05 18:06:54 +03:00
parent 0751ddb88a
commit 9bf2a15045
8 changed files with 435 additions and 64 deletions

View File

@@ -2,8 +2,10 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
@@ -11,50 +13,51 @@ import (
"gitea.mrixs.me/minecraft-platform/backend/internal/core/importer"
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
"gitea.mrixs.me/minecraft-platform/backend/internal/ws"
)
type ModpackHandler struct {
ModpackRepo *database.ModpackRepository
JobRepo *database.JobRepository
JanitorService *core.FileJanitorService
Hub *ws.Hub
}
// ImportModpack обрабатывает загрузку и импорт модпака.
// ImportJobParams содержит параметры для фоновой задачи импорта
type ImportJobParams struct {
ImporterType string
ImportMethod string
SourceURL string
TempZipPath string
Name string
DisplayName string
MCVersion string
}
// ImportModpack обрабатывает запрос на импорт и запускает асинхронную задачу
func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(512 << 20); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
importerType := r.FormValue("importerType")
importMethod := r.FormValue("importMethod")
sourceURL := r.FormValue("sourceUrl")
params := ImportJobParams{
ImporterType: r.FormValue("importerType"),
ImportMethod: r.FormValue("importMethod"),
SourceURL: r.FormValue("sourceUrl"),
Name: r.FormValue("name"),
DisplayName: r.FormValue("displayName"),
MCVersion: r.FormValue("mcVersion"),
}
var tempZipPath string
var err error
// --- Выбираем импортер ---
var imp importer.ModpackImporter
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
switch importerType {
case "simple":
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
case "curseforge":
apiKey := os.Getenv("CURSEFORGE_API_KEY")
if apiKey == "" {
http.Error(w, "CurseForge API key is not configured on the server", http.StatusInternalServerError)
return
}
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
case "modrinth":
imp = importer.NewModrinthImporter(storagePath)
default:
http.Error(w, "Invalid importer type", http.StatusBadRequest)
// Валидация
if params.Name == "" || params.DisplayName == "" || params.MCVersion == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// --- Получаем zip-файл ---
if importMethod == "file" {
// Обработка загрузки файла
if params.ImportMethod == "file" {
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Invalid file upload", http.StatusBadRequest)
@@ -67,55 +70,136 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Could not create temp file", http.StatusInternalServerError)
return
}
defer tempFile.Close()
defer os.Remove(tempFile.Name())
// Не удаляем файл здесь, так как он нужен worker-у. Удалим в worker-е.
// defer os.Remove(tempFile.Name())
if _, err := io.Copy(tempFile, file); err != nil {
tempFile.Close()
os.Remove(tempFile.Name())
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
return
}
tempZipPath = tempFile.Name()
} else if importMethod == "url" {
cfImporter, ok := imp.(*importer.CurseForgeImporter)
if !ok {
http.Error(w, "Importer type does not support URL import", http.StatusBadRequest)
return
}
tempZipPath, err = cfImporter.DownloadModpackFromURL(sourceURL)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to download from URL: %v", err), http.StatusInternalServerError)
return
}
defer os.Remove(tempZipPath)
} else {
http.Error(w, "Invalid import method", http.StatusBadRequest)
return
tempFile.Close()
params.TempZipPath = tempFile.Name()
}
// --- Запускаем импорт ---
files, err := imp.Import(tempZipPath)
// Создаем задачу в БД
jobID, err := h.JobRepo.CreateJob(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("Import failed: %v", err), http.StatusInternalServerError)
if params.ImportMethod == "file" {
os.Remove(params.TempZipPath)
}
http.Error(w, fmt.Sprintf("Failed to create job: %v", err), http.StatusInternalServerError)
return
}
// --- Сохраняем результат в БД ---
// Запускаем worker
go h.processImportJob(jobID, params)
// Отправляем ответ
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]interface{}{
"job_id": jobID,
"message": "Import job started",
})
}
// processImportJob выполняет импорт в фоне
func (h *ModpackHandler) processImportJob(jobID int, params ImportJobParams) {
ctx := context.Background()
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 10, "")
// Очистка временного файла по завершении
if params.TempZipPath != "" {
defer os.Remove(params.TempZipPath)
}
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
var imp importer.ModpackImporter
// Настройка импортера
switch params.ImporterType {
case "simple":
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
case "curseforge":
apiKey := os.Getenv("CURSEFORGE_API_KEY")
if apiKey == "" {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "CurseForge API key missing")
return
}
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
case "modrinth":
imp = importer.NewModrinthImporter(storagePath)
default:
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "Invalid importer type")
return
}
// Загрузка по URL если нужно
if params.ImportMethod == "url" {
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 20, "Downloading from URL...")
// Логика скачивания зависит от типа импортера, пока поддерживаем CurseForge
// TODO: Сделать интерфейс DownloadableImporter
if cfImporter, ok := imp.(*importer.CurseForgeImporter); ok {
zipPath, err := cfImporter.DownloadModpackFromURL(params.SourceURL)
if err != nil {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Download failed: %v", err))
return
}
params.TempZipPath = zipPath
defer os.Remove(zipPath) // Удаляем скачанный файл после обработки
} else {
// Для других типов пока не поддерживаем URL download внутри импортера
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "URL import not supported for this type")
return
}
}
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 40, "Processing modpack files...")
// Импорт файлов
files, err := imp.Import(params.TempZipPath)
if err != nil {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Import failed: %v", err))
return
}
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 80, "Saving to database...")
// Сохранение в БД
modpack := &models.Modpack{
Name: r.FormValue("name"),
DisplayName: r.FormValue("displayName"),
MinecraftVersion: r.FormValue("mcVersion"),
Name: params.Name,
DisplayName: params.DisplayName,
MinecraftVersion: params.MCVersion,
}
err = h.ModpackRepo.CreateModpackTx(r.Context(), modpack, files)
err = h.ModpackRepo.CreateModpackTx(ctx, modpack, files)
if err != nil {
http.Error(w, fmt.Sprintf("Database save failed: %v", err), http.StatusInternalServerError)
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Database save failed: %v", err))
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "Modpack '%s' imported successfully with %d files.", modpack.DisplayName, len(files))
h.updateJobStatus(ctx, jobID, models.JobStatusCompleted, 100, "Success")
// Запуск Janitor-а
go h.JanitorService.CleanOrphanedFiles(context.Background())
}
// updateJobStatus обновляет статус в БД и отправляет уведомление через WebSocket
func (h *ModpackHandler) updateJobStatus(ctx context.Context, jobID int, status models.ImportJobStatus, progress int, errMsg string) {
// Обновляем БД
if err := h.JobRepo.UpdateJobStatus(ctx, jobID, status, progress, errMsg); err != nil {
slog.Error("Failed to update job status in DB", "jobID", jobID, "error", err)
}
// Отправляем в WebSocket
update := map[string]interface{}{
"job_id": jobID,
"status": status,
"progress": progress,
"error_message": errMsg,
}
msg, _ := json.Marshal(update)
h.Hub.BroadcastMessage(msg)
}