Files
learning/cmd/server/main.go
Ilya 80c019b791
All checks were successful
CI / test (push) Successful in 27s
Build and Deploy / build-and-deploy (push) Successful in 33s
feat(lessons): уроки + видео (MinIO stream-proxy)
storage/minio.go:
- New() толерантен к пустым creds → Configured()=false, видео-фичи
  отдают 503; остальное работает.
- GenerateKey/PutObject/Stat/GetObject(Range)/Delete + ParseRange/
  WriteRangeResponse helpers. По паттерну telephony record stream.
- EnsureBucket — best-effort при старте сервиса.

LessonRepository:
- ListByCourse / Get / Create / Update / Delete (возвращает старый
  video_key для MinIO-cleanup) / ReorderInCourse через UNNEST.
- SetVideo — отдельный helper для post-upload UPDATE с возвратом
  старого key (чтобы handler удалил предыдущее видео при замене).

LessonHandler:
- CRUD с проверкой owner курса (authorizeCourseOwner-helper).
- Reorder батч.
- UploadVideo: multipart "video" + duration_sec из формы.
  PutObject в MinIO → SetVideo в БД. При ошибке UPDATE откатываем
  объект из MinIO (PutObject + revert). Старый video_key удаляется
  best-effort.
- StreamVideo: Range-aware прокси по паттерну telephony.
  Content-Disposition: inline + nodownload-заголовки. Гейтит
  is_published || owner. MinIO URL клиенту не светится.
- DeleteVideo: чистит video_key + объект из MinIO.

main.go: 8 новых routes (CRUD + reorder + upload + stream + delete).
Storage инициализируется один раз; ENV-fallback логирует «disabled».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:58:05 +03:00

183 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// learning-server — микросервис обучения: тесты, курсы (с видео),
// гранулярные доступы и public-ссылки для кандидатов с email-валидацией.
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
commondb "gitea.estateliga.work/admin/portal-common/db"
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
"learning-service/internal/config"
"learning-service/internal/handler"
"learning-service/internal/migrate"
"learning-service/internal/repository"
"learning-service/internal/storage"
)
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pool, err := commondb.ConnectURL(cfg.DatabaseURL)
if err != nil {
slog.Error("connect database", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := migrate.Run(context.Background(), pool, cfg.MigrationsDir); err != nil {
slog.Error("run migrations", "error", err)
os.Exit(1)
}
// MinIO для видео-уроков. Если ENV не сконфигурирован — клиент nil,
// upload/stream-handler'ы возвращают 503; остальной сервис работает.
store, err := storage.New(storage.Config{
Endpoint: cfg.MinIOEndpoint,
AccessKey: cfg.MinIOAccessKey,
SecretKey: cfg.MinIOSecretKey,
Bucket: cfg.MinIOBucket,
UseSSL: cfg.MinIOUseSSL,
})
if err != nil {
slog.Error("init storage", "error", err)
os.Exit(1)
}
if store.Configured() {
if err := store.EnsureBucket(context.Background()); err != nil {
slog.Warn("ensure bucket failed", "error", err)
}
slog.Info("video storage enabled", "endpoint", cfg.MinIOEndpoint, "bucket", cfg.MinIOBucket)
} else {
slog.Warn("video storage disabled (MINIO_* env vars not set)")
}
testRepo := repository.NewTestRepository(pool)
attemptRepo := repository.NewAttemptRepository(pool)
courseRepo := repository.NewCourseRepository(pool)
lessonRepo := repository.NewLessonRepository(pool)
healthH := handler.NewHealthHandler(pool)
testH := handler.NewTestHandler(testRepo)
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
courseH := handler.NewCourseHandler(courseRepo)
lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store)
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Recoverer)
r.Get("/healthz", healthH.Healthz)
r.Get("/readyz", healthH.Readyz)
r.Route("/api", func(r chi.Router) {
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
// Tests CRUD
r.Get("/tests", testH.List)
r.Post("/tests", testH.Create)
r.Get("/tests/{id}", testH.Get)
r.Patch("/tests/{id}", testH.Update)
r.Delete("/tests/{id}", testH.Delete)
// Questions внутри теста. /reorder регистрируется ДО /{questionId},
// иначе chi заматчит {questionId} = "reorder".
r.Get("/tests/{id}/questions", testH.ListQuestions)
r.Post("/tests/{id}/questions", testH.CreateQuestion)
r.Post("/tests/{id}/questions/reorder", testH.ReorderQuestions)
r.Put("/tests/{id}/questions/{questionId}", testH.UpdateQuestion)
r.Delete("/tests/{id}/questions/{questionId}", testH.DeleteQuestion)
// Attempts: старт + получение + сабмит. Списки — отдельно
// (мои попытки vs все попытки по тесту для HR).
r.Post("/tests/{id}/attempts", attemptH.Start)
r.Get("/tests/{id}/attempts", attemptH.ListByTest)
r.Get("/attempts", attemptH.ListMine)
r.Get("/attempts/{id}", attemptH.Get)
r.Post("/attempts/{id}/submit", attemptH.Submit)
// Courses CRUD.
r.Get("/courses", courseH.List)
r.Post("/courses", courseH.Create)
r.Get("/courses/{id}", courseH.Get)
r.Patch("/courses/{id}", courseH.Update)
r.Delete("/courses/{id}", courseH.Delete)
// Lessons CRUD. /reorder регистрируется ДО /{id}, иначе chi
// заматчит {id} = "reorder".
r.Get("/courses/{courseId}/lessons", lessonH.ListByCourse)
r.Post("/courses/{courseId}/lessons", lessonH.Create)
r.Post("/courses/{courseId}/lessons/reorder", lessonH.Reorder)
r.Get("/lessons/{id}", lessonH.Get)
r.Patch("/lessons/{id}", lessonH.Update)
r.Delete("/lessons/{id}", lessonH.Delete)
// Video upload/stream/delete. Upload — multipart (поле "video"),
// stream — Range-aware прокси из MinIO.
r.Post("/lessons/{id}/video", lessonH.UploadVideo)
r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo)
r.Delete("/lessons/{id}/video", lessonH.DeleteVideo)
r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented)
r.HandleFunc("/public-tokens", notImplemented)
r.HandleFunc("/public-tokens/{id}", notImplemented)
})
// Public endpoints — без InternalAuth (кандидаты ходят анонимно
// по token'у). Открываем минимум: проверка токена + старт attempt +
// сабмит. Содержательно гейтим через token-валидацию в самом handler'е.
r.Route("/public", func(r chi.Router) {
r.HandleFunc("/learning/resolve/{token}", notImplemented)
r.HandleFunc("/learning/attempts/{id}", notImplemented)
r.HandleFunc("/learning/attempts/{id}/submit", notImplemented)
})
srv := &http.Server{
Addr: ":" + cfg.ServerPort,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
slog.Info("learning server starting", "port", cfg.ServerPort, "pod", cfg.PodName)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown error", "error", err)
}
slog.Info("server stopped")
}
// notImplemented — заглушка для эндпоинтов, которые есть в схеме, но
// ещё не имплементированы. Возвращает 501 + понятное сообщение, чтобы
// фронт мог отрендерить «в разработке» вместо generic 500.
func notImplemented(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"not implemented yet — следующая итерация"}`))
}