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

290
migrations/001_init.up.sql Normal file
View File

@@ -0,0 +1,290 @@
-- learning-service: первая миграция. Создаёт схему MVP:
-- тесты + вопросы + ответы + попытки прохождения
-- курсы + уроки (видео + markdown + опциональный тест в конце)
-- access_grants — гранулярные доступы (user / role / department / position)
-- public_tokens — одноразовые ссылки для кандидатов с email-проверкой
--
-- Cross-service ссылки (subject_id, candidate_id, owner_user_id) хранятся
-- как UUID без FK — portal/candidates живут в других БД. Целостность
-- гарантируется бизнес-логикой (валидация при создании).
--
-- 1 файл вместо разнесения по фичам — на старте проще читать как единое
-- целое; начнётся эволюция — разнесём.
BEGIN;
-- ============================================================
-- TESTS
-- ============================================================
-- Тест — самостоятельная единица для прохождения. Может быть прикреплён
-- к уроку курса (lessons.test_id) или существовать отдельно (для public-
-- ссылок кандидатам, ad-hoc проверок и т.п.).
CREATE TABLE tests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
-- passing_score: процент правильных от max, 0..100. 70 = по умолчанию
-- классическая «3+». Если null — авто-зачёт не рассчитываем,
-- HR смотрит результат вручную.
passing_score INT NULL CHECK (passing_score IS NULL OR (passing_score >= 0 AND passing_score <= 100)),
-- max_attempts: 0 = без лимита, N > 0 — попытки ограничены.
max_attempts INT NOT NULL DEFAULT 0 CHECK (max_attempts >= 0),
-- time_limit_sec: 0 = без таймера; иначе UI ставит обратный отсчёт.
time_limit_sec INT NOT NULL DEFAULT 0 CHECK (time_limit_sec >= 0),
-- shuffle_questions: если true, UI каждой попытке отдаёт вопросы в
-- случайном порядке. Серверный select по test_questions.position
-- остаётся; шафлинг на клиенте.
shuffle_questions BOOLEAN NOT NULL DEFAULT FALSE,
-- show_correct_after: показывать правильные ответы по завершении
-- (учебные тесты — да; аттестационные — обычно нет).
show_correct_after BOOLEAN NOT NULL DEFAULT TRUE,
-- is_published — черновики не видны никому кроме owner; published
-- видны всем, кому есть access_grant.
is_published BOOLEAN NOT NULL DEFAULT FALSE,
owner_user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tests_owner ON tests(owner_user_id);
CREATE INDEX idx_tests_published ON tests(is_published) WHERE is_published = TRUE;
-- Вопрос теста. kind определяет UI-рендер и логику авто-оценки.
-- single — radio-buttons, ровно один is_correct;
-- multi — checkboxes, может быть >=1 is_correct;
-- text — свободный ввод, оценка вручную HR'ом (auto_grade=false).
CREATE TABLE test_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
-- position: явный порядок (drag&drop в UI выставит). Уникальность
-- НЕ обеспечиваем (двигание через UPDATE батчем — было бы много
-- констрейнт-flip'ов; на дубли клиент не натолкнётся в норме).
position INT NOT NULL DEFAULT 0,
kind TEXT NOT NULL DEFAULT 'single'
CHECK (kind IN ('single','multi','text')),
-- text — текст вопроса; markdown допустим, фронт рендерит.
text TEXT NOT NULL,
-- points: вклад в общий счёт. 1 по умолчанию; для «бонусных» можно 2-3.
points INT NOT NULL DEFAULT 1 CHECK (points > 0),
-- explanation: показывается после ответа (если show_correct_after).
explanation TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_test_questions_test ON test_questions(test_id, position);
-- Вариант ответа. Для kind=text — не используется (NULL=correct + sample_answer
-- в самом вопросе). Для single/multi — варианты с is_correct-флагом.
CREATE TABLE test_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_test_answers_question ON test_answers(question_id, position);
-- Попытка прохождения. Один из {user_id, public_token_id} обязателен —
-- либо это сотрудник под портальным логином, либо кандидат по public-токену.
-- candidate_id заполняется когда токен был выпущен под конкретного кандидата
-- (см. public_tokens.candidate_id); удобно для join'ов с candidates-сервисом.
CREATE TABLE test_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
user_id UUID NULL,
public_token_id UUID NULL,
candidate_id BIGINT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','submitted','graded','expired')),
-- score: суммарные баллы за правильные ответы; max_score = sum points
-- по test_questions на момент сабмита (фиксируем для read-only результата).
score INT NULL,
max_score INT NULL,
-- passed: рассчитывается при сабмите если у теста passing_score != NULL.
passed BOOLEAN NULL,
-- Денормализованный snapshot ФИО респондента для public-ссылок:
-- кандидат заполняет в форме перед стартом, мы сохраняем, чтобы HR видел
-- кто проходил (без join'а с candidates).
respondent_name TEXT NOT NULL DEFAULT '',
respondent_email TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_at TIMESTAMPTZ NULL,
graded_at TIMESTAMPTZ NULL,
-- Метаданные клиента — для аудита подозрительных попыток (один токен,
-- разные IP'шники → возможно ссылку перешарили).
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
-- Гарантируем что заполнен хотя бы один из user_id / public_token_id.
CONSTRAINT test_attempts_subject_required CHECK (
user_id IS NOT NULL OR public_token_id IS NOT NULL
)
);
CREATE INDEX idx_test_attempts_test ON test_attempts(test_id, started_at DESC);
CREATE INDEX idx_test_attempts_user ON test_attempts(user_id, started_at DESC)
WHERE user_id IS NOT NULL;
CREATE INDEX idx_test_attempts_candidate ON test_attempts(candidate_id, started_at DESC)
WHERE candidate_id IS NOT NULL;
CREATE INDEX idx_test_attempts_token ON test_attempts(public_token_id, started_at DESC)
WHERE public_token_id IS NOT NULL;
-- Ответ пользователя на конкретный вопрос в попытке. Хранится JSONB,
-- т.к. формат зависит от kind:
-- single → {"answer_id": "uuid"}
-- multi → {"answer_ids": ["uuid", "uuid"]}
-- text → {"text": "...свободный ответ..."}
-- При сабмите attempts-handler сверяется с test_answers.is_correct и
-- считает score; для text — оставляет null до ручной оценки HR'ом.
CREATE TABLE test_attempt_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
attempt_id UUID NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
-- correct: NULL для text (ждёт ручной оценки), true/false для auto-graded.
correct BOOLEAN NULL,
-- score: 0..question.points. Заполняется при сабмите (auto) или вручную HR'ом.
score INT NULL CHECK (score IS NULL OR score >= 0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (attempt_id, question_id)
);
-- ============================================================
-- COURSES
-- ============================================================
-- Курс = упорядоченный список уроков. Уроки могут содержать видео + текст
-- и ОПЦИОНАЛЬНО прикреплённый тест (test_id NULL — урок без проверки).
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
-- slug: короткий человекочитаемый идентификатор для URL'ей
-- (/learning/c/onboarding-2025). Уникален среди опубликованных.
slug TEXT NOT NULL,
-- cover_image_key: опциональная картинка-обложка (MinIO key).
cover_image_key TEXT NOT NULL DEFAULT '',
is_published BOOLEAN NOT NULL DEFAULT FALSE,
owner_user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (slug)
);
CREATE INDEX idx_courses_owner ON courses(owner_user_id);
CREATE INDEX idx_courses_published ON courses(is_published) WHERE is_published = TRUE;
-- Урок. Внутри — markdown-тело (всегда есть, может быть пустым) + видео
-- (опционально, через MinIO key) + тест в конце (опционально, FK на tests).
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
-- markdown: основной контент. Можно использовать сам по себе (без видео).
markdown TEXT NOT NULL DEFAULT '',
-- video_key: ключ объекта в MinIO bucket (см. config.MinIOBucket).
-- При удалении урока физический файл НЕ удаляется — отдельный сборщик
-- мусора по «висящим» ключам, чтобы случайный delete не уничтожил видео.
video_key TEXT NOT NULL DEFAULT '',
video_duration_sec INT NOT NULL DEFAULT 0,
-- test_id: связь с тестом. На уровне урока — для проверки усвоения.
test_id UUID NULL REFERENCES tests(id) ON DELETE SET NULL,
-- is_required: курс не считается пройденным пока этот урок не закрыт.
-- В MVP «закрыт» = выставлен flag в progress-таблице (см. ниже).
is_required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_lessons_course ON lessons(course_id, position);
-- Прогресс по урокам. Один ряд = один пользователь × один урок.
-- viewed_at = просмотрел видео/прочитал markdown; completed_at = плюс
-- сдал прикреплённый тест (если он был). Для уроков без теста completed_at
-- ставится одновременно с viewed_at (handler сам отметит).
CREATE TABLE lesson_progress (
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
viewed_at TIMESTAMPTZ NULL,
completed_at TIMESTAMPTZ NULL,
PRIMARY KEY (lesson_id, user_id)
);
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id, completed_at DESC);
-- ============================================================
-- ACCESS GRANTS
-- ============================================================
-- Гранулярный доступ к ресурсу. Один ряд = «кому что разрешено».
--
-- resource_type: 'test' | 'course'
-- subject_type: 'user' | 'role' | 'department' | 'position' | 'public'
-- - user: subject_id = portal user_id (UUID)
-- - role: subject_id = portal role_id (UUID) — все носители роли получают доступ
-- - department: subject_id = portal department_id (UUID) — все из отдела
-- - position: subject_id = portal position_id (UUID) — все на этой должности
-- - public: subject_id = NULL — anyone с действующим public-токеном
--
-- can_manage: если true — subject может редактировать ресурс (owner + admins
-- получают это автоматически без grant'а; здесь — для «соавторов»).
CREATE TABLE access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
resource_id UUID NOT NULL,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','role','department','position','public')),
subject_id UUID NULL,
can_manage BOOLEAN NOT NULL DEFAULT FALSE,
granted_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- public: subject_id обязательно NULL; для остальных типов — обязательно заполнено.
CONSTRAINT access_grants_subject_id_match CHECK (
(subject_type = 'public' AND subject_id IS NULL) OR
(subject_type <> 'public' AND subject_id IS NOT NULL)
),
-- Уникальность: не можем выдать два одинаковых грантa.
UNIQUE (resource_type, resource_id, subject_type, subject_id)
);
CREATE INDEX idx_access_resource ON access_grants(resource_type, resource_id);
CREATE INDEX idx_access_subject ON access_grants(subject_type, subject_id)
WHERE subject_id IS NOT NULL;
-- ============================================================
-- PUBLIC TOKENS
-- ============================================================
-- Одноразовая ссылка для кандидата. HR создаёт токен на конкретный тест
-- или курс, привязывает к email + опционально candidate_id, отправляет
-- ссылку <PUBLIC_BASE>/public/learning/<resource_type>/<token>.
--
-- При открытии:
-- 1. Фронт спрашивает email
-- 2. Бэк сверяет с intended_email (case-insensitive). Не совпало → 403.
-- 3. Создаём test_attempt с public_token_id, привязываем opened_at.
-- 4. Юзер проходит, отправляет — used_at заполняется.
--
-- max_attempts > 1 разрешает несколько прохождений по одной ссылке (например,
-- для tutorial-теста). По умолчанию 1.
CREATE TABLE public_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- token: длинная случайная строка; ходит в URL. Делаем поле UNIQUE,
-- чтобы повторно открыть ссылку и проверить, что она ещё действует.
token TEXT NOT NULL UNIQUE,
resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
resource_id UUID NOT NULL,
intended_email TEXT NOT NULL,
-- candidate_id: если HR создавал ссылку из карточки candidate'а
-- сохраняем для join'а результатов с candidates-сервисом.
candidate_id BIGINT NULL,
max_attempts INT NOT NULL DEFAULT 1 CHECK (max_attempts > 0),
used_attempts INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NULL,
-- opened_at: первый раз когда токен был «активирован» (открыт +
-- прошёл email-проверку). Для аудита.
opened_at TIMESTAMPTZ NULL,
-- used_at: первый submitted attempt. После этого, если max_attempts=1,
-- ссылка не открывается заново.
used_at TIMESTAMPTZ NULL,
-- revoked_at: HR может вручную отозвать ссылку до использования.
revoked_at TIMESTAMPTZ NULL,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_public_tokens_resource ON public_tokens(resource_type, resource_id);
CREATE INDEX idx_public_tokens_candidate ON public_tokens(candidate_id)
WHERE candidate_id IS NOT NULL;
CREATE INDEX idx_public_tokens_email ON public_tokens(LOWER(intended_email));
COMMIT;