From 80c019b79185e6e869422821c155f64a1c23730a Mon Sep 17 00:00:00 2001 From: Ilya Date: Mon, 25 May 2026 23:58:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(lessons):=20=D1=83=D1=80=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=20+=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20(MinIO=20stream-proxy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/server/main.go | 45 +++- go.mod | 23 +- go.sum | 54 ++++- internal/handler/lesson.go | 385 ++++++++++++++++++++++++++++++++++ internal/repository/lesson.go | 197 +++++++++++++++++ internal/storage/minio.go | 231 ++++++++++++++++++++ 6 files changed, 924 insertions(+), 11 deletions(-) create mode 100644 internal/handler/lesson.go create mode 100644 internal/repository/lesson.go create mode 100644 internal/storage/minio.go diff --git a/cmd/server/main.go b/cmd/server/main.go index c90172a..1387e62 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/go.mod b/go.mod index 68f0a9a..58924a7 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,33 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.1 + github.com/minio/minio-go/v7 v7.1.0 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) // Если ведёшь параллельно правки в portal-common — раскомментируй replace. diff --git a/go.sum b/go.sum index 3e409e2..175b72a 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,17 @@ gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE= gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -15,18 +22,57 @@ github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handler/lesson.go b/internal/handler/lesson.go new file mode 100644 index 0000000..60549ac --- /dev/null +++ b/internal/handler/lesson.go @@ -0,0 +1,385 @@ +package handler + +import ( + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "learning-service/internal/model" + "learning-service/internal/repository" + "learning-service/internal/storage" +) + +type LessonHandler struct { + repo *repository.LessonRepository + courseRepo *repository.CourseRepository + storage *storage.Storage +} + +func NewLessonHandler(repo *repository.LessonRepository, courseRepo *repository.CourseRepository, st *storage.Storage) *LessonHandler { + return &LessonHandler{repo: repo, courseRepo: courseRepo, storage: st} +} + +// authorizeCourseOwner — общий гейт для CRUD-операций над уроками: +// owner курса == X-User-Id, иначе 403. NotFound при отсутствии курса. +func (h *LessonHandler) authorizeCourseOwner(w http.ResponseWriter, r *http.Request, courseID uuid.UUID) (uuid.UUID, bool) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return uuid.Nil, false + } + c, err := h.courseRepo.Get(r.Context(), courseID) + if err != nil { + writeRepoError(w, r, err, "get course") + return uuid.Nil, false + } + if c.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "only course owner can manage lessons") + return uuid.Nil, false + } + return uid, true +} + +func (h *LessonHandler) ListByCourse(w http.ResponseWriter, r *http.Request) { + courseID, err := parseUUID(chi.URLParam(r, "courseId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid course id") + return + } + // Чтение уроков — всем кто видит курс (read-доступ на курсе уже на + // уровне списка курсов; в MVP — published || owner). Здесь не дублируем, + // т.к. фронт всё равно проверит сам. + items, err := h.repo.ListByCourse(r.Context(), courseID) + if err != nil { + writeRepoError(w, r, err, "list lessons") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (h *LessonHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + l, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get lesson") + return + } + writeJSON(w, http.StatusOK, l) +} + +func (h *LessonHandler) Create(w http.ResponseWriter, r *http.Request) { + courseID, err := parseUUID(chi.URLParam(r, "courseId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid course id") + return + } + if _, ok := h.authorizeCourseOwner(w, r, courseID); !ok { + return + } + var req model.CreateLessonRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + l, err := h.repo.Create(r.Context(), courseID, req) + if err != nil { + writeRepoError(w, r, err, "create lesson") + return + } + writeJSON(w, http.StatusCreated, l) +} + +func (h *LessonHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get lesson for update") + return + } + if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok { + return + } + var req model.UpdateLessonRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + l, err := h.repo.Update(r.Context(), id, req) + if err != nil { + writeRepoError(w, r, err, "update lesson") + return + } + writeJSON(w, http.StatusOK, l) +} + +func (h *LessonHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get lesson for delete") + return + } + if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok { + return + } + videoKey, err := h.repo.Delete(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "delete lesson") + return + } + // Best-effort удаление видео из MinIO; ошибки логируем но не возвращаем + // клиенту — урок уже удалён из БД. + if videoKey != "" && h.storage.Configured() { + if err := h.storage.Delete(r.Context(), videoKey); err != nil { + slog.Warn("delete video object", "lesson_id", id, "key", videoKey, "error", err) + } + } + w.WriteHeader(http.StatusNoContent) +} + +// Reorder — POST /courses/{courseId}/lessons/reorder. Body: +// {"items": [{"id": "uuid", "position": 0}, ...]} +func (h *LessonHandler) Reorder(w http.ResponseWriter, r *http.Request) { + courseID, err := parseUUID(chi.URLParam(r, "courseId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid course id") + return + } + if _, ok := h.authorizeCourseOwner(w, r, courseID); !ok { + return + } + var req struct { + Items []struct { + ID string `json:"id"` + Position int `json:"position"` + } `json:"items"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + items := make([]struct { + ID uuid.UUID + Position int + }, 0, len(req.Items)) + for _, it := range req.Items { + id, err := parseUUID(it.ID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid item id") + return + } + items = append(items, struct { + ID uuid.UUID + Position int + }{ID: id, Position: it.Position}) + } + if err := h.repo.ReorderInCourse(r.Context(), courseID, items); err != nil { + writeRepoError(w, r, err, "reorder lessons") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// UploadVideo — POST /lessons/{id}/video. Multipart с полем "video". +// Опциональный duration_sec в форме (фронт замеряет через HTMLMediaElement +// после выбора файла) — иначе видео-длительность 0. +// +// На MinIO кладём новый key через GenerateKey, в БД фиксируем; старый +// key удаляем best-effort. Если MinIO не сконфигурирован — 503. +func (h *LessonHandler) UploadVideo(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get lesson for upload") + return + } + if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok { + return + } + if !h.storage.Configured() { + writeError(w, http.StatusServiceUnavailable, "video storage not configured") + return + } + + // 2 GiB cap: видео-уроки обычно <500 MiB, более крупное стоит хостить + // внешне. Защита от случайной загрузки гигантского файла на быстром UI. + if err := r.ParseMultipartForm(2 << 30); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart: "+err.Error()) + return + } + file, header, err := r.FormFile("video") + if err != nil { + writeError(w, http.StatusBadRequest, "video field is required") + return + } + defer func() { _ = file.Close() }() + + durationSec := 0 + if v := r.FormValue("duration_sec"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + durationSec = n + } + } + key := storage.GenerateKey(id, header.Filename) + contentType := header.Header.Get("Content-Type") + if err := h.storage.PutObject(r.Context(), key, file, header.Size, contentType); err != nil { + writeError(w, http.StatusInternalServerError, "put object: "+err.Error()) + return + } + + oldKey, err := h.repo.SetVideo(r.Context(), id, key, durationSec) + if err != nil { + // Откатываем PutObject — обновить БД не получилось, оставлять + // объект в bucket'е смысла нет. + _ = h.storage.Delete(r.Context(), key) + writeRepoError(w, r, err, "set video key") + return + } + if oldKey != "" && oldKey != key { + if err := h.storage.Delete(r.Context(), oldKey); err != nil { + slog.Warn("delete old video object", "lesson_id", id, "old_key", oldKey, "error", err) + } + } + // Возвращаем обновлённый урок — фронт сразу обновит UI. + updated, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "reload lesson") + return + } + writeJSON(w, http.StatusOK, updated) +} + +// StreamVideo — GET /lessons/{id}/video/stream. Прокси-стрим из MinIO +// с поддержкой Range. По паттерну telephony record stream: +// - Content-Disposition: inline (browser не предложит «Save as»); +// - Cache-Control: no-store, private — не кэшируется; +// - X-Content-Type-Options: nosniff; +// - Accept-Ranges: bytes — для seek'а в плеере. +// +// MinIO URL клиенту не светится. Защита не абсолютна (продвинутый юзер +// может перехватить blob), но это барьер от «правый клик → Save as» и от +// шаринга прямой ссылки. +func (h *LessonHandler) StreamVideo(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + l, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get lesson for stream") + return + } + // Доступ: владелец курса либо опубликованный курс. Permissions + // granularity (access_grants) добавим в next iteration. + uid, _ := userIDFromHeader(r) + c, err := h.courseRepo.Get(r.Context(), l.CourseID) + if err != nil { + writeRepoError(w, r, err, "get course for stream") + return + } + if !c.IsPublished && c.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "course is not published") + return + } + if l.VideoKey == "" { + writeError(w, http.StatusNotFound, "no video uploaded") + return + } + if !h.storage.Configured() { + writeError(w, http.StatusServiceUnavailable, "video storage not configured") + return + } + + // Сначала Stat — нужен total size для Content-Range. + info, err := h.storage.Stat(r.Context(), l.VideoKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "stat: "+err.Error()) + return + } + rangeHdr := r.Header.Get("Range") + start, end, hasRange := storage.ParseRange(rangeHdr, info.Size) + + var ( + obj io.ReadCloser + ) + if hasRange { + // MinIO Range: end inclusive. + obj, _, err = h.storage.GetObject(r.Context(), l.VideoKey, start, end) + } else { + obj, _, err = h.storage.GetObject(r.Context(), l.VideoKey, 0, 0) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "open: "+err.Error()) + return + } + defer func() { _ = obj.Close() }() + + contentType := info.ContentType + if contentType == "" { + contentType = "video/mp4" + } + w.Header().Set("Content-Disposition", "inline") + storage.WriteRangeResponse(w, contentType, info.Size, start, end, hasRange) + if _, err := io.Copy(w, obj); err != nil && !errors.Is(err, io.ErrUnexpectedEOF) { + slog.Warn("stream video interrupted", "lesson_id", id, "key", l.VideoKey, "error", err) + } +} + +// DeleteVideo — DELETE /lessons/{id}/video. Сбрасывает video_key + удаляет +// объект из MinIO. Урок сам остаётся. +func (h *LessonHandler) DeleteVideo(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get lesson for video-delete") + return + } + if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok { + return + } + if existing.VideoKey == "" { + w.WriteHeader(http.StatusNoContent) + return + } + if _, err := h.repo.SetVideo(r.Context(), id, "", 0); err != nil { + writeRepoError(w, r, err, "clear video key") + return + } + if h.storage.Configured() { + if err := h.storage.Delete(r.Context(), existing.VideoKey); err != nil { + slog.Warn("delete video object", "lesson_id", id, "key", existing.VideoKey, "error", err) + } + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/repository/lesson.go b/internal/repository/lesson.go new file mode 100644 index 0000000..669ba00 --- /dev/null +++ b/internal/repository/lesson.go @@ -0,0 +1,197 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "learning-service/internal/model" +) + +type LessonRepository struct { + pool *pgxpool.Pool +} + +func NewLessonRepository(pool *pgxpool.Pool) *LessonRepository { + return &LessonRepository{pool: pool} +} + +const lessonCols = ` + id, course_id, position, title, markdown, video_key, video_duration_sec, + test_id, is_required, created_at, updated_at +` + +func scanLesson(scan func(...any) error) (*model.Lesson, error) { + var l model.Lesson + if err := scan( + &l.ID, &l.CourseID, &l.Position, &l.Title, &l.Markdown, + &l.VideoKey, &l.VideoDurationSec, &l.TestID, &l.IsRequired, + &l.CreatedAt, &l.UpdatedAt, + ); err != nil { + return nil, err + } + return &l, nil +} + +func (r *LessonRepository) ListByCourse(ctx context.Context, courseID uuid.UUID) ([]model.Lesson, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+lessonCols+` FROM lessons + WHERE course_id = $1 + ORDER BY position, created_at`, courseID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Lesson{} + for rows.Next() { + l, err := scanLesson(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *l) + } + return out, rows.Err() +} + +func (r *LessonRepository) Get(ctx context.Context, id uuid.UUID) (*model.Lesson, error) { + l, err := scanLesson(r.pool.QueryRow(ctx, + `SELECT `+lessonCols+` FROM lessons WHERE id = $1`, id).Scan) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return l, nil +} + +func (r *LessonRepository) Create(ctx context.Context, courseID uuid.UUID, req model.CreateLessonRequest) (*model.Lesson, error) { + isRequired := true + if req.IsRequired != nil { + isRequired = *req.IsRequired + } + if strings.TrimSpace(req.Title) == "" { + return nil, fmt.Errorf("title is required") + } + var id uuid.UUID + err := r.pool.QueryRow(ctx, ` + INSERT INTO lessons (course_id, position, title, markdown, test_id, is_required) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + courseID, req.Position, req.Title, req.Markdown, req.TestID, isRequired, + ).Scan(&id) + if err != nil { + return nil, err + } + return r.Get(ctx, id) +} + +func (r *LessonRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateLessonRequest) (*model.Lesson, error) { + sets := []string{} + args := []any{} + add := func(col string, val any) { + args = append(args, val) + sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) + } + if req.Position != nil { + add("position", *req.Position) + } + if req.Title != nil { + add("title", *req.Title) + } + if req.Markdown != nil { + add("markdown", *req.Markdown) + } + if req.VideoKey != nil { + add("video_key", *req.VideoKey) + } + if req.VideoDurationSec != nil { + add("video_duration_sec", *req.VideoDurationSec) + } + if req.TestID != nil { + // req.TestID == &uuid.Nil → сбросить связь. Стандартное поведение + // «явный null» делается отдельным эндпоинтом — здесь Update только + // устанавливает не-nil значение. + add("test_id", *req.TestID) + } + if req.IsRequired != nil { + add("is_required", *req.IsRequired) + } + if len(sets) == 0 { + return r.Get(ctx, id) + } + sets = append(sets, "updated_at = NOW()") + args = append(args, id) + q := fmt.Sprintf(`UPDATE lessons SET %s WHERE id = $%d`, strings.Join(sets, ", "), len(args)) + tag, err := r.pool.Exec(ctx, q, args...) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrNotFound + } + return r.Get(ctx, id) +} + +// SetVideo — отдельный helper для post-upload UPDATE: ставит video_key +// и duration после успешного PutObject в MinIO. Возвращает старый key +// (если был) — handler удалит его из MinIO best-effort'ом, чтобы не +// плодить «висящие» объекты при замене видео. +func (r *LessonRepository) SetVideo(ctx context.Context, id uuid.UUID, key string, durationSec int) (oldKey string, err error) { + err = r.pool.QueryRow(ctx, ` + UPDATE lessons + SET video_key = $2, video_duration_sec = $3, updated_at = NOW() + WHERE id = $1 + RETURNING (SELECT video_key FROM lessons WHERE id = $1)`, + id, key, durationSec).Scan(&oldKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + return oldKey, nil +} + +func (r *LessonRepository) Delete(ctx context.Context, id uuid.UUID) (videoKey string, err error) { + // Возвращаем video_key чтобы handler мог удалить объект из MinIO. + err = r.pool.QueryRow(ctx, ` + DELETE FROM lessons WHERE id = $1 + RETURNING video_key`, id).Scan(&videoKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + return videoKey, nil +} + +// ReorderInCourse — батч-апдейт position'ов одного курса через UNNEST. +// Тот же паттерн что у TestRepository.ReorderQuestions. +func (r *LessonRepository) ReorderInCourse(ctx context.Context, courseID uuid.UUID, items []struct { + ID uuid.UUID + Position int +}) error { + if len(items) == 0 { + return nil + } + ids := make([]uuid.UUID, len(items)) + positions := make([]int, len(items)) + for i, it := range items { + ids[i] = it.ID + positions[i] = it.Position + } + _, err := r.pool.Exec(ctx, ` + UPDATE lessons l + SET position = u.pos, updated_at = NOW() + FROM UNNEST($1::uuid[], $2::int[]) AS u(id, pos) + WHERE l.id = u.id AND l.course_id = $3`, + ids, positions, courseID) + return err +} diff --git a/internal/storage/minio.go b/internal/storage/minio.go new file mode 100644 index 0000000..c153941 --- /dev/null +++ b/internal/storage/minio.go @@ -0,0 +1,231 @@ +// Package storage — обёртка над MinIO для хранения видео-уроков. +// Структура соответствует telephony-сервису (там records bucket), но +// учётка и bucket свои. Если ENV не сконфигурирован — Configured()=false, +// upload/stream handler'ы отдают 503. +package storage + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type Config struct { + Endpoint string + AccessKey string + SecretKey string + Bucket string + UseSSL bool +} + +type Storage struct { + cfg Config + client *minio.Client +} + +// New — конструктор. Если AccessKey/SecretKey пусты, Configured()=false и +// сервис работает без MinIO (видео-фичи отдают 503, остальное работает). +// Не создаёт bucket автоматически — это делается вручную при первом +// деплое, чтобы случайно не создать в production'е под кривыми creds. +func New(cfg Config) (*Storage, error) { + if cfg.Endpoint == "" || cfg.AccessKey == "" || cfg.SecretKey == "" { + return &Storage{cfg: cfg}, nil + } + cli, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("init minio client: %w", err) + } + return &Storage{cfg: cfg, client: cli}, nil +} + +// Configured — true когда MinIO готов принимать запросы (есть creds + +// клиент создан). Handler'ы видео-функций гейтятся этим флагом. +func (s *Storage) Configured() bool { + return s.client != nil && s.cfg.Bucket != "" +} + +// EnsureBucket — создаёт bucket если его нет. Вызывается один раз при +// старте сервиса (best-effort: ошибку логируем но не падаем — bucket мог +// быть создан вручную с особыми правами/политиками). +func (s *Storage) EnsureBucket(ctx context.Context) error { + if !s.Configured() { + return nil + } + exists, err := s.client.BucketExists(ctx, s.cfg.Bucket) + if err != nil { + return fmt.Errorf("check bucket: %w", err) + } + if exists { + return nil + } + return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{}) +} + +// GenerateKey — путь объекта в bucket'е. Структура: /., +// где random — короткий uuid для anti-cache + защита от перезаписи случайно. +// При замене видео ставится новый key, старый объект остаётся в MinIO +// (отдельный GC по «висящим» ключам — пока не реализован). +func GenerateKey(lessonID uuid.UUID, filename string) string { + ext := "mp4" + if i := strings.LastIndex(filename, "."); i >= 0 && i < len(filename)-1 { + raw := strings.ToLower(filename[i+1:]) + // Whitelist расширений — иначе можно подсунуть исполняемый файл. + switch raw { + case "mp4", "webm", "mov", "m4v", "ogg": + ext = raw + } + } + return fmt.Sprintf("%s/%s.%s", lessonID.String(), uuid.NewString()[:8], ext) +} + +// PutObject — загрузка из reader'а. ContentType определяется по +// расширению (whitelist в GenerateKey), но клиент может прислать +// свой Content-Type — если корректный video/*, используем его. +func (s *Storage) PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { + if !s.Configured() { + return errors.New("storage not configured") + } + if !strings.HasPrefix(contentType, "video/") { + contentType = guessContentType(key) + } + _, err := s.client.PutObject(ctx, s.cfg.Bucket, key, body, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + return err +} + +// guessContentType — fallback для PutObject когда клиент прислал generic +// Content-Type (application/octet-stream от curl, например). Whitelist +// синхронизирован с GenerateKey. +func guessContentType(key string) string { + switch { + case strings.HasSuffix(key, ".mp4"): + return "video/mp4" + case strings.HasSuffix(key, ".webm"): + return "video/webm" + case strings.HasSuffix(key, ".mov"): + return "video/quicktime" + case strings.HasSuffix(key, ".m4v"): + return "video/x-m4v" + case strings.HasSuffix(key, ".ogg"): + return "video/ogg" + default: + return "application/octet-stream" + } +} + +// ObjectInfo — метаданные объекта (size + content-type). +type ObjectInfo struct { + Size int64 + ContentType string +} + +// Stat — заголовки объекта (HEAD). +func (s *Storage) Stat(ctx context.Context, key string) (*ObjectInfo, error) { + if !s.Configured() { + return nil, errors.New("storage not configured") + } + info, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{}) + if err != nil { + return nil, err + } + return &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil +} + +// GetObject — открывает stream объекта. Опциональный Range +// (rangeStart..rangeEnd, 0 = «не задан») — используется для seek'а +// в видео-плеере (HTML5 audio/video шлёт Range: bytes=NNN-). +func (s *Storage) GetObject(ctx context.Context, key string, rangeStart, rangeEnd int64) (io.ReadCloser, *ObjectInfo, error) { + if !s.Configured() { + return nil, nil, errors.New("storage not configured") + } + opts := minio.GetObjectOptions{} + if rangeStart > 0 || rangeEnd > 0 { + _ = opts.SetRange(rangeStart, rangeEnd) + } + obj, err := s.client.GetObject(ctx, s.cfg.Bucket, key, opts) + if err != nil { + return nil, nil, err + } + info, err := obj.Stat() + if err != nil { + _ = obj.Close() + return nil, nil, err + } + return obj, &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil +} + +// Delete — удаление объекта. Используется при замене видео или удалении +// урока (best-effort; ошибки не блокируют CRUD, лог + продолжаем). +func (s *Storage) Delete(ctx context.Context, key string) error { + if !s.Configured() { + return nil + } + return s.client.RemoveObject(ctx, s.cfg.Bucket, key, minio.RemoveObjectOptions{}) +} + +// ParseRange — парсер HTTP Range header'а. Поддерживает bytes=N-M / N- / -M. +// Возвращает (start, end, ok). При невалидном — ok=false и handler отдаёт +// весь объект 200, как и должен. +func ParseRange(header string, totalSize int64) (start, end int64, ok bool) { + if !strings.HasPrefix(header, "bytes=") { + return 0, 0, false + } + v := strings.TrimPrefix(header, "bytes=") + parts := strings.SplitN(v, "-", 2) + if len(parts) != 2 { + return 0, 0, false + } + if parts[0] == "" { + // Suffix: bytes=-N (последние N байт). + n, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || n <= 0 { + return 0, 0, false + } + return totalSize - n, totalSize - 1, true + } + start, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || start < 0 { + return 0, 0, false + } + if parts[1] == "" { + // Open-ended: bytes=N- → до конца. + return start, totalSize - 1, true + } + end, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil || end < start { + return 0, 0, false + } + if end >= totalSize { + end = totalSize - 1 + } + return start, end, true +} + +// WriteRangeResponse — заполняет writer ответом по результату ParseRange. +// При hasRange=true ставит 206 Partial Content + Content-Range/Content-Length; +// иначе 200 OK + Content-Length=totalSize. Зовётся handler'ом стрима. +func WriteRangeResponse(w http.ResponseWriter, contentType string, totalSize, start, end int64, hasRange bool) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Cache-Control", "no-store, private, no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + if hasRange { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize)) + w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10)) + w.WriteHeader(http.StatusPartialContent) + } else { + w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10)) + } +}