Files
learning/internal/repository/test.go
Ilya 62519081e7 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>
2026-05-25 22:43:37 +03:00

283 lines
8.5 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 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
}