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