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>
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"learning-service/internal/handler"
|
||||
"learning-service/internal/migrate"
|
||||
"learning-service/internal/repository"
|
||||
"learning-service/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -43,14 +44,38 @@ func main() {
|
||||
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)
|
||||
@@ -86,16 +111,26 @@ func main() {
|
||||
r.Get("/attempts/{id}", attemptH.Get)
|
||||
r.Post("/attempts/{id}/submit", attemptH.Submit)
|
||||
|
||||
// Courses CRUD. Lessons/video — следующая итерация.
|
||||
// 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)
|
||||
r.HandleFunc("/courses/{id}/lessons", notImplemented)
|
||||
r.HandleFunc("/lessons/{id}", notImplemented)
|
||||
r.HandleFunc("/lessons/{id}/video", notImplemented)
|
||||
r.HandleFunc("/lessons/{id}/video/stream", notImplemented)
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user