feat(lessons): уроки + видео (MinIO stream-proxy)
All checks were successful
CI / test (push) Successful in 27s
Build and Deploy / build-and-deploy (push) Successful in 33s

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:
Ilya
2026-05-25 23:58:05 +03:00
parent 350703ab83
commit 80c019b791
6 changed files with 924 additions and 11 deletions

View 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
}