Files
learning/internal/handler/lesson.go
Ilya 400df0124d
All checks were successful
CI / test (push) Successful in 19s
Build and Deploy / build-and-deploy (push) Successful in 26s
feat(lessons): ListVideos — плоский endpoint для раздела «Видео-уроки»
LessonRepository.ListVideos: SELECT с INNER JOIN courses, фильтр
video_key != '' + (course.is_published OR course.owner_user_id = viewer).
Возвращает LessonWithCourse — урок + denorm course_{title,slug,
is_published,owner_user_id} чтобы фронт сгруппировал по курсу
без N+1.

LessonHandler.ListVideos: GET /lessons?has_video=true. Гейт уже на
SQL-уровне, в коде только X-User-Id из headers.

Route регистрируется ДО /lessons/{id}, иначе chi бы заматчил
{id}="lessons".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:17:34 +03:00

403 lines
13 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})
}
// ListVideos — GET /lessons?has_video=true. Плоский список уроков с
// видео для отдельной страницы «Видео-уроки» в портале. Гейтит на уровне
// репо: только опубликованные курсы или мои.
func (h *LessonHandler) ListVideos(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
items, err := h.repo.ListVideos(r.Context(), uid)
if err != nil {
writeRepoError(w, r, err, "list video 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)
}