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)
|
||||
|
||||
23
go.mod
23
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.
|
||||
|
||||
54
go.sum
54
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=
|
||||
|
||||
385
internal/handler/lesson.go
Normal file
385
internal/handler/lesson.go
Normal file
@@ -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)
|
||||
}
|
||||
197
internal/repository/lesson.go
Normal file
197
internal/repository/lesson.go
Normal file
@@ -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
|
||||
}
|
||||
231
internal/storage/minio.go
Normal file
231
internal/storage/minio.go
Normal file
@@ -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'е. Структура: <lesson_id>/<random>.<ext>,
|
||||
// где 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user