Микросервис обучения портала: тесты, курсы, видео-уроки, доступы, 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>
291 lines
18 KiB
PL/PgSQL
291 lines
18 KiB
PL/PgSQL
-- 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;
|