init: learning-service skeleton

Микросервис обучения портала: тесты, курсы, видео-уроки, доступы,
public-ссылки для кандидатов с email-валидацией.

В этой итерации:
- Skeleton (config, migrate, main, health) по паттерну tasks/candidates
- Migration 001_init: 10 таблиц (tests/questions/answers/attempts/
  attempt_answers + courses/lessons/lesson_progress + access_grants +
  public_tokens) с подробными комментариями why
- Tests: полный CRUD + вопросы/ответы; non-owner'у is_correct и
  explanation скрываются в выдаче
- Заглушки 501 для attempts / courses / lessons / video-stream /
  access / public-tokens — следующие итерации
- k8s: namespace, configmap, secrets, postgres, deployment с HPA,
  service с portal-discovery annotations
- Dockerfile, Makefile, .gitignore

См. README.md для полного списка отложенного и инструкций запуска.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 22:43:37 +03:00
commit 62519081e7
24 changed files with 1915 additions and 0 deletions

282
internal/repository/test.go Normal file
View File

@@ -0,0 +1,282 @@
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 TestRepository struct {
pool *pgxpool.Pool
}
func NewTestRepository(pool *pgxpool.Pool) *TestRepository {
return &TestRepository{pool: pool}
}
// testCols включает correlated subquery для счётчика вопросов — UI получает
// бейджи без отдельного round-trip'а на каждый тест в списке.
const testCols = `
t.id, t.title, t.description, t.passing_score, t.max_attempts,
t.time_limit_sec, t.shuffle_questions, t.show_correct_after,
t.is_published, t.owner_user_id, t.created_at, t.updated_at,
(SELECT COUNT(*)::int FROM test_questions q WHERE q.test_id = t.id) AS questions_count
`
func scanTest(scan func(...any) error) (*model.Test, error) {
var t model.Test
if err := scan(
&t.ID, &t.Title, &t.Description, &t.PassingScore, &t.MaxAttempts,
&t.TimeLimitSec, &t.ShuffleQuestions, &t.ShowCorrectAfter,
&t.IsPublished, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt,
&t.QuestionsCount,
); err != nil {
return nil, err
}
return &t, nil
}
// List — листинг тестов. Фильтр onlyPublished исключает черновики (для не-владельца).
// ownerFilter (не nil) — показать только тесты конкретного юзера.
//
// Для гранулярного access-check'а (через access_grants) предусмотрено
// поле visibleIDs: если не nil, добавляется фильтр id = ANY($X). Это
// позволяет handler'у дёрнуть access-репо отдельно и передать id'шники
// сюда — repo не зависит от access-репо (плоский граф).
func (r *TestRepository) List(ctx context.Context, ownerFilter *uuid.UUID, onlyPublished bool, visibleIDs []uuid.UUID) ([]model.Test, error) {
conds := []string{}
args := []any{}
push := func(cond string, val any) {
args = append(args, val)
conds = append(conds, strings.Replace(cond, "?", fmt.Sprintf("$%d", len(args)), 1))
}
if ownerFilter != nil {
push("t.owner_user_id = ?", *ownerFilter)
}
if onlyPublished {
conds = append(conds, "t.is_published = TRUE")
}
if visibleIDs != nil {
// nil = без фильтра, пустой массив = «ничего не видно».
if len(visibleIDs) == 0 {
return []model.Test{}, nil
}
push("t.id = ANY(?)", visibleIDs)
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ")
}
q := fmt.Sprintf(`SELECT %s FROM tests t %s ORDER BY t.updated_at DESC`, testCols, where)
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Test{}
for rows.Next() {
t, err := scanTest(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *t)
}
return out, rows.Err()
}
func (r *TestRepository) Get(ctx context.Context, id uuid.UUID) (*model.Test, error) {
q := fmt.Sprintf(`SELECT %s FROM tests t WHERE t.id = $1`, testCols)
t, err := scanTest(r.pool.QueryRow(ctx, q, id).Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return t, nil
}
func (r *TestRepository) Create(ctx context.Context, ownerID uuid.UUID, req model.CreateTestRequest) (*model.Test, error) {
var id uuid.UUID
err := r.pool.QueryRow(ctx, `
INSERT INTO tests (
title, description, passing_score, max_attempts, time_limit_sec,
shuffle_questions, show_correct_after, owner_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
req.Title, req.Description, req.PassingScore, req.MaxAttempts,
req.TimeLimitSec, req.ShuffleQuestions, req.ShowCorrectAfter, ownerID,
).Scan(&id)
if err != nil {
return nil, err
}
return r.Get(ctx, id)
}
func (r *TestRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateTestRequest) (*model.Test, 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.Title != nil {
add("title", *req.Title)
}
if req.Description != nil {
add("description", *req.Description)
}
if req.PassingScore != nil {
add("passing_score", *req.PassingScore)
}
if req.MaxAttempts != nil {
add("max_attempts", *req.MaxAttempts)
}
if req.TimeLimitSec != nil {
add("time_limit_sec", *req.TimeLimitSec)
}
if req.ShuffleQuestions != nil {
add("shuffle_questions", *req.ShuffleQuestions)
}
if req.ShowCorrectAfter != nil {
add("show_correct_after", *req.ShowCorrectAfter)
}
if req.IsPublished != nil {
add("is_published", *req.IsPublished)
}
if len(sets) == 0 {
return r.Get(ctx, id)
}
sets = append(sets, "updated_at = NOW()")
args = append(args, id)
q := fmt.Sprintf(`UPDATE tests 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)
}
func (r *TestRepository) Delete(ctx context.Context, id uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM tests WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListQuestions — все вопросы теста, плюс ответы (через отдельный запрос
// и in-memory join по question_id). Дёшево — обычно у теста 5-30 вопросов
// по 2-4 ответа = единицы сотен строк.
func (r *TestRepository) ListQuestions(ctx context.Context, testID uuid.UUID) ([]model.Question, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, test_id, position, kind, text, points, explanation, created_at
FROM test_questions
WHERE test_id = $1
ORDER BY position, created_at`, testID)
if err != nil {
return nil, err
}
defer rows.Close()
qs := []model.Question{}
qIDs := []uuid.UUID{}
for rows.Next() {
var q model.Question
if err := rows.Scan(&q.ID, &q.TestID, &q.Position, &q.Kind, &q.Text, &q.Points, &q.Explanation, &q.CreatedAt); err != nil {
return nil, err
}
qs = append(qs, q)
qIDs = append(qIDs, q.ID)
}
if len(qs) == 0 {
return qs, nil
}
// Один батч-запрос на все ответы.
aRows, err := r.pool.Query(ctx, `
SELECT id, question_id, position, text, is_correct, created_at
FROM test_answers
WHERE question_id = ANY($1)
ORDER BY position, created_at`, qIDs)
if err != nil {
return nil, err
}
defer aRows.Close()
byQ := map[uuid.UUID][]model.Answer{}
for aRows.Next() {
var a model.Answer
if err := aRows.Scan(&a.ID, &a.QuestionID, &a.Position, &a.Text, &a.IsCorrect, &a.CreatedAt); err != nil {
return nil, err
}
byQ[a.QuestionID] = append(byQ[a.QuestionID], a)
}
for i := range qs {
qs[i].Answers = byQ[qs[i].ID]
}
return qs, nil
}
// CreateQuestion — транзакционно создаёт вопрос + ответы (отдельный INSERT'ом
// на каждый, без bulk — у теста типично 2-6 ответов на вопрос).
func (r *TestRepository) CreateQuestion(ctx context.Context, testID uuid.UUID, req model.CreateQuestionRequest) (*model.Question, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
var qID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO test_questions (test_id, position, kind, text, points, explanation)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
testID, req.Position, req.Kind, req.Text, req.Points, req.Explanation,
).Scan(&qID)
if err != nil {
return nil, err
}
for _, a := range req.Answers {
if _, err := tx.Exec(ctx, `
INSERT INTO test_answers (question_id, position, text, is_correct)
VALUES ($1, $2, $3, $4)`,
qID, a.Position, a.Text, a.IsCorrect); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
// Перечитываем целиком, чтобы вернуть и вопрос, и ответы.
all, err := r.ListQuestions(ctx, testID)
if err != nil {
return nil, err
}
for _, q := range all {
if q.ID == qID {
return &q, nil
}
}
return nil, ErrNotFound
}
func (r *TestRepository) DeleteQuestion(ctx context.Context, questionID uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM test_questions WHERE id = $1`, questionID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}