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:
12
migrations/001_init.down.sql
Normal file
12
migrations/001_init.down.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
BEGIN;
|
||||
DROP TABLE IF EXISTS public_tokens;
|
||||
DROP TABLE IF EXISTS access_grants;
|
||||
DROP TABLE IF EXISTS lesson_progress;
|
||||
DROP TABLE IF EXISTS lessons;
|
||||
DROP TABLE IF EXISTS courses;
|
||||
DROP TABLE IF EXISTS test_attempt_answers;
|
||||
DROP TABLE IF EXISTS test_attempts;
|
||||
DROP TABLE IF EXISTS test_answers;
|
||||
DROP TABLE IF EXISTS test_questions;
|
||||
DROP TABLE IF EXISTS tests;
|
||||
COMMIT;
|
||||
290
migrations/001_init.up.sql
Normal file
290
migrations/001_init.up.sql
Normal 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;
|
||||
Reference in New Issue
Block a user