Files
learning/internal/repository/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

198 lines
5.6 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 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
}