Files
learning/internal/handler/lesson.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

386 lines
12 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.
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)
}