68 Commits

Author SHA1 Message Date
a42bc64193 fix: края архива, истории, пагинации и лимита попыток 2026-10-29 21:05:44 +03:00
82394cc688 feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости
- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
2026-10-29 20:51:27 +03:00
7ef5c866a3 fix(web): устойчивые ошибки courseNo и login E2E 2026-10-29 20:01:40 +03:00
65ed08f5c2 feat(web): перевести браузерные URL курсов на courseNo
- teacher/admin course pages, content, gradebook, profile, downloads
- courseNo -> UUID только на границе REST API
- обновлены e2e, шаблоны, тесты и docs
- LDD-UI-002; baseline 59 claims / 385 тестов
2026-10-29 19:51:03 +03:00
b57a5ffbce docs: уточнить остаточные P1/P2 после импорта 2026-10-29 19:24:09 +03:00
1b98b1828d fix(import): обновлять updated_at при восстановлении аккаунта 2026-10-29 19:23:35 +03:00
1570196cc7 fix(import): построчные ошибки до записи
- preflight занятого логина и неактивной целевой группы
- applied=false с номером строки, без частичных студентов
- LDD-IMPORT-003; baseline 58 claims / 368 тестов
2026-10-29 19:20:16 +03:00
238c689175 docs: актуализировать счётчики тестов по модулям 2026-10-29 18:59:19 +03:00
9412d87fed fix: закрыть P0/P1 расхождения с целевой моделью
- академический отпуск: readOnly, запрет submit/start/finish защиты
- черновики лабораторных скрыты от студентов в списке/деталях/по UUID
- удалено действие «Убрать из группы» из UI и API
- импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта
- глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории
- LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0
- backup: безопасный парсинг .env, verify-latest, кавычки cron
- robots/noindex, комментарии без автора, профиль без черновиков
- docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
2026-10-29 18:58:21 +03:00
9cb4d0d263 docs: обновить baseline после hotfix действий статей 2026-10-29 12:04:43 +03:00
f3d614bb33 ci: изолировать пайплайны на отдельной VM
- Woodpecker workflows требуют агент с меткой isolation=dedicated-vm
- шаги выполняются в lms-ci:1 с nested dockerd, без хост-Docker сокета
- ci/Dockerfile: прогретый immutable-образ (Java, Python, Node, Gradle-кэш)
- ci-dind.sh поднимает dockerd внутри privileged-шага; ci-build-image.sh собирает образ
- deploy/WOODPECKER.md: отдельная VM, firewall, отсутствие production-секретов,
  trusted repo, чек-лист безопасности
2026-10-29 12:01:44 +03:00
685916c4bd ci: перевести merge gate на Gitea + Woodpecker
- .woodpecker/check.yaml, smoke.yaml, release.yaml
- local-backend агент: Java 21, Docker Compose v2, Python 3, Node/Playwright
- concurrency lms-ci, чтобы smoke/release не занимали порты одновременно
- deploy/WOODPECKER.md: server, OAuth Gitea, systemd-агент, security note
- удалён .gitlab-ci.yml
2026-10-29 12:01:44 +03:00
1e1e9ae0e4 feat: закрыть остаточный backlog
- keyset-пагинация аудита с курсором (occurred_at, id) и UI «Показать ещё»
- пакетный /api/v1/labs/details и переход progress на один запрос
- абсолютный потолок сессии 30 дней (identity V12 + web), очистка refresh
- админская инвентаризация legacy-активов (/admin/legacy-assets, скрипт)
- E2E в Chromium/Firefox/WebKit (18/18), CI preflight
- LDD: 51 claim, 346 тестов, docs и handoff обновлены
2026-10-29 12:01:44 +03:00
d45137220c docs: handoff после закрытия продуктовых рисков 2026-10-29 12:01:44 +03:00
9dcba8ce0c docs: закрытие продуктовых рисков и новый LDD baseline 2026-10-29 12:01:44 +03:00
71fa3284a0 ci: дымовой тест на push и полный release gate 2026-10-29 12:01:44 +03:00
4ef6c7f7ed feat(perf): пагинация аудита и bulk-группы курса 2026-10-29 12:01:44 +03:00
3adfba9bec feat(auth): короткий access, refresh и 7-дневная sliding-сессия 2026-10-29 12:01:44 +03:00
08e5a62c0e feat(security): закрыть legacy-активы от публичного доступа 2026-10-29 12:01:44 +03:00
97149fa59d docs: актуальный p95 LDD load smoke 2026-10-29 12:01:44 +03:00
287e01cde8 docs: завершить LDD baseline и release evidence 2026-10-29 12:01:44 +03:00
85d34e78cb test(ldd): конкурентность, нагрузка и release gate 2026-10-29 12:01:44 +03:00
05f628a52b docs: установка Chromium для LDD browser E2E 2026-10-29 12:01:44 +03:00
238fd91f69 docs: LDD baseline с mutation и browser E2E 2026-10-29 12:01:44 +03:00
b7129b92e0 fix(json): применять Kotlin-модуль Jackson 3 2026-10-29 12:01:43 +03:00
905feb440b feat(ldd): property-based, mutation и браузерный E2E 2026-10-29 12:01:43 +03:00
80ccf26b61 docs: LDD baseline и ссылки на реестр 2026-10-29 12:01:43 +03:00
e70394d634 ci: добавить LDD evidence gate 2026-10-29 12:01:43 +03:00
4130a1af2f feat(ldd): реестр утверждений, evidence gate и runtime-проверки 2026-10-29 12:01:43 +03:00
0ac75bcd5c docs: уточнить аудит просмотра профиля 2026-10-29 12:01:43 +03:00
25a3dbd0fb docs: права преподавателя, аудит, CI и итоговые проверки 2026-10-29 12:01:43 +03:00
d7b886513f chore: маршрут аудита, внутренний токен academic и CI 2026-10-29 12:01:43 +03:00
16e6c3282b test(web): преподавательская ведомость, материалы и аудит 2026-10-29 12:01:43 +03:00
97e14f040c feat(web): ведомость преподавателя, материалы курса и аудит 2026-10-29 12:01:43 +03:00
4d97feaf9e feat(identity): центральный аудит и внутренний список групп 2026-10-29 12:01:43 +03:00
c6ca981eca feat(progress): преподавательские оценки, защиты и аудит 2026-10-29 12:01:43 +03:00
1de7207c3c feat(academic): права преподавателя, группы курса и аудит 2026-10-29 12:01:43 +03:00
5affe9873b feat(web): преподавательский редактор лабораторных 2026-10-29 12:01:43 +03:00
916046791a feat(progress): доступ преподавателя к операциям лабораторной 2026-10-29 12:01:43 +03:00
7c86194765 feat(academic): преподаватель может вести лабораторные 2026-10-29 12:01:43 +03:00
d6461a3a9c docs: видимость активов и итоговые проверки 2026-10-29 12:01:43 +03:00
9aef82e217 feat(academic): защита активов материалов 2026-10-29 12:01:43 +03:00
3fd0d41cf2 docs: итоговый отчёт и оставшиеся разрывы 2026-10-29 12:01:43 +03:00
fba648ea54 fix(web): перепроверять статус сессии 2026-10-29 12:01:43 +03:00
520a61085a fix(academic): детерминированный предпросмотр при совпадении slug 2026-10-29 12:01:43 +03:00
60a318539c docs: этап 5 — курсовые материалы 2026-10-29 12:01:43 +03:00
0b5d5d0ca5 feat(web): страницы материалов курса 2026-10-29 12:01:43 +03:00
43229059d3 feat(academic): курсовые материалы и публичный ALL-контент 2026-10-29 12:01:43 +03:00
4a2daf2fec docs: этапы 3–4 — роли, профиль и жизненный цикл 2026-10-29 12:01:43 +03:00
1d21e66dc4 feat(web): повышение курса и жизненный цикл студентов 2026-10-29 12:01:43 +03:00
e139a585b4 feat(identity): повышение курса и жизненный цикл студентов 2026-10-29 12:01:43 +03:00
753cb5ba0a feat(web): профиль студента и ростер группы для преподавателя 2026-10-29 12:01:43 +03:00
fa097518f8 feat(progress): профиль студента курса и скачивание попыток 2026-10-29 12:01:42 +03:00
a970fe62c4 feat(academic): состав групп курса для преподавателя 2026-10-29 12:01:42 +03:00
c11a308ff8 feat(identity): внутренние API для профиля студента 2026-10-29 12:01:42 +03:00
dd1129fe80 feat(web): кабинет преподавателя и управление преподавателями 2026-10-29 12:01:42 +03:00
8cc658bf97 feat(academic): доступ к курсам и лабораторным по ролям 2026-10-29 12:01:42 +03:00
a8fa566d3c feat(identity): аккаунты преподавателей 2026-10-29 12:01:42 +03:00
6f8add3736 docs: этап 2 — курсы, bootstrap и лабораторные 2026-10-29 12:01:42 +03:00
9a539407f1 feat(web): страницы курсов и привязка лабораторных 2026-10-29 12:01:42 +03:00
0c51bf2d12 feat(academic): курсы, bootstrap и привязка лабораторных 2026-10-29 12:01:42 +03:00
0c68702e8f fix(web): восстановить статус студента в матрице группы 2026-10-29 12:01:42 +03:00
cbd98f55f0 fix(progress): перенести профильную миграцию на V13 после аттестаций 2026-10-29 12:01:42 +03:00
07a223b759 feat(identity): группы-когорты, история и импорт v2 2026-10-29 12:01:42 +03:00
24a92632fd fix(ui): различать готовность защиты и истечение времени 2026-10-29 12:01:42 +03:00
7c45de8d9f feat(ops): обязательный backup БД и файлов перед деплоем 2026-10-29 12:01:42 +03:00
de9535d35c feat(db): миграции групп, курсов, статусов и профиля 2026-10-29 12:01:42 +03:00
cde04487e8 docs: план, ADR и контракты групп и курсов 2026-10-29 12:01:42 +03:00
366 changed files with 35602 additions and 877 deletions

View File

@@ -10,6 +10,7 @@ oldLMS
.env .env
.env.prod .env.prod
node_modules node_modules
e2e
__pycache__ __pycache__
*.pyc *.pyc
*.iml *.iml

View File

@@ -12,3 +12,24 @@ LMS_WEB_PORT=8090
# Домен (используется в deploy-скрипте/nginx). # Домен (используется в deploy-скрипте/nginx).
LMS_DOMAIN=lms.our-place.ru LMS_DOMAIN=lms.our-place.ru
LETSENCRYPT_EMAIL=admin@our-place.ru LETSENCRYPT_EMAIL=admin@our-place.ru
# Резервное копирование (см. deploy/BACKUP.md).
LMS_BACKUP_DIR=/opt/lms/backups
LMS_BACKUP_KEEP_DAYS=14
# Внутренний токен между identity и progress (профиль студента).
# Обязателен в production, задайте длинную случайную строку.
LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string
# Сроки аутентификации и сессии (значения по умолчанию подходят).
# Access JWT: короткий, обновляется refresh-токеном.
LMS_ACCESS_TOKEN_TTL=15m
# Refresh-токен: 7 дней, ротация и отзыв при выходе/смене пароля.
LMS_REFRESH_TOKEN_TTL=7d
# Web-сессия LMS_SESSION: 7 дней sliding, access обновляется автоматически.
LMS_SESSION_TIMEOUT=7d
# Жёсткий потолок сессии: даже при ежедневной активности раз в 30 дней нужен повторный вход.
LMS_SESSION_ABSOLUTE_TIMEOUT=30d
# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой.
LMS_REFRESH_TOKEN_RETENTION=30d
LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'

6
.gitignore vendored
View File

@@ -6,7 +6,13 @@ build/
.env .env
.env.prod .env.prod
/data/ /data/
/backups/
/exports/
/texput.log
node_modules/ node_modules/
.jqwik-database
e2e/test-results/
e2e/playwright-report/
# Локальные сессии Kilo Code — не часть репозитория. # Локальные сессии Kilo Code — не часть репозитория.
.kilo/ .kilo/
# Каталог с боевыми данными и паролями (не коммитить!) # Каталог с боевыми данными и паролями (не коммитить!)

22
.woodpecker/check.yaml Normal file
View File

@@ -0,0 +1,22 @@
# Merge gate: тесты, линтеры и evidence в изолированном Docker-in-Docker.
# Агент с меткой isolation=dedicated-vm работает на отдельной VM; хост-Docker
# сокет в шаг не монтируется.
labels:
isolation: dedicated-vm
when:
- event: [push, pull_request]
steps:
- name: check
image: lms-ci:1
privileged: true
environment:
DOCKER_HOST: unix:///var/run/docker.sock
commands:
- ./scripts/ci-dind.sh
- ./gradlew --no-daemon test ktlintCheck detekt evidenceCheck
concurrency:
limit: 1
group: lms-ci

26
.woodpecker/release.yaml Normal file
View File

@@ -0,0 +1,26 @@
# Полный release gate в изолированном Docker-in-Docker-шаге на отдельной VM.
# Запускается на тег или вручную. Хост-Docker сокет не монтируется.
labels:
isolation: dedicated-vm
when:
- event: [tag, manual]
steps:
- name: release
image: lms-ci:1
privileged: true
environment:
DOCKER_HOST: unix:///var/run/docker.sock
commands:
# ldd-migration-upgrade.sh сравнивает ветку с master; в CI клон может быть shallow.
- git fetch --unshallow origin || true
- git fetch origin master:refs/heads/master || true
- ./scripts/ci-dind.sh
- cd e2e && npm ci && cd ..
- ./scripts/ci-preflight.sh --release
- ./scripts/ldd-release.sh
concurrency:
limit: 1
group: lms-ci

25
.woodpecker/smoke.yaml Normal file
View File

@@ -0,0 +1,25 @@
# Дымовой тест на каждый push/MR в изолированном Docker-in-Docker-шаге.
# Шаг сам поднимает dockerd внутри себя, поэтому не получает хост-Docker сокет.
labels:
isolation: dedicated-vm
when:
- event: [push, pull_request]
depends_on:
- check
steps:
- name: smoke
image: lms-ci:1
privileged: true
environment:
DOCKER_HOST: unix:///var/run/docker.sock
commands:
- ./scripts/ci-dind.sh
- ./scripts/ci-preflight.sh
- ./scripts/ldd-smoke.sh
concurrency:
limit: 1
group: lms-ci

View File

@@ -24,6 +24,16 @@ UML- и архитектурные диаграммы (Mermaid) — в ката
- `services/gateway` — Spring Cloud Gateway, маршрутизация и проверка JWT. - `services/gateway` — Spring Cloud Gateway, маршрутизация и проверка JWT.
- `web` — серверный веб-интерфейс (Thymeleaf + htmx), BFF. - `web` — серверный веб-интерфейс (Thymeleaf + htmx), BFF.
Ключевое в текущей версии:
- роли `ADMIN/TEACHER/STUDENT`, курсы и группы-когорты, повышение и жизненный
цикл студента, профиль студента курса;
- access JWT 15 минут + refresh 7 дней с ротацией/отзывом; web-сессия 7 дней
sliding с абсолютным потолком 30 дней и автоматическим обновлением access;
- аудит с keyset-пагинацией, пакетная выдача лабораторных и групп курса;
- материалы и активы secure-by-default: файл без статьи доступен только
админу (инвентаризация `/admin/legacy-assets`).
## Требования ## Требования
- JDK 21 (runtime) + Docker (для PostgreSQL 18). Gradle сам скачает toolchain 21 для сборки. - JDK 21 (runtime) + Docker (для PostgreSQL 18). Gradle сам скачает toolchain 21 для сборки.
@@ -52,7 +62,17 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
```bash ```bash
./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты ./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты
./gradlew evidenceCheck # LDD: P0-claims должны иметь зелёные свидетельства
./gradlew ktlintFormat # автоформат Kotlin ./gradlew ktlintFormat # автоформат Kotlin
./scripts/ldd-smoke.sh # быстрый smoke полного стека (как CI workflow smoke)
./scripts/ldd-runtime.sh # runtime-smoke полного стека + scale load
./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы
./scripts/ldd-mutation.sh # PIT: mutation strength критичных P0-компонентов
./scripts/ldd-browser.sh # Playwright E2E: Chromium/Firefox/WebKit
./scripts/ldd-release.sh # полный release gate: test/upgrade/mutation/runtime/E2E
./scripts/ci-preflight.sh --release # проверка окружения runner без установки
./scripts/ci-build-image.sh # прогретый CI-образ lms-ci:1 для Woodpecker
./scripts/legacy-assets.sh # инвентаризация бесхозных файлов на стенде (admin)
``` ```
- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки). - `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки).
@@ -61,6 +81,18 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23 - Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23
не работает на JDK 25. не работает на JDK 25.
- Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`). - Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`).
- **LDD** — реестр проверяемых утверждений `docs/evidence/claims.json`;
P0 без зелёного теста валит `./gradlew evidenceCheck`. Runtime, migration,
mutation, browser E2E и smoke — скрипты из `scripts/`; baseline —
`docs/evidence/baseline-2026-10-27.md`.
- CI merge gate — `.woodpecker/` (Gitea + Woodpecker 3.x):
workflow `check` (`test ktlintCheck detekt evidenceCheck`) и `smoke`
(`scripts/ldd-smoke.sh`) на каждом push/MR; полный `release` —
`scripts/ldd-release.sh` — на тег или вручную. Smoke/release идут на
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **66 claims, 398 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)
@@ -69,21 +101,25 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
```bash ```bash
cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
./deploy/deploy.sh up # сборка + запуск (postgres, 5 сервисов) ./deploy/deploy.sh up # backup + сборка + запуск (postgres, 5 сервисов)
./deploy/deploy.sh logs web ./deploy/deploy.sh logs web
./deploy/deploy.sh ps ./deploy/deploy.sh ps
./deploy/deploy.sh down ./deploy/deploy.sh down
``` ```
Перед обновлением `deploy.sh up` обязательно делает backup БД и файлов.
Ежедневное расписание, retention и восстановление — в [`deploy/BACKUP.md`](deploy/BACKUP.md).
Наружу публикуется только web на `127.0.0.1:8090`; reverse proxy — nginx Наружу публикуется только web на `127.0.0.1:8090`; reverse proxy — nginx
(конфиг и полная инструкция по деплою на `lms.our-place.ru` в (конфиг и полная инструкция по деплою на `lms.our-place.ru` в
[`deploy/DEPLOY.md`](deploy/DEPLOY.md) и `deploy/nginx/lms.our-place.ru.conf`). [`deploy/DEPLOY.md`](deploy/DEPLOY.md) и `deploy/nginx/lms.our-place.ru.conf`).
## Наполнение данными ## Наполнение данными
Порядок ввода данных (всё через админку): `/setup` → администратор; импорт групп Порядок ввода данных (всё через админку): `/setup` → администратор; курс
(`.xls`, логин = студенческий номер, временный пароль со сменой при первом входе); (bootstrap или «+ Новый курс»); импорт групп (`.xls`, логин = студенческий номер,
импорт лабораторной ZIP'ом (`lab.yaml` + `README.md` + `variants/*.md`); «Распределить» временный пароль со сменой при первом входе); импорт лабораторной ZIP'ом с
выбором курса (`lab.yaml` + `README.md` + `variants/*.md`); «Распределить»
— случайная раздача по кругам в рамках уровня; оценки/защита — на странице группы; — случайная раздача по кругам в рамках уровня; оценки/защита — на странице группы;
учебные материалы — статьи или ZIP (`chapters/…`, доступны гостям). учебные материалы — статьи или ZIP (`chapters/…`, доступны гостям).
@@ -95,11 +131,46 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
администратора (`/setup`); далее — вход по логину и паролю. администратора (`/setup`); далее — вход по логину и паролю.
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД), - **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах, проверка токена на gateway, роли ADMIN/TEACHER/STUDENT, CSRF на всех POST-формах,
session fixation-защита; вход студента с обязательной сменой пароля. session fixation-защита; вход студента с обязательной сменой пароля.
- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное Access-токен живёт 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh — 7 дней
добавление студента (генерация пароля) и «Добавить существующего» (перевод из (`LMS_REFRESH_TOKEN_TTL`) с ротацией и отзывом; прямой API использует
другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля. `POST /api/v1/auth/refresh`. Web-сессия `LMS_SESSION` — 7 дней sliding
(`LMS_SESSION_TIMEOUT`), access обновляется автоматически за 2 минуты до
истечения; logout и смена пароля отзывают refresh.
- **Группы и студенты**: группы-когорты (поток, курс, номер, степень, год набора),
импорт `.xls/.xlsx/.csv` по шаблону `поток/курс/номер/степень/год набора/ФИО/№/база`,
ручное добавление студента (генерация пароля) и «Добавить существующего»
(перевод из другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV,
сброс пароля, история членства.
- **Курсы**: админ-страницы курсов (`/admin/courses`), номер `course_no` для URL,
назначение преподавателей и привязка групп с историей; bootstrap переносит
существующие лабораторные, главы и активные группы в курс «Промышленное
программирование на языке C». Лабораторные привязаны к курсу: создание
и импорт требуют выбора курса, лабу можно скопировать в другой курс.
- **Преподаватели и доступ**: `/admin/teachers` создаёт аккаунты `TEACHER`
с одноразовым паролем; преподаватель видит только назначенные курсы
(`/teacher`), их группы, студентов и профили. Курсы и лабораторные
фильтруются по роли, внутренние вызовы identity защищены `LMS_INTERNAL_TOKEN`.
- **Профиль студента курса**: `/teacher/courses/{courseNo}/students/{studentId}` и админский
маршрут показывают сводку, оценки, комментарии с авторами, защиты и все
попытки сдачи со скачиванием ZIP; студенту API отдаёт только его профиль
и опубликованные данные.
- **Работа преподавателя**: ведомость группы переиспользует
`/admin/groups/{groupId}?courseNo=...` без админских контролов — оценки и
комментарии, публикация, уровни/варианты и раздача, защиты (код/reset),
скачивание попыток и ZIP группы; лабораторные/уровни/варианты своего курса,
привязка/отвязка групп на `/teacher/courses/{courseNo}`, авторские материалы на
`/teacher/courses/{courseNo}/content`. Чужой курс отклоняется на сервисах.
- **Аудит**: чувствительные действия (профили, скачивания, оценки, защиты,
курсы, группы, материалы, преподаватели, жизненный цикл) пишутся в
`identity.audit_events`; админ смотрит `/admin/audit` с фильтром по действию,
временем МСК и постраничным переходом «Показать ещё» (`page`/`limit`).
- **Повышение и жизненный цикл**: `/admin/promotion` — dry-run, применение
и откат повышения (автоматический dry-run 31 августа МСК); на странице
группы — академотпуск, возврат, отчисление, архив и восстановление;
архив группы и подтверждение удаления логином/ФИО. Вход закрыт для
выпущенных и архивных групп.
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` + - **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` +
`assets/`); создание через UI с произвольным числом уровней; единая форма `assets/`); создание через UI с произвольным числом уровней; единая форма
настроек с одной кнопкой «Сохранить всё»; статусы draft/published настроек с одной кнопкой «Сохранить всё»; статусы draft/published
@@ -119,6 +190,9 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP
(`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/ (`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/
удаление глав, разделов, статей; перенос статей между разделами. удаление глав, разделов, статей; перенос статей между разделами.
Видимость картинок наследуется от статьи; файл без привязки к статье отдаёт
`404` обычным пользователям и доступен только админу, который получает
список таких файлов через `GET /api/v1/content/legacy-assets`.
- **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка), - **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка),
карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача
файлов (zip/.c, переотправка до дедлайна, история, скачивание последней), файлов (zip/.c, переотправка до дедлайна, история, скачивание последней),

35
build.gradle.kts Normal file
View File

@@ -0,0 +1,35 @@
import lms.evidence.EvidenceCheckTask
/**
* LDD evidence gate: каждое критичное утверждение из docs/evidence/claims.json
* должно ссылаться на реально прошедший тест. P0-утверждение без свидетельства
* валит сборку; P1-пробелы показываются в отчёте.
*
* Запуск: ./gradlew evidenceCheck
*/
val evidenceTestTasks =
listOf(
":libs:markdown:test",
":services:gateway:test",
":services:identity-service:test",
":services:academic-service:test",
":services:progress-service:test",
":web:test",
)
tasks.register<EvidenceCheckTask>("evidenceCheck") {
group = "verification"
description = "LDD: проверяет, что claims имеют актуальные зелёные свидетельства"
dependsOn(evidenceTestTasks)
claimsFile.set(layout.projectDirectory.file("docs/evidence/claims.json"))
runtimeFiles.from(
fileTree("build/evidence") {
include("*.json")
},
)
evidenceTestTasks.forEach { taskPath ->
val projectPath = taskPath.substringBeforeLast(':')
testResults.from(project(projectPath).tasks.named("test"))
}
reportFile.set(layout.buildDirectory.file("reports/evidence/index.md"))
}

View File

@@ -0,0 +1,171 @@
package lms.evidence
import groovy.json.JsonSlurper
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.time.Instant
import javax.xml.parsers.DocumentBuilderFactory
/**
* LDD evidence gate: проверяет, что каждое активное P0-утверждение имеет
* зелёное JUnit-свидетельство, а P1-пробелы видны в отчёте.
*/
abstract class EvidenceCheckTask : DefaultTask() {
@get:InputFile
abstract val claimsFile: RegularFileProperty
@get:InputFiles
abstract val runtimeFiles: ConfigurableFileCollection
@get:InputFiles
abstract val testResults: ConfigurableFileCollection
@get:OutputFile
abstract val reportFile: RegularFileProperty
@Suppress("UNCHECKED_CAST")
@TaskAction
fun check() {
val claims = parseClaims(claimsFile.get().asFile)
if (claims.isEmpty()) throw GradleException("LDD: claims.json не содержит claims")
data class Result(val key: String, val passed: Boolean)
val xmlFiles =
testResults.files.flatMap { candidate ->
if (candidate.isDirectory) {
candidate
.walkTopDown()
.filter { it.isFile && it.name.startsWith("TEST-") && it.extension == "xml" }
.toList()
} else {
listOf(candidate).filter { it.isFile && it.name.startsWith("TEST-") && it.extension == "xml" }
}
}
val results = mutableMapOf<String, Result>()
for (xmlFile in xmlFiles) {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile)
val testCases = document.getElementsByTagName("testcase")
for (index in 0 until testCases.length) {
val node = testCases.item(index) as org.w3c.dom.Element
val key = "${node.getAttribute("classname")}#${node.getAttribute("name")}"
val broken =
node.getElementsByTagName("failure").length > 0 ||
node.getElementsByTagName("error").length > 0 ||
node.getElementsByTagName("skipped").length > 0
results[key] = Result(key, !broken)
}
}
val runtimeChecks = parseRuntimeChecks(runtimeFiles.files)
val ids = HashSet<String>()
val gaps = mutableListOf<String>()
val p0Gaps = mutableListOf<String>()
val rows = mutableListOf<String>()
for (claim in claims) {
val id = claim["id"]?.toString().orEmpty().ifBlank { "?" }
if (!ids.add(id)) throw GradleException("LDD: дублирующийся claim id $id")
val criticality = claim["criticality"]?.toString().orEmpty().ifBlank { "P1" }
val status = claim["status"]?.toString().orEmpty().ifBlank { "active" }
val title = claim["title"]?.toString().orEmpty()
val evidence = stringList(claim["evidence"])
val runtime = stringList(claim["runtime"])
val missingTests = evidence.filter { results[it]?.passed != true }
val failedTests = evidence.filter { results[it]?.passed == false }
val runtimeMissing =
runtime.filter { idValue ->
val state = runtimeChecks[idValue]
state == null || state != "passed"
}
val proven =
status == "active" &&
(evidence.isNotEmpty() || runtime.isNotEmpty()) &&
missingTests.isEmpty() &&
failedTests.isEmpty() &&
(runtime.isEmpty() || runtimeMissing.isEmpty())
val reasons =
buildList {
if (status != "active") add("status=$status")
if (evidence.isEmpty() && runtime.isEmpty()) add("нет свидетельств")
missingTests.forEach { add("не найден/не прошёл: $it") }
failedTests.forEach { add("упал: $it") }
if (runtime.isNotEmpty() && runtimeChecks.isEmpty()) add("runtime не запускался")
runtimeMissing.forEach { add("runtime не прошёл: $it") }
}
if (!proven && status == "active" && criticality == "P0") {
p0Gaps += "$id: ${reasons.joinToString("; ")}"
}
if (!proven) gaps += "$id ($criticality): ${reasons.joinToString("; ")}"
rows +=
"| $id | $criticality | ${if (proven) "✅ proven" else "⚠️ gap"} | $title | " +
"${evidence.size} tests${if (runtime.isNotEmpty()) ", ${runtime.size} runtime" else ""} |"
}
reportFile.get().asFile.parentFile.mkdirs()
val report =
buildString {
appendLine("# LDD evidence report")
appendLine()
appendLine("Сформирован: ${Instant.now()}")
appendLine("Утверждений: ${claims.size}; proven: ${claims.size - gaps.size}; gaps: ${gaps.size}.")
appendLine()
appendLine("| Claim | Уровень | Статус | Формулировка | Свидетельства |")
appendLine("|---|---|---|---|---|")
rows.forEach { appendLine(it) }
appendLine()
if (gaps.isEmpty()) {
appendLine("Все активные claims подтверждены.")
} else {
appendLine("## Пробелы")
appendLine()
gaps.forEach { appendLine("- $it") }
}
appendLine()
appendLine("P0 без свидетельств: ${p0Gaps.size}.")
}
reportFile.get().asFile.writeText(report)
println(
"LDD evidence: claims=${claims.size}, proven=${claims.size - gaps.size}, " +
"gaps=${gaps.size}, p0Gaps=${p0Gaps.size}",
)
println("LDD report: ${reportFile.get().asFile.path}")
if (p0Gaps.isNotEmpty()) {
throw GradleException(
"LDD P0 evidence gate failed:\n" + p0Gaps.joinToString("\n") { " - $it" },
)
}
}
@Suppress("UNCHECKED_CAST")
private fun parseClaims(file: java.io.File): List<Map<String, Any?>> {
if (!file.isFile) throw GradleException("LDD: не найден ${file.path}")
val root = JsonSlurper().parse(file) as Map<String, Any?>
return (root["claims"] as? List<Map<String, Any?>>).orEmpty()
}
@Suppress("UNCHECKED_CAST")
private fun parseRuntimeChecks(files: Set<java.io.File>): Map<String, String> {
val checks = mutableMapOf<String, String>()
for (file in files.filter { it.isFile }) {
val root = JsonSlurper().parse(file) as Map<String, Any?>
val list = (root["checks"] as? List<Map<String, Any?>>).orEmpty()
for (check in list) {
val id = check["id"]?.toString().orEmpty()
if (id.isNotEmpty()) checks[id] = check["status"]?.toString().orEmpty()
}
}
return checks
}
private fun stringList(value: Any?): List<String> =
(value as? List<*>).orEmpty().mapNotNull { it?.toString()?.trim()?.takeIf(String::isNotEmpty) }.distinct()
}

24
ci/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Предварительно прогретый CI-образ для Woodpecker.
# Сборка на CI VM (доверенный администратором коммит):
# ./scripts/ci-build-image.sh lms-ci:1
# Образ содержит только зависимости; исходники в пайплайне берутся из checkout.
FROM docker:27-dind
RUN apk add --no-cache openjdk21-jdk python3 bash
WORKDIR /opt/lms-warmup
COPY gradle gradle
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
COPY buildSrc buildSrc
COPY config config
COPY libs libs
COPY services services
COPY web web
# Прогреваем Gradle-кэш и плагины. Это ускоряет check/smoke/release,
# при этом кэш остаётся внутри неизменяемого образа, а не в общем volume.
RUN ./gradlew --no-daemon testClasses --console=plain
# Исходники нужны были только для прогрева кэша; в образе их не оставляем.
RUN rm -rf /opt/lms-warmup

114
deploy/BACKUP.md Normal file
View File

@@ -0,0 +1,114 @@
# Резервное копирование LMS
Резервное копирование обязательно:
- **перед каждым обновлением** (`./deploy/deploy.sh up` делает backup автоматически);
- **ежедневно** по расписанию (systemd timer);
- перед миграциями и любыми ручными изменениями схемы/данных.
## Что входит в backup
Один каталог `backups/lms-<UTC timestamp>/`:
| Файл | Содержимое |
|---|---|
| `db.dump` | `pg_dump --format=custom` всей БД `lms` |
| `submissions.tar.gz` | volume `lms_lms_submissions` — решения студентов |
| `assets.tar.gz` | volume `lms_lms_assets` — файлы материалов |
| `SHA256SUMS` | контрольные суммы |
| `manifest.txt` | дата, БД, пользователь, проект, retention |
Каталоги бэкапов создаются с правами `700`: внутри персональные данные.
## Настройки
В `.env.prod`:
```dotenv
LMS_BACKUP_DIR=/opt/lms/backups
LMS_BACKUP_KEEP_DAYS=14
```
`LMS_BACKUP_KEEP_DAYS` — сколько дней хранить. `0` отключает автоудаление.
## Ручные команды
```bash
./deploy/backup.sh backup # создать backup
./deploy/backup.sh backup --if-running # пропустить, если стек не запущен
./deploy/backup.sh list # список backup-каталогов
./deploy/backup.sh verify <dir> # проверить суммы и целостность
./deploy/backup.sh restore <dir> # восстановить (destructive)
```
`restore` требует ввести `RESTORE`, останавливает прикладные сервисы,
пересоздаёт БД, восстанавливает файлы и поднимает стек. PostgreSQL при этом
не удаляется.
## Автоматически перед обновлением
`deploy/deploy.sh up` вызывает:
```bash
./deploy/backup.sh backup --if-running
```
Если стек ещё не запущен (первый деплой), шаг пропускается. Если backup
завершился ошибкой — деплой прерывается.
## Ежедневное расписание (systemd)
```bash
sudo cp deploy/systemd/lms-backup.service /etc/systemd/system/
sudo cp deploy/systemd/lms-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lms-backup.timer
systemctl list-timers lms-backup.timer
```
По умолчанию — каждый день в 03:00 с разбросом до 30 минут. `Persistent=true`
означает, что пропущенный запуск выполнится после включения сервера.
Если systemd недоступен, cron:
```cron
0 3 * * * cd /opt/lms && ./deploy/backup.sh backup >>/var/log/lms-backup.log 2>&1
```
## Восстановление
```bash
./deploy/backup.sh verify /opt/lms/backups/lms-20261023T030000Z
./deploy/backup.sh restore /opt/lms/backups/lms-20261023T030000Z
```
Проверка после восстановления:
```bash
./deploy/deploy.sh ps
curl -fsS http://127.0.0.1:8090/actuator/health
```
## Внешняя копия
Backup на том же сервере не защищает от потери сервера. Настройте выгрузку:
```bash
rsync -a --delete /opt/lms/backups/ backup-host:/srv/lms-backups/
```
Требования:
- шифрование при передаче и хранении (backups содержат ПДн);
- отдельный хост/хранилище;
- проверка восстановления не реже раза в месяц на staging;
- не хранить backup в git и в каталоге проекта без `.gitignore`.
## Проверка
Минимум раз в месяц:
1. `./deploy/backup.sh verify <dir>`.
2. Развернуть копию на staging.
3. Восстановить БД и файлы.
4. Проверить вход, группу, лабу, сдачу и скачивание файла.

View File

@@ -47,7 +47,36 @@ cp .env.prod.example .env.prod
nano .env.prod nano .env.prod
``` ```
Обязательно задайте надёжный `LMS_DB_PASSWORD`. Остальные значения по умолчанию подходят. Обязательно задайте `LMS_DB_PASSWORD` и `LMS_INTERNAL_TOKEN`. Остальные
значения по умолчанию подходят.
`LMS_INTERNAL_TOKEN` — общий секрет для внутренних вызовов identity из
academic/progress (профиль студента, состав группы). Сгенерируйте длинную
случайную строку (например, `openssl rand -hex 32`) и укажите её в `.env.prod`.
Compose передаёт одно и то же значение identity, academic и progress.
Значение по умолчанию `dev-internal-token` для production не используйте;
`.env.prod` не коммитьте.
Сроки аутентификации и сессии (можно не задавать — действуют значения ниже):
```dotenv
LMS_ACCESS_TOKEN_TTL=15m # access JWT identity, короткий
LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и отзыв
LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding
LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии
LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh
LMS_REFRESH_TOKEN_CLEANUP_CRON='0 30 3 * * *'
```
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`,
`LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION` и
`LMS_REFRESH_TOKEN_CLEANUP_CRON` читает identity; `LMS_SESSION_TIMEOUT` и
`LMS_SESSION_ABSOLUTE_TIMEOUT` — web. `docker-compose.prod.yml` прокидывает эти
переменные в environment соответствующих сервисов; при переопределении
используются значения из `.env.prod`. Access
обновляется автоматически за 2 минуты до истечения; прямой API-клиент должен
сам вызывать `POST /api/v1/auth/refresh`. Уменьшайте значения только осознанно:
слишком короткая web-сессия заставит пользователей чаще входить заново.
## 4. Собрать и запустить ## 4. Собрать и запустить
@@ -96,24 +125,74 @@ sudo certbot --nginx -d lms.our-place.ru --redirect -m admin@our-place.ru --agre
```bash ```bash
cd /opt/lms cd /opt/lms
git pull git pull
./deploy/deploy.sh up # пересоберёт изменённые образы и перезапустит ./deploy/deploy.sh up # backup → сборка → миграции → перезапуск
``` ```
Миграции БД (Flyway) применяются автоматически при старте сервисов. `deploy.sh up` **обязательно** делает backup перед обновлением. Если backup
завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются
автоматически при старте сервисов: identity `V4–V14` (`V10` создаёт
`identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` —
`absolute_expires_at`, `V13` — keyset-индексы аудита, `V14` — финальные
NOT NULL/unique ограничения когорты), academic `V6–V12`, progress `V13`.
Перед первым деплоем с legacy-данными проверьте, что у всех групп разобрана
когорта (V5 заполняет её из имени `ПОТОК-КУРСНОМЕРСТЕПЕНЬ`):
```bash
docker compose exec -T postgres psql -U lms -d lms -c "
SELECT id, name, stream, degree, course, number, enrollment_year
FROM identity.groups
WHERE stream IS NULL OR degree IS NULL OR course IS NULL
OR number IS NULL OR enrollment_year IS NULL;"
```
Если строки есть, заполните поля вручную; иначе `V14` намеренно остановит
миграцию с понятной ошибкой. Возврат из архива доступен на странице группы.
После обновления проверьте, что админ открывает `/admin/audit` и видит события
(например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и
студент на эту страницу не попадают. Журнал листается keyset-курсором
(`cursor`/`limit`, «Показать ещё»; offset `page` остаётся для совместимости).
Для инвентаризации старых файлов админ открывает `/admin/legacy-assets`,
вызывает `GET /api/v1/content/legacy-assets` или запускает
`scripts/legacy-assets.sh`: файлы без привязки к статье больше не публичны
(`404` для обычных пользователей), их нужно привязать или удалить.
CI (`.woodpecker/`, Gitea + Woodpecker 3.x) на каждом push/MR выполняет
workflow `check` (`./gradlew --no-daemon test ktlintCheck detekt evidenceCheck`)
и `smoke` (`./scripts/ldd-smoke.sh`: полный стек, health, вход админа, один
курс). Полный workflow `release` (`./scripts/ldd-release.sh`) обязателен для
тегов и запускается вручную для веток. Smoke/release начинаются с
`scripts/ci-preflight.sh`, который проверяет Java 21, Docker Compose v2,
Python 3, Node и Playwright. Агент — Docker backend на отдельной CI VM,
шаги — в nested dind без хост-Docker сокета; установка и модель изоляции —
[`WOODPECKER.md`](WOODPECKER.md).
## 8. Резервное копирование ## 8. Резервное копирование
```bash Полная инструкция — [`BACKUP.md`](BACKUP.md). Кратко:
# БД
docker compose -f docker-compose.prod.yml --env-file .env.prod exec -T postgres \
pg_dump -U "$LMS_DB_USER" "$LMS_DB_NAME" | gzip > lms-$(date +%F).sql.gz
# Файлы (решения и материалы) ```bash
docker run --rm -v lms_lms_submissions:/sub -v lms_lms_assets:/assets -v "$PWD":/backup alpine \ ./deploy/backup.sh backup # БД + решения + материалы
tar czf /backup/lms-files-$(date +%F).tar.gz -C / sub assets ./deploy/backup.sh list
./deploy/backup.sh verify <dir>
./deploy/backup.sh restore <dir> # destructive, требует ввода RESTORE
``` ```
Восстановление БД: `gunzip -c backup.sql.gz | docker compose ... exec -T postgres psql -U lms lms`. Backup включает `pg_dump` БД, volume решений и volume материалов, контрольные
суммы и manifest. По умолчанию хранится 14 дней (`LMS_BACKUP_KEEP_DAYS`),
каталог — `/opt/lms/backups` (`LMS_BACKUP_DIR`).
Ежедневное расписание:
```bash
sudo cp deploy/systemd/lms-backup.service deploy/systemd/lms-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lms-backup.timer
```
Backup на том же сервере — не единственная защита: настройте выгрузку на
отдельный хост и периодически проверяйте восстановление на staging.
## 9. Диагностика ## 9. Диагностика
@@ -134,6 +213,9 @@ docker run --rm -v lms_lms_submissions:/sub -v lms_lms_assets:/assets -v "$PWD":
JWKS: должно быть `LMS_JWT_JWK_SET_URI=http://identity:8081/api/v1/auth/jwks.json` JWKS: должно быть `LMS_JWT_JWK_SET_URI=http://identity:8081/api/v1/auth/jwks.json`
(уже прописано в `docker-compose.prod.yml` для academic/progress/gateway). (уже прописано в `docker-compose.prod.yml` для academic/progress/gateway).
Внутри контейнера `localhost:8081` недоступен, поэтому токен не проверяется. Внутри контейнера `localhost:8081` недоступен, поэтому токен не проверяется.
- **403/502 в профиле студента или составе группы** — проверьте, что
`LMS_INTERNAL_TOKEN` задан в `.env.prod` и одинаков у identity, academic
и progress. При неверном внутреннем токене identity отвечает `403`.
- **Не проходят загрузки больших ZIP** — увеличьте `client_max_body_size` в nginx - **Не проходят загрузки больших ZIP** — увеличьте `client_max_body_size` в nginx
(сейчас 120 МБ) и `spring.servlet.multipart.max-request-size`. (сейчас 120 МБ) и `spring.servlet.multipart.max-request-size`.

View File

@@ -1,7 +1,7 @@
# Порядок наполнения LMS данными (через интерфейс) # Порядок наполнения LMS данными (через интерфейс)
Всё делается в админке, порядок: администратор → группы и студенты → лабораторная → Всё делается в админке, порядок: администратор → курс (bootstrap) → группы
раздача вариантов → защита → материалы. и студенты → лабораторная → раздача вариантов → защита → материалы.
## Шаг 1. Деплой ## Шаг 1. Деплой
@@ -9,24 +9,54 @@
откройте домен — попадёте на `/setup`, создайте администратора (логин и пароль откройте домен — попадёте на `/setup`, создайте администратора (логин и пароль
храните в секрете, в репозиторий их не писать). храните в секрете, в репозиторий их не писать).
## Шаг 2. Группы и студенты ## Шаг 2. Курс
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы Откройте **Курсы** (`/admin/courses`). Все лабораторные принадлежат курсу,
(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»). поэтому курс должен существовать до импорта лабораторных.
Название группы берётся из файла или указывается при импорте. Логином студента
становится студенческий номер, пароль генерируется (временный, с обязательной * **Существующая база:** если курсов ещё нет, нажмите «Создать курс и перенести
сменой при первом входе). данные». Bootstrap создаст курс «Промышленное программирование на языке C»,
привяжет к нему все активные группы и перенесёт существующие лабораторные
и главы. Повторный запуск безопасен и не создаёт дублей.
* **Новая база:** создайте курс кнопкой «+ Новый курс» (номер присвоится
автоматически) либо тем же bootstrap.
* После импорта групп (шаг 3) привяжите их к курсу на странице курса, если их
не было в момент bootstrap: блок «Привязать группу». Там же назначаются
преподаватели — по UUID аккаунта со страницы «Пользователи».
Архивированный курс нельзя выбрать для новой лабораторной или копирования.
Без курса создание и импорт лабораторной недоступны: в форме нужно выбрать курс.
## Шаг 3. Группы и студенты
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы.
Обязательные столбцы:
```text
поток | курс | номер | степень | год набора | ФИО | № | база
```
Все строки файла должны относиться к одной группе. Группа создаётся по полям
(имя собирается автоматически, например `ПИ-11БО`). Логином студента становится
номер (`№`), пароль генерируется (временный, с обязательной сменой при первом
входе). Ключ слияния — `student_no`: повторный импорт обновляет студента, а не
дублирует.
Импорт атомарный: при ошибке в любой строке данные не меняются, а в интерфейсе
показывается отчёт с номерами строк.
Дальше по каждому студенту при необходимости: Дальше по каждому студенту при необходимости:
* **«Сбросить пароль»** — выдать новый временный пароль; * **«Сбросить пароль»** — выдать новый временный пароль;
* **«Добавить существующего»** — перевести студента из другой группы; * **«Добавить существующего»** — перевести студента из другой группы;
* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются). * **«Отчислить из группы»** — убрать из группы, студент получает статус
«отчислен»; аккаунт, сдачи и оценки сохраняются.
Выгрузить логины: **«Логины XLSX» / «CSV»**. Выгрузить логины: **«Логины XLSX» / «CSV»**.
## Шаг 3. Лабораторная ## Шаг 4. Лабораторная
**Лабораторные → Импорт** — ZIP со структурой: **Лабораторные → Импорт** — сначала выберите **курс** (обязательно), затем
загрузите ZIP со структурой:
```text ```text
lab.yaml # номер, название, points_max, уровни (code/title), сроки, статус lab.yaml # номер, название, points_max, уровни (code/title), сроки, статус
@@ -38,9 +68,12 @@ variants/01.md # вариант: front-matter (level, number, title) + тек
странице лабораторной: странице лабораторной:
* «Настроить» — срок сдачи, дата защиты, максимум баллов, статус; * «Настроить» — срок сдачи, дата защиты, максимум баллов, статус;
* уровни/варианты можно править и добавлять прямо там же. * уровни/варианты можно править и добавлять прямо там же;
* «Копировать в курс» — создать копию лабораторной (с уровнями и вариантами)
в другом курсе; номер и название можно изменить. Прогресс студентов не
копируется, копия создаётся черновиком.
## Шаг 4. Раздача вариантов ## Шаг 5. Раздача вариантов
На странице лабораторной — **«Распределить»**: выбрать группы, при необходимости На странице лабораторной — **«Распределить»**: выбрать группы, при необходимости
«пересоздать заново». Логика: случайная очередь по всем вариантам выбранного «пересоздать заново». Логика: случайная очередь по всем вариантам выбранного
@@ -48,7 +81,7 @@ variants/01.md # вариант: front-matter (level, number, title) + тек
вручную не трогаются. Частично/точно — в списке назначений (уровень и вариант по вручную не трогаются. Частично/точно — в списке назначений (уровень и вариант по
каждому студенту) или массово чекбоксами. каждому студенту) или массово чекбоксами.
## Шаг 5. Оценки и защита ## Шаг 6. Оценки и защита
На странице группы (матрица): На странице группы (матрица):
@@ -58,7 +91,7 @@ variants/01.md # вариант: front-matter (level, number, title) + тек
(`студент;ЛР;задание` или `студент;задание` при выбранной ЛР), «выдать код», (`студент;ЛР;задание` или `студент;задание` при выбранной ЛР), «выдать код»,
«изменить», «текст», после готовности — «доп. задание: начать заново». «изменить», «текст», после готовности — «доп. задание: начать заново».
## Шаг 6. Учебные материалы ## Шаг 7. Учебные материалы
**Лабораторные → материалы (`/learn`)** доступны гостям. Для наполнения: **Лабораторные → материалы (`/learn`)** доступны гостям. Для наполнения:
создание глав/разделов/статей в админке или **импорт ZIP** со структурой: создание глав/разделов/статей в админке или **импорт ZIP** со структурой:
@@ -68,7 +101,9 @@ chapters/01-глава/01-раздел/01-статья.md # front-matter: titl
assets/ # картинки для md assets/ # картинки для md
``` ```
Материалы видны всем; для редактирования — «Материалы» с правами администратора. Материалы видны всем; глобальные материалы редактирует только администратор
(«Материалы»). Преподаватель редактирует материалы своего курса на
`/teacher/courses/{courseNo}/content`.
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве» Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и (синхронизация — архив считается источником истины) и без неё (merge — только добавить и

204
deploy/WOODPECKER.md Normal file
View File

@@ -0,0 +1,204 @@
# CI на Gitea + Woodpecker: изолированный контур
Репозиторий использует **Woodpecker CI 3.x**. Конфигурация — в `.woodpecker/`:
| Workflow | Событие | Что делает |
|---|---|---|
| `check` | push, pull_request | тесты, ktlint, detekt, `evidenceCheck` |
| `smoke` | push, pull_request | после `check`: полный стек, health, вход админа, один курс |
| `release` | tag, manual | миграции, mutation, load, Chromium/Firefox/WebKit E2E, финальный evidence |
## 1. Модель изоляции
CI **не должен работать на хосте LMS и не должен иметь доступ к production**.
Разработчики не считаются доверенными: пайплайн может содержать произвольный
код, включая `privileged`-контейнеры.
Схема:
```
Gitea + Woodpecker server отдельная CI VM
┌───────────────────────┐ ┌──────────────────────────────┐
│ Gitea (production) │ webhook │ Woodpecker agent (Docker) │
│ Woodpecker server │◄────────►│ └─ pipeline step │
│ │ gRPC │ └─ nested dockerd (dind)│
└───────────────────────┘ │ ├─ postgres │
│ ├─ LMS-сервисы │
│ └─ Playwright │
└──────────────────────────────┘
нет prod-сети и секретов
```
Правила:
- **Отдельная VM** (или отдельный физический хост) для агента. Не запускайте
агент на сервере LMS/Gitea.
- На VM **нет** production-БД, production-томов, deploy-ключей и
`LMS_INTERNAL_TOKEN`/паролей production. Только одноразовые тестовые значения,
которые создаёт сам пайплайн.
- Сетевой firewall: VM может ходить в Gitea/Woodpecker и к registry/пакетным
зеркалам; **не** может в production-подсеть и к production-БД.
- VM должна пересоздаваться из чистого снапшота после релиза или по расписанию.
Всё, что осталось на диске, считается недоверенным.
- Пайплайн выполняется в образе `lms-ci:1` (на базе `docker:27-dind`) с
**собственным nested dockerd**.
Хост-Docker сокет в шаг не монтируется. `privileged` нужен только этому
вложенному демону; на отдельной VM это допустимо.
- Агент помечен `isolation=dedicated-vm`; workflows требуют эту метку. Это
не даёт им случайно попасть на агент, стоящий рядом с production.
- В Woodpecker не должно быть production-secrets. Если позже понадобится
деплой — отдельный защищённый агент/пайплайн с ручным подтверждением, не
этот.
> Важно: `privileged`-шаг может скомпрометировать **VM**, но не production.
> Именно поэтому VM обязательна. Не ослабляйте это правило.
## 2. Требования к CI VM
- Docker Engine + Compose v2.
- 4+ CPU, 8+ GB RAM, 40+ GB диска (Gradle, Docker-образы, браузеры).
- Исходящий доступ к Gitea, registry и пакетным зеркалам.
- Нет входящих портов, кроме SSH для администрирования.
- Нет монтирования production-томов и нет production-сетей.
## 3. Woodpecker server
Server можно держать рядом с Gitea (он не выполняет код пайплайнов). Пример
`docker-compose.woodpecker.yml` (секреты не коммитить):
```yaml
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000" # UI за nginx
- "127.0.0.1:9000:9000" # gRPC для агента
environment:
WOODPECKER_OPEN: "false"
WOODPECKER_HOST: ${WOODPECKER_HOST:?https://ci.example.ru}
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: ${GITEA_URL:?https://git.example.ru}
WOODPECKER_GITEA_CLIENT: ${GITEA_CLIENT:?}
WOODPECKER_GITEA_SECRET: ${GITEA_SECRET:?}
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:?}
WOODPECKER_ADMIN: ${WOODPECKER_ADMIN:?admin}
volumes:
- woodpecker-data:/var/lib/woodpecker
volumes:
woodpecker-data:
```
1. В Gitea создайте OAuth2-приложение:
redirect URI `https://ci.example.ru/authorize`.
2. Заполните `GITEA_CLIENT`, `GITEA_SECRET`, `WOODPECKER_AGENT_SECRET`
(например, `openssl rand -hex 32`), `WOODPECKER_HOST`, `GITEA_URL`.
3. Если Gitea и Woodpecker на одном хосте, разрешите webhook на loopback в
`/etc/gitea/conf/app.ini`:
```ini
[webhook]
ALLOWED_HOST_LIST = external,loopback
```
Проверьте, что Gitea отдаёт API-страницы не меньше 50 элементов.
4. Поднимите server и зайдите в UI через Gitea-аккаунт.
5. Включите репозиторий LMS в Woodpecker (Settings → Repositories).
6. В настройках репозитория включите **Trusted** (нужно для `privileged` и
volumes). Доверять можно только этому приватному репозиторию.
## 4. Агент на отдельной VM
Агент — в Docker, backend `docker`, с доступом к Docker daemon **этой VM**.
Шаги пайплайна получают не хостовый сокет, а собственный nested dockerd.
`docker-compose.agent.yml` на CI VM:
```yaml
services:
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
restart: unless-stopped
environment:
WOODPECKER_SERVER: ${WOODPECKER_SERVER:?https://ci.example.ru:9000}
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:?}
WOODPECKER_BACKEND: docker
WOODPECKER_MAX_WORKFLOWS: "1"
# Только этот агент должен брать workflows с этой меткой.
WOODPECKER_AGENT_LABELS: isolation=dedicated-vm
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
```bash
docker compose -f docker-compose.agent.yml up -d
docker compose -f docker-compose.agent.yml logs -f
```
Дополнительно в UI откройте **Settings → Agents** и задайте агенту фильтр
`isolation=dedicated-vm` — это серверная проверка, которую агент не может
подделать.
**Общего кэша между пайплайнами нет.** Каждый шаг получает чистый контейнер,
а вложенный Docker storage живёт только внутри него. Это важно: разработчик не
может оставить артефакт, который повлияет на следующий pipeline или релиз.
Пайплайны используют предварительно прогретый образ `lms-ci:1`. Соберите его
на VM из **проверенного администратором** коммита до первого запуска:
```bash
git checkout <проверенный-коммит>
./scripts/ci-build-image.sh lms-ci:1
```
Сборка образа выполняет Gradle-код из репозитория. Не собирайте образ из
непроверенной ветки/PR: это запуск недоверенного кода на CI VM.
Образ содержит Docker-in-Docker, Java 21, Python, bash, Node и прогретый
Gradle-кэш; исходники всё равно берутся из checkout. Пересобирайте образ при
изменении зависимостей или базовых версий. Не монтируйте в шаги общие
writable-кэши: это канал влияния одного пайплайна на другой.
## 5. Как устроены workflows
- `check`, `smoke` и `release` требуют `labels.isolation=dedicated-vm`.
- `smoke` ждёт успешного `check` (`depends_on`).
- Все workflows имеют `concurrency: {limit: 1, group: lms-ci}`, потому что
smoke/release поднимают стек на портах 8080–8090.
- `check` и `smoke`/`release` используют `lms-ci:1` + `./scripts/ci-dind.sh`:
скрипт поднимает **вложенный** dockerd. Образ собирается из `ci/Dockerfile`.
- `release` перед миграционным тестом делает `git fetch` master: CI-клон может
быть shallow.
- `smoke`/`release` не получают хост-Docker сокет; `docker compose` работает
с вложенным демоном.
## 6. Первый прогон
0. На CI VM соберите образ: `./scripts/ci-build-image.sh lms-ci:1`.
1. Push в ветку или MR → `check`, затем `smoke`.
2. Убедитесь, что оба workflow зелёные и выполнялись на агенте с меткой
`isolation=dedicated-vm`.
3. Проверьте ручной запуск: UI → Run pipeline → event `manual` → workflow
`release`.
4. Для релиза: `git tag vX.Y.Z && git push origin vX.Y.Z` → `release`.
5. После релиза/по расписанию пересоздайте VM из чистого снапшота.
Если pipeline не появляется:
- репозиторий включён в Woodpecker;
- у агента и server совпадает `WOODPECKER_AGENT_SECRET`;
- агент видит Docker: `docker compose version` на VM;
- метка агента `isolation=dedicated-vm` и серверный фильтр совпадают;
- путь конфигурации проекта — `.woodpecker/` (по умолчанию);
- образ `lms-ci:1` собран на VM (иначе шаг не стартует);
- репозиторий помечен trusted, иначе `privileged` будет отклонён.
## 7. Чек-лист безопасности
- [ ] Агент не на production-хосте.
- [ ] На VM нет production-секретов, БД и томов.
- [ ] Firewall запрещает VM → production.
- [ ] В Woodpecker нет production-secrets.
- [ ] Включён trusted только для LMS, не для чужих репозиториев.
- [ ] `WOODPECKER_AGENT_SECRET` уникален и хранится вне репозитория.
- [ ] VM пересоздаётся из снапшота; кэш не считается доверенным.
- [ ] `privileged` разрешён только на этой VM, не на общем runner.

294
deploy/backup.sh Executable file
View File

@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# Резервное копирование и восстановление LMS.
#
# ./deploy/backup.sh backup # БД + файлы, с retention
# ./deploy/backup.sh backup --if-running # пропустить, если стек не запущен
# ./deploy/backup.sh list
# ./deploy/backup.sh verify <dir>
# ./deploy/backup.sh restore <dir> # destructive, требует ввода RESTORE
#
# Переменные:
# LMS_BACKUP_DIR каталог бэкапов (по умолчанию ./backups)
# LMS_BACKUP_KEEP_DAYS сколько дней хранить (по умолчанию 14)
# LMS_COMPOSE_PROJECT имя compose-проекта (по умолчанию lms)
set -euo pipefail
cd "$(dirname "$0")/.."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env.prod"
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
die() {
echo "Ошибка: $*" >&2
exit 1
}
info() {
echo ">> $*"
}
usage() {
cat >&2 <<'EOF'
usage:
deploy/backup.sh backup [--if-running]
deploy/backup.sh list
deploy/backup.sh verify-latest
deploy/backup.sh verify <backup-dir>
deploy/backup.sh restore <backup-dir>
Переменные окружения: LMS_BACKUP_DIR, LMS_BACKUP_KEEP_DAYS, LMS_COMPOSE_PROJECT.
EOF
exit 1
}
require_docker() {
command -v docker >/dev/null 2>&1 || die "нужен Docker"
docker compose version >/dev/null 2>&1 || die "нужен Docker Compose v2"
}
load_env() {
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
# Не source-им .env.prod: значения с пробелами (например, cron) иначе
# исполняются как команды, а произвольный shell-код из файла опасен.
local key value line
while IFS= read -r line; do
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
value="${line#*=}"
value="${value%$'\r'}"
value="${value#\"}"; value="${value%\"}"
value="${value#\'\'}"; value="${value%\'\'}"
case "$key" in
LMS_DB_NAME | LMS_DB_USER | LMS_DB_PASSWORD | LMS_WEB_PORT | \
LMS_BACKUP_DIR | LMS_BACKUP_KEEP_DAYS | LMS_COMPOSE_PROJECT)
export "$key=$value"
;;
esac
done <"$ENV_FILE"
: "${LMS_DB_NAME:=lms}"
: "${LMS_DB_USER:=lms}"
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
}
compose() {
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
}
postgres_running() {
compose ps --status running --services 2>/dev/null | grep -qx 'postgres'
}
ensure_volumes() {
local volume
for volume in "${PROJECT}_lms_submissions" "${PROJECT}_lms_assets"; do
docker volume inspect "$volume" >/dev/null 2>&1 || die "не найден volume $volume"
done
}
backup() {
local if_running=0
[[ "${1:-}" == "--if-running" ]] && if_running=1
require_docker
load_env
if ! postgres_running; then
if ((if_running)); then
info "PostgreSQL не запущен — backup пропущен"
return 0
fi
die "PostgreSQL не запущен; запустите стек или используйте --if-running"
fi
ensure_volumes
mkdir -p "$BACKUP_ROOT"
chmod 700 "$BACKUP_ROOT"
# Не даём двум backup-процессам писать одновременно.
exec 9>"$BACKUP_ROOT/.backup.lock"
command -v flock >/dev/null 2>&1 || die "нужен flock (util-linux)"
flock -n 9 || die "уже выполняется другой backup"
local timestamp dir tmp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
dir="$BACKUP_ROOT/lms-$timestamp"
tmp="$dir.tmp"
[[ ! -e "$dir" ]] || die "каталог уже существует: $dir"
rm -rf "$tmp"
mkdir -p "$tmp"
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" EXIT
info "Дамп базы данных..."
compose exec -T postgres sh -c \
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-privileges' \
>"$tmp/db.dump"
[[ -s "$tmp/db.dump" ]] || die "pg_dump вернул пустой файл"
info "Архив файлов (решения и материалы)..."
docker run --rm \
-v "${PROJECT}_lms_submissions:/submissions:ro" \
-v "${PROJECT}_lms_assets:/assets:ro" \
-v "$tmp":/backup \
postgres:18 sh -c \
'tar czf /backup/submissions.tar.gz -C /submissions . && tar czf /backup/assets.tar.gz -C /assets .'
gzip -t "$tmp/submissions.tar.gz"
gzip -t "$tmp/assets.tar.gz"
cat >"$tmp/manifest.txt" <<EOF
backup_version=1
created_at=$timestamp
db_name=$LMS_DB_NAME
db_user=$LMS_DB_USER
compose_project=$PROJECT
keep_days=$KEEP_DAYS
EOF
(
cd "$tmp"
sha256sum db.dump submissions.tar.gz assets.tar.gz >SHA256SUMS
)
chmod 700 "$tmp"
mv "$tmp" "$dir"
trap - EXIT
info "Backup готов: $dir"
du -sh "$dir"/* | sed 's/^/ /'
if [[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] && ((KEEP_DAYS > 0)); then
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' \
-mtime +"$KEEP_DAYS" -print -exec rm -rf {} + 2>/dev/null || true
fi
}
list_backups() {
if [[ -f "$ENV_FILE" ]]; then
load_env
fi
if [[ ! -d "$BACKUP_ROOT" ]]; then
echo "Каталог бэкапов не найден: $BACKUP_ROOT"
return 0
fi
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort
}
latest_backup() {
if [[ -f "$ENV_FILE" ]]; then
load_env
fi
[[ -d "$BACKUP_ROOT" ]] || return 0
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -print 2>/dev/null | sort | tail -n 1 || true
}
verify_latest_backup() {
local dir
dir="$(latest_backup)"
if [[ -z "$dir" ]]; then
info "Backup ещё не создавался — проверять нечего"
return 0
fi
verify_backup "$dir"
}
verify_backup() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
[[ -d "$dir" ]] || die "нет каталога: $dir"
dir="$(cd "$dir" && pwd)"
[[ -f "$dir/SHA256SUMS" ]] || die "нет SHA256SUMS"
[[ -s "$dir/db.dump" ]] || die "нет db.dump"
[[ -s "$dir/submissions.tar.gz" ]] || die "нет submissions.tar.gz"
[[ -s "$dir/assets.tar.gz" ]] || die "нет assets.tar.gz"
require_docker
(
cd "$dir"
sha256sum -c SHA256SUMS
)
gzip -t "$dir/submissions.tar.gz"
gzip -t "$dir/assets.tar.gz"
docker run --rm -v "$dir":/backup:ro postgres:18 \
pg_restore --list /backup/db.dump >/dev/null
info "Backup корректен: $dir"
}
restore_backup() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
[[ -d "$dir" ]] || die "нет каталога: $dir"
dir="$(cd "$dir" && pwd)"
verify_backup "$dir"
load_env
postgres_running || die "PostgreSQL не запущен"
ensure_volumes
echo
echo "Будут восстановлены БД '$LMS_DB_NAME' и файлы из:"
echo " $dir"
echo "Текущие данные будут удалены."
read -r -p "Введите RESTORE для продолжения: " answer
[[ "$answer" == "RESTORE" ]] || die "восстановление отменено"
info "Останавливаю прикладные сервисы..."
compose stop web gateway identity academic progress >/dev/null 2>&1 || true
info "Пересоздаю базу данных..."
compose exec -T postgres sh -c \
'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
info "Восстанавливаю БД..."
compose exec -T postgres sh -c \
'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-privileges' \
<"$dir/db.dump"
info "Восстанавливаю файлы..."
docker run --rm \
-v "${PROJECT}_lms_submissions:/submissions" \
-v "$dir":/backup:ro \
postgres:18 sh -c \
'find /submissions -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar xzf /backup/submissions.tar.gz -C /submissions'
docker run --rm \
-v "${PROJECT}_lms_assets:/assets" \
-v "$dir":/backup:ro \
postgres:18 sh -c \
'find /assets -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar xzf /backup/assets.tar.gz -C /assets'
info "Запускаю сервисы..."
compose up -d
local attempt
for attempt in $(seq 1 40); do
if curl -fsS "http://127.0.0.1:${LMS_WEB_PORT:-8090}/actuator/health" >/dev/null 2>&1; then
info "Восстановление завершено, web здоров"
return 0
fi
sleep 2
done
die "сервисы не поднялись за 80 секунд; проверьте ./deploy/deploy.sh logs web"
}
case "${1:-backup}" in
backup)
shift
backup "${1:-}"
;;
list)
list_backups
;;
verify-latest)
verify_latest_backup
;;
verify)
verify_backup "${2:-}"
;;
restore)
restore_backup "${2:-}"
;;
*)
usage
;;
esac

View File

@@ -30,6 +30,9 @@ cmd="${1:-up}"
case "$cmd" in case "$cmd" in
up) up)
echo ">> Резервная копия перед обновлением (обязательно)..."
./deploy/backup.sh backup --if-running
./deploy/backup.sh verify-latest
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..." echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
$COMPOSE build $COMPOSE build
echo ">> Запуск сервисов..." echo ">> Запуск сервисов..."
@@ -52,8 +55,20 @@ case "$cmd" in
restart) restart)
$COMPOSE restart "${2:-}" $COMPOSE restart "${2:-}"
;; ;;
backup)
./deploy/backup.sh backup
;;
backups)
./deploy/backup.sh list
;;
verify)
./deploy/backup.sh verify "${2:-}"
;;
restore)
./deploy/backup.sh restore "${2:-}"
;;
*) *)
echo "usage: $0 [up|ps|logs <service>|restart [service]|down]" >&2 echo "usage: $0 [up|ps|logs <service>|restart [service]|down|backup|backups|verify <dir>|restore <dir>]" >&2
exit 1 exit 1
;; ;;
esac esac

View File

@@ -0,0 +1,82 @@
# Preflight перед миграциями групп и курсов
**Перед любыми миграциями обязателен backup:** `./deploy/backup.sh backup`
(см. [`../BACKUP.md`](../BACKUP.md)). Preflight не заменяет backup.
Скрипты только читают данные:
- `groups-courses.sql` — до миграций: проблемы, которые могут помешать;
- `groups-courses-post.sql` — после миграций и bootstrap курса C: проверка результата;
- `progress-v10-v13.sql` — проверка истории Flyway и колонок/индекса при переходе
старой feature-миграции V10 на V13.
## До миграций
```bash
psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses.sql
```
Проверяет:
- группы, имя которых не разбирается шаблоном;
- одинаковые кортежи `(stream, degree, number, course, enrollment_year)`;
- группы без полного набора полей;
- одинаковые имена групп;
- студентов без текущей группы;
- дубли номеров лабораторных при переносе в один курс;
- дубли slug глав;
- объём материалов и активов.
## После миграций и bootstrap
```bash
psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql
```
Проверяет:
- лабы без курса (должно быть `0` перед `SET NOT NULL`);
- главы без курса (должно быть `0`, если все текущие материалы курсовые);
- наличие ровно одного курса C;
- комментарии без автора;
- статусы аккаунтов;
- группы без полного разбора.
## Переход V10 → V13
Нужен для любой БД, которая когда-либо применяла старую feature-миграцию
`V10__profile_authors.sql`: dev/feature-БД и staging, ненадолго запускавший
master-коммит `bfdae41` (out-of-order + V10). На production/master записи V10
нет — раздел можно пропустить.
Порядок:
1. Backup: `./deploy/backup.sh backup`.
2. Проверить состояние:
```bash
psql -h localhost -U lms -d lms -f deploy/preflight/progress-v10-v13.sql
```
Колонки должны быть `uuid`/nullable, индекс `lab_grades_lab_idx` — существовать
и быть валидным; `columns_ok` и `index_ok` должны быть `true`.
3. Если V10 есть в истории с `success=true`, удалить только эту запись истории
(`flyway repair` или точечный `DELETE` из примера в конце скрипта). При
`success=false` сначала проверить откат объектов; V13 идемпотентна и создаст
недостающее.
4. Deploy: обычный запуск применит V13.
5. Postflight: `psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql`.
После V13 переход forward-only: откат на сборку без V13 не пройдёт валидацию
Flyway. Не оставляйте `out-of-order` включённым.
## Что делать с результатами
1. Группы с нестандартными именами — разобрать вручную и заполнить поля до
перехода на `NOT NULL`.
2. Одинаковые кортежи — исправить номер или год набора.
3. Дубли номеров лаб — перенумеровать или разнести по разным курсам до
включения `UNIQUE (number, course_id)` / `NOT NULL`.
4. Дубли slug — переименовать главы до частичных индексов.
5. После bootstrap курса C проверить, что `labs_without_course = 0` и
`chapters_without_course = 0`.

View File

@@ -0,0 +1,38 @@
-- Пост-миграционные проверки. Запускать после миграций и bootstrap курса C.
\pset pager off
\echo '=== 1. Лабы без курса (должно быть 0 перед SET NOT NULL) ==='
SELECT count(*) AS labs_without_course
FROM academic.labs
WHERE course_id IS NULL;
\echo '=== 2. Главы без курса (должно быть 0, если все текущие материалы курсовые) ==='
SELECT count(*) AS chapters_without_course
FROM academic.chapters
WHERE course_id IS NULL;
\echo '=== 3. Курсы (должен быть ровно один курс C) ==='
SELECT id, course_no, title, owner_account_id, status
FROM academic.courses
ORDER BY course_no;
\echo '=== 4. Комментарии без автора (старые записи) ==='
SELECT count(*) AS comments_without_author
FROM progress.grade_comments
WHERE author_account_id IS NULL;
\echo '=== 5. Аккаунты по ролям и статусам ==='
SELECT role, status, count(*) AS accounts
FROM identity.accounts
GROUP BY role, status
ORDER BY role, status;
\echo '=== 6. Группы без полного разбора ==='
SELECT id, name, stream, course, number, degree, enrollment_year
FROM identity.groups
WHERE stream IS NULL
OR course IS NULL
OR number IS NULL
OR degree IS NULL
OR enrollment_year IS NULL
ORDER BY name;

View File

@@ -0,0 +1,124 @@
-- Preflight (до миграций) для перехода на группы-когорты, курсы и перенос лаб/материалов.
-- Только чтение. Запускать на копии рабочей БД до применения миграций.
-- Пример:
-- psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses.sql
\pset pager off
\echo '=== 1. Группы, не подходящие под шаблон ^(.+)-([0-9])([0-9]+)(БО|МО)$ ==='
SELECT id, name
FROM identity.groups
WHERE name !~ '^(.+)-([0-9])([0-9]+)(БО|МО)$'
ORDER BY name;
\echo '=== 2. Разобранные группы с одинаковым кортежем (stream, degree, number, course, enrollment_year) ==='
WITH params AS (
SELECT CASE
WHEN EXTRACT(MONTH FROM now()) >= 9 THEN EXTRACT(YEAR FROM now())
ELSE EXTRACT(YEAR FROM now()) - 1
END::int AS academic_year_start
),
parsed AS (
SELECT
g.name,
m[1] AS stream,
m[2]::int AS course,
m[3]::int AS number,
m[4] AS degree,
(SELECT academic_year_start FROM params) - (m[2]::int - 1) AS enrollment_year
FROM identity.groups g
CROSS JOIN LATERAL (
SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m
) parsed_name
WHERE m IS NOT NULL
)
SELECT stream, degree, number, course, enrollment_year,
count(*) AS groups,
array_agg(name ORDER BY name) AS names
FROM parsed
GROUP BY stream, degree, number, course, enrollment_year
HAVING count(*) > 1
ORDER BY stream, degree, course, number;
\echo '=== 3. Группы, которые не разберутся шаблоном (потребуют ручного разбора) ==='
WITH parsed AS (
SELECT
g.id,
g.name,
m[1] AS stream,
m[2]::int AS course,
m[3]::int AS number,
m[4] AS degree
FROM identity.groups g
CROSS JOIN LATERAL (
SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m
) parsed_name
)
SELECT id, name, stream, course, number, degree
FROM parsed
WHERE stream IS NULL
OR course IS NULL
OR number IS NULL
OR degree IS NULL
ORDER BY name;
\echo '=== 4. Одинаковые имена групп (после снятия UNIQUE станут допустимы) ==='
SELECT name, count(*) AS groups
FROM identity.groups
GROUP BY name
HAVING count(*) > 1
ORDER BY name;
\echo '=== 5. Студенты без текущей группы ==='
SELECT s.student_no, a.login, s.last_name, s.first_name
FROM identity.students s
JOIN identity.accounts a ON a.id = s.account_id
LEFT JOIN identity.enrollments e ON e.student_id = s.account_id
WHERE e.id IS NULL
ORDER BY s.student_no;
\echo '=== 6. Аккаунты по ролям (до появления статусов) ==='
SELECT role, count(*) AS accounts
FROM identity.accounts
GROUP BY role
ORDER BY role;
\echo '=== 7. Дубли номеров лабораторных (все лабы переезжают в один курс C) ==='
SELECT number,
count(*) AS labs,
array_agg(id ORDER BY created_at) AS ids,
array_agg(title ORDER BY created_at) AS titles
FROM academic.labs
GROUP BY number
HAVING count(*) > 1
ORDER BY number;
\echo '=== 8. Лабораторные по семестрам ==='
SELECT semester_id, number, count(*) AS labs
FROM academic.labs
GROUP BY semester_id, number
HAVING count(*) > 1
ORDER BY semester_id, number;
\echo '=== 9. Дубли slug глав (до перехода на частичные индексы) ==='
SELECT slug, count(*) AS chapters
FROM academic.chapters
GROUP BY slug
HAVING count(*) > 1
ORDER BY slug;
\echo '=== 10. Объём материалов ==='
SELECT
(SELECT count(*) FROM academic.chapters) AS chapters,
(SELECT count(*) FROM academic.sections) AS sections,
(SELECT count(*) FROM academic.articles) AS articles,
(SELECT count(*) FROM academic.article_assets) AS article_assets;
\echo '=== 11. Активы без статьи ==='
SELECT count(*) AS assets_without_article
FROM academic.article_assets
WHERE article_id IS NULL;
\echo '=== 12. Семестры ==='
SELECT id, title, year, active
FROM academic.semesters
ORDER BY year, title;

View File

@@ -0,0 +1,96 @@
-- Preflight (только чтение): переход progress-миграции профиля V10 -> V13.
-- Проверяет историю Flyway, тип/nullable колонок и валидность индекса.
-- Ничего не изменяет: DELETE в конце приведён только как закомментированный пример.
-- Пример:
-- psql -h localhost -U lms -d lms -f deploy/preflight/progress-v10-v13.sql
\set ON_ERROR_STOP on
\pset pager off
\echo '=== 0. Наличие progress.flyway_schema_history ==='
SELECT to_regclass('progress.flyway_schema_history') IS NOT NULL
AS progress_flyway_schema_history_exists \gset
\if :progress_flyway_schema_history_exists
\echo '=== 1. progress.flyway_schema_history: версии 10-13 ==='
SELECT installed_rank, version, description, type, script, success, installed_on
FROM progress.flyway_schema_history
WHERE version IN ('10', '11', '12', '13')
ORDER BY installed_rank;
\else
\echo 'progress.flyway_schema_history не найдена: сервис ещё не применял миграции progress.'
\echo 'Проверьте LMS_DB_URL / схему progress и повторите preflight.'
\endif
\echo '=== 2. Колонки профиля: тип uuid и nullable (ok = true) ==='
SELECT expected.table_name,
expected.column_name,
c.data_type,
c.is_nullable,
(c.data_type = 'uuid' AND c.is_nullable = 'YES') AS ok
FROM (VALUES
('grade_comments', 'author_account_id'),
('lab_grades', 'updated_by_account_id')
) AS expected(table_name, column_name)
LEFT JOIN information_schema.columns c
ON c.table_schema = 'progress'
AND c.table_name = expected.table_name
AND c.column_name = expected.column_name
ORDER BY expected.table_name, expected.column_name;
\echo '=== 3. Индекс lab_grades_lab_idx: существует, валиден, определение ==='
SELECT i.indexrelid::regclass AS index_name,
i.indisvalid,
i.indisready,
pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index i
JOIN pg_class idx ON idx.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = idx.relnamespace
WHERE n.nspname = 'progress'
AND idx.relname = 'lab_grades_lab_idx';
\echo '=== 4. Сводка: колонки и индекс готовы к V13 ==='
SELECT
(
SELECT count(*) = 2
AND bool_and(c.data_type = 'uuid' AND c.is_nullable = 'YES')
FROM information_schema.columns c
WHERE c.table_schema = 'progress'
AND (c.table_name, c.column_name) IN (
('grade_comments', 'author_account_id'),
('lab_grades', 'updated_by_account_id')
)
) AS columns_ok,
(
SELECT count(*) = 1
AND bool_and(i.indisvalid)
FROM pg_index i
JOIN pg_class idx ON idx.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = idx.relnamespace
WHERE n.nspname = 'progress'
AND idx.relname = 'lab_grades_lab_idx'
) AS index_ok;
-- Очистка истории (НЕ выполняется этим скриптом).
--
-- Если старая feature-миграция V10 применена со success=true, удалите ТОЛЬКО
-- её запись истории: объекты схемы уже созданы, а V13 идемпотентна и создаст
-- недостающие колонки/индекс. Гард по RETURNING: ожидается ровно одна строка;
-- если строк больше или меньше — ROLLBACK.
--
-- BEGIN;
-- DELETE FROM progress.flyway_schema_history
-- WHERE version = '10'
-- AND script = 'V10__profile_authors.sql'
-- AND success
-- RETURNING installed_rank, version, script, success;
-- -- expect exactly one row; otherwise ROLLBACK
-- COMMIT;
--
-- Если V10 осталась со success=false, сначала убедитесь, что её частичные
-- изменения откатились на уровне объектов, затем выполните `flyway repair`
-- или удалите только неуспешную строку. V13 идемпотентна и досоздаст
-- недостающие объекты. Перед удалением строки запустите этот preflight:
-- все проверки должны быть true.
--
-- Не переставляйте V11/V12 и не включайте out-of-order.

View File

@@ -0,0 +1,15 @@
[Unit]
Description=LMS backup (PostgreSQL + files)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
WorkingDirectory=/opt/lms
ExecStart=/opt/lms/deploy/backup.sh backup
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
# Если сервис запускается не от root, раскомментируйте и укажите пользователя
# с доступом к Docker и каталогу /opt/lms:
# User=lms

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Daily LMS backup
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=1800
[Install]
WantedBy=timers.target

View File

@@ -18,6 +18,9 @@ x-db-env: &db-env
x-jwt-env: &jwt-env x-jwt-env: &jwt-env
LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json
x-internal-env: &internal-env
LMS_INTERNAL_TOKEN: ${LMS_INTERNAL_TOKEN:?Задайте LMS_INTERNAL_TOKEN в .env.prod}
services: services:
postgres: postgres:
image: postgres:18 image: postgres:18
@@ -40,8 +43,13 @@ services:
image: lms-identity:latest image: lms-identity:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
<<: *db-env <<: [*db-env, *internal-env]
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
LMS_ACCESS_TOKEN_TTL: ${LMS_ACCESS_TOKEN_TTL:-15m}
LMS_REFRESH_TOKEN_TTL: ${LMS_REFRESH_TOKEN_TTL:-7d}
LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d}
LMS_REFRESH_TOKEN_RETENTION: ${LMS_REFRESH_TOKEN_RETENTION:-30d}
LMS_REFRESH_TOKEN_CLEANUP_CRON: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:-0 30 3 * * *}
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
networks: [lms] networks: [lms]
@@ -51,8 +59,9 @@ services:
image: lms-academic:latest image: lms-academic:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
<<: [*db-env, *jwt-env] <<: [*db-env, *jwt-env, *internal-env]
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=academic LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=academic
LMS_IDENTITY_URI: http://identity:8081
LMS_CONTENT_ASSETS_DIR: /data/content-assets LMS_CONTENT_ASSETS_DIR: /data/content-assets
volumes: volumes:
- lms_assets:/data/content-assets - lms_assets:/data/content-assets
@@ -66,7 +75,7 @@ services:
image: lms-progress:latest image: lms-progress:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
<<: [*db-env, *jwt-env] <<: [*db-env, *jwt-env, *internal-env]
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress
LMS_SUBMISSIONS_DIR: /data/submissions LMS_SUBMISSIONS_DIR: /data/submissions
LMS_IDENTITY_URI: http://identity:8081 LMS_IDENTITY_URI: http://identity:8081
@@ -101,6 +110,8 @@ services:
environment: environment:
LMS_GATEWAY_URL: http://gateway:8080 LMS_GATEWAY_URL: http://gateway:8080
SERVER_PORT: 8090 SERVER_PORT: 8090
LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d}
LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d}
ports: ports:
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090" - "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
depends_on: depends_on:

View File

@@ -32,8 +32,11 @@ flowchart LR
id -.->|JWKS| pr id -.->|JWKS| pr
``` ```
- Браузер никогда не получает JWT: токен живёт в HTTP-сессии web-сервиса - Браузер никогда не получает JWT: access-токен живёт в HTTP-сессии web-сервиса
(`lms.token`), а в браузер уходит только cookie сессии (`HttpOnly`, 30 дней). (`lms.token`), а в браузер уходит только cookie сессии `LMS_SESSION`
(`HttpOnly`, `SameSite=Lax`, 7 дней sliding). Access-токен — 15 минут,
обновляется автоматически за 2 минуты до истечения по refresh-токену;
cookie `Max-Age` перевыпускается на каждом ответе.
- Все REST-запросы web проксирует на gateway, добавляя `Authorization: Bearer`. - Все REST-запросы web проксирует на gateway, добавляя `Authorization: Bearer`.
- Gateway проверяет подпись токена по JWKS identity и передаёт вниз заголовки - Gateway проверяет подпись токена по JWKS identity и передаёт вниз заголовки
`X-Account-Id`, `X-Account-Login`, `X-Account-Role`. `X-Account-Id`, `X-Account-Login`, `X-Account-Role`.
@@ -139,10 +142,13 @@ flowchart TD
n -->|да| p["Контроллер → сервис → БД"] n -->|да| p["Контроллер → сервис → БД"]
``` ```
- Роли: `ADMIN`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_<role>`. - Роли: `ADMIN`, `TEACHER`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_<role>`.
- Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,jwks.json}`, - Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,refresh,jwks.json}`,
`/api/v1/content/tree`, `/api/v1/content/assets/**`, `/api/v1/content/tree`, `/api/v1/content/{chapter}/{section}/{article}`.
`/api/v1/content/{chapter}/{section}/{article}`. `/api/v1/content/assets/**` gateway пропускает без JWT (публичные
`ALL`-активы), но academic проверяет видимость статьи и для курсовых или
ролевых активов требует токен. Файл без привязки к статье — `404` всем,
кроме админа (secure-by-default).
- RSA-ключи identity хранятся в таблице `settings` и публикуются в JWKS. - RSA-ключи identity хранятся в таблице `settings` и публикуются в JWKS.
## 5. Рендеринг страниц и защита форм (web) ## 5. Рендеринг страниц и защита форм (web)

View File

@@ -52,6 +52,12 @@ flowchart TB
`LMS_JWT_JWK_SET_URI` обязателен для gateway, academic и progress — `LMS_JWT_JWK_SET_URI` обязателен для gateway, academic и progress —
в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401). в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401).
Сроки сессии задаются переменными (значения по умолчанию подходят):
`LMS_ACCESS_TOKEN_TTL=15m`, `LMS_REFRESH_TOKEN_TTL=7d`,
`LMS_SESSION_ABSOLUTE_TIMEOUT=30d`, `LMS_REFRESH_TOKEN_RETENTION=30d`,
`LMS_REFRESH_TOKEN_CLEANUP_CRON` для identity; `LMS_SESSION_TIMEOUT=7d` и
`LMS_SESSION_ABSOLUTE_TIMEOUT=30d` для web.
## 3. Сборка ## 3. Сборка
```mermaid ```mermaid
@@ -101,7 +107,7 @@ sequenceDiagram
DC->>DC: docker build (5 стадий) DC->>DC: docker build (5 стадий)
DC->>PG: старт postgres (volume lms_pgdata) DC->>PG: старт postgres (volume lms_pgdata)
DC->>DC: старт identity → academic → progress → gateway → web DC->>DC: старт identity → academic → progress → gateway → web
Note over DC: Flyway применяет миграции автоматически<br/>при старте сервисов (identity V1..V3, academic V1..V5, progress V1..V9; у web миграций нет) Note over DC: Flyway применяет миграции автоматически<br/>при старте сервисов (identity V1..V13, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет)
``` ```
- Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`. - Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`.

View File

@@ -12,6 +12,8 @@ classDiagram
+String login +String login
+String passwordHash +String passwordHash
+LmsRole role +LmsRole role
+AccountStatus status
+Instant archivedAt
+Boolean mustChangePassword +Boolean mustChangePassword
+String tempPassword +String tempPassword
+Instant createdAt +Instant createdAt
@@ -25,11 +27,19 @@ classDiagram
+String middleName +String middleName
+String basis +String basis
+String note +String note
+StudentStatus status
+Short enrollmentYear
+String fullName +String fullName
} }
class GroupEntity { class GroupEntity {
+UUID id +UUID id
+String name +String name
+String stream
+Short course
+Short number
+String degree
+Short enrollmentYear
+GroupStatus status
+Int taskDurationMin +Int taskDurationMin
+Instant createdAt +Instant createdAt
} }
@@ -37,6 +47,17 @@ classDiagram
+UUID id +UUID id
+Instant enrolledAt +Instant enrolledAt
} }
class MembershipHistory {
+UUID id
+UUID studentId
+UUID fromGroupId
+UUID toGroupId
+MembershipAction action
+String reason
+UUID actorAccountId
+UUID promotionRunId
+Instant at
}
class Setting { class Setting {
+String key +String key
+String value +String value
@@ -44,12 +65,15 @@ classDiagram
class LmsRole { class LmsRole {
<<enumeration>> <<enumeration>>
ADMIN ADMIN
TEACHER
STUDENT STUDENT
} }
Account "1" --> "1" Student : @MapsId account_id Account "1" --> "1" Student : @MapsId account_id
GroupEntity "1" --> "*" Enrollment : group_id GroupEntity "1" --> "*" Enrollment : group_id
Student "1" --> "*" Enrollment : student_id Student "1" --> "*" Enrollment : student_id
Student "1" --> "*" MembershipHistory : student_id
GroupEntity "1" --> "*" MembershipHistory : from/to_group_id
Account --> LmsRole : role Account --> LmsRole : role
``` ```
@@ -68,20 +92,28 @@ classDiagram
class GroupService { class GroupService {
+list() List~GroupSummary~ +list() List~GroupSummary~
+create(CreateGroupRequest) GroupSummary +create(CreateGroupRequest) GroupSummary
+createStudent(UUID, CreateStudentRequest) Credential +createStudent(UUID, CreateStudentRequest, UUID) Credential
+candidates(UUID) List~StudentCandidate~ +candidates(UUID) List~StudentCandidate~
+enroll(UUID, UUID) GroupDetail +enroll(UUID, UUID, UUID) GroupDetail
+unenroll(UUID, UUID) GroupDetail +unenroll(UUID, UUID, UUID) GroupDetail
+import(String, String, ByteArray) ImportResult
+groupOfStudent(UUID) GroupSummary +groupOfStudent(UUID) GroupSummary
+detail(UUID) GroupDetail +detail(UUID) GroupDetail
+resetPassword(UUID) ResetPasswordResponse +resetPassword(UUID) ResetPasswordResponse
+credentials(UUID, String) ExportFile +credentials(UUID, String) ExportFile
} }
class GroupImportService {
+import(String, ByteArray, UUID) ImportResult
}
class GroupCohortResolver {
+resolve(CreateGroupRequest) GroupCohort
+validate(GroupCohort) GroupCohort
+defaultEnrollmentYear(Short) Short
}
class AccountRepository class AccountRepository
class GroupRepository class GroupRepository
class EnrollmentRepository class EnrollmentRepository
class StudentRepository class StudentRepository
class MembershipHistoryRepository
class SettingRepository class SettingRepository
class StudentImportParser { class StudentImportParser {
+parse(String, ByteArray) ParsedGroup +parse(String, ByteArray) ParsedGroup
@@ -96,6 +128,7 @@ classDiagram
AuthController --> AuthService AuthController --> AuthService
GroupController --> GroupService GroupController --> GroupService
GroupController --> GroupImportService
StudentController --> GroupService StudentController --> GroupService
AuthService --> AccountRepository AuthService --> AccountRepository
AuthService --> StudentRepository AuthService --> StudentRepository
@@ -103,7 +136,14 @@ classDiagram
GroupService --> EnrollmentRepository GroupService --> EnrollmentRepository
GroupService --> StudentRepository GroupService --> StudentRepository
GroupService --> AccountRepository GroupService --> AccountRepository
GroupService --> StudentImportParser GroupService --> MembershipHistoryRepository
GroupService --> GroupCohortResolver
GroupImportService --> GroupRepository
GroupImportService --> EnrollmentRepository
GroupImportService --> StudentRepository
GroupImportService --> AccountRepository
GroupImportService --> MembershipHistoryRepository
GroupImportService --> StudentImportParser
GroupService --> CredentialsExporter GroupService --> CredentialsExporter
GroupService --> Passwords GroupService --> Passwords
``` ```
@@ -516,6 +556,8 @@ erDiagram
varchar login UK varchar login UK
varchar password_hash varchar password_hash
varchar role varchar role
varchar status
timestamptz archived_at
boolean must_change_password boolean must_change_password
varchar temp_password varchar temp_password
timestamptz created_at timestamptz created_at
@@ -529,10 +571,18 @@ erDiagram
varchar middle_name varchar middle_name
varchar basis varchar basis
text note text note
varchar status
smallint enrollment_year
} }
GROUPS { GROUPS {
uuid id PK uuid id PK
varchar name UK varchar name
varchar stream
smallint course
smallint number
varchar degree
smallint enrollment_year
varchar status
int task_duration_min int task_duration_min
timestamptz created_at timestamptz created_at
} }
@@ -542,14 +592,27 @@ erDiagram
uuid student_id FK uuid student_id FK
timestamptz enrolled_at timestamptz enrolled_at
} }
MEMBERSHIP_HISTORY {
uuid id PK
uuid student_id FK
uuid from_group_id FK
uuid to_group_id FK
varchar action
text reason
uuid actor_account_id
uuid promotion_run_id
timestamptz at
}
SETTINGS { SETTINGS {
varchar key PK varchar key PK
varchar value varchar value
} }
ACCOUNTS ||--|| STUDENTS : "account_id" ACCOUNTS ||--o| STUDENTS : "account_id"
GROUPS ||--o{ ENROLLMENTS : "group_id" GROUPS ||--o{ ENROLLMENTS : "group_id"
STUDENTS ||--o{ ENROLLMENTS : "student_id" STUDENTS ||--o{ ENROLLMENTS : "student_id"
STUDENTS ||--o{ MEMBERSHIP_HISTORY : "student_id"
GROUPS ||--o{ MEMBERSHIP_HISTORY : "from/to_group_id"
SEMESTERS { SEMESTERS {
uuid id PK uuid id PK

View File

@@ -125,8 +125,12 @@ printf("Ответ: 42\n");
* Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную. * Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную.
* Минимальный архив (например, только один раздел + `assets/`) безопасно импортировать * Минимальный архив (например, только один раздел + `assets/`) безопасно импортировать
в режиме merge: остальные материалы останутся как есть. в режиме merge: остальные материалы останутся как есть.
* Ссылки вида `assets/имя` автоматически заменяются на публичный адрес * Ссылки вида `assets/имя` автоматически заменяются на адрес
`/api/v1/content/assets/...`; сами файлы складываются в хранилище. `/api/v1/content/assets/...`; сами файлы складываются в хранилище. Для
курсовых статей этот адрес требует сессию пользователя курса, для публичных
`ALL`-материалов остаётся публичным. Файл без привязки к статье больше не
публичен: обычные пользователи получают `404`, админ — файл и его имя в
`GET /api/v1/content/legacy-assets` для привязки или удаления.
## Публикация и скрытие ## Публикация и скрытие

View File

@@ -1,8 +1,37 @@
# 07. Группы, курсы и преподаватели, повышение, интеграция (ТЗ) # 07. Группы, курсы и преподаватели, повышение, интеграция (ТЗ)
**Статус:** проект согласован, к реализации не приступаем до отдельного сигнала. **Статус:** проект согласован, реализация ведётся по плану
[08-groups-courses-plan.md](08-groups-courses-plan.md).
Документ описывает целевую модель, схему преобразований БД, правила и граничные случаи. Документ описывает целевую модель, схему преобразований БД, правила и граничные случаи.
## 0. Решения, принятые при планировании (2026-10-23)
При противоречии с разделами 1–14 приоритет у этого раздела и плана
[08-groups-courses-plan.md](08-groups-courses-plan.md).
1. **«Общих» лабораторных нет.** Все лабы принадлежат курсу. Текущие лабы переносятся
в курс «Промышленное программирование на языке C»; все текущие активные группы
привязываются к нему. `labs.course_id` сначала nullable, после переноса — `NOT NULL`.
2. **Курс в URL — целочисленный `course_no`.** UUID остаётся первичным ключом;
`courses.course_no` — `BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный и неизменяемый.
Курсовые материалы открываются по `/learn/course/{course_no}/...`.
Все текущие материалы переносятся в курс C и доступны только студентам его групп,
преподавателям курса и админу. Старые ссылки не поддерживаются.
`audience` применяется только к будущим глобальным материалам (`course_id IS NULL`).
3. **Статусы и вход.** `accounts.status (ACTIVE|ARCHIVED)` — для всех пользователей;
`students.status (ACTIVE|ACADEMIC_LEAVE|EXPELLED|GRADUATED|ARCHIVED)` — академический.
Вход закрыт для `EXPELLED`, `GRADUATED`, `ARCHIVED` и архивных аккаунтов.
`ACADEMIC_LEAVE` — вход в режиме только чтения. Web перепроверяет статус с кэшем
30–60 секунд; сервисы проверяют доступ к курсу при каждом запросе.
4. **Журнал повышения.** `promotion_runs` + `promotion_run_items` + `membership_history`.
Dry-run сохраняется как план; применение идемпотентно; откат — по элементам журнала
с проверкой конфликтов. 31 августа в 00:00 МСК автоматически строится dry-run;
применение — только вручную админом.
5. **Профиль студента курса.** Преподаватель своего курса открывает профиль студента
по клику на ФИО или отдельной кнопке: статистика по лабам, комментарии преподавателей,
скачивание файлов любой попытки сдачи. Подробно — §5.1.
6. **Объём.** Полный ТЗ, этапы 0–6, один релиз с внутренними merge gate.
## 1. Зафиксированные решения ## 1. Зафиксированные решения
1. Сущность **курс** живёт в `academic-service`; владелец — **админ**, преподавателей на курс 1. Сущность **курс** живёт в `academic-service`; владелец — **админ**, преподавателей на курс
@@ -31,8 +60,8 @@
**Разбор имени группы:** `^(.+)-(\d)(\d+)(БО|МО)$` → `stream` (буквы/дефисы), `course` (одна цифра), **Разбор имени группы:** `^(.+)-(\d)(\d+)(БО|МО)$` → `stream` (буквы/дефисы), `course` (одна цифра),
`number` (остальные цифры), `degree`. Примеры: `ПИ-11БО` → ПИ/1/1/БО; `ИВТ-ТОП-12БО` → ИВТ-ТОП/1/2/БО; `number` (остальные цифры), `degree`. Примеры: `ПИ-11БО` → ПИ/1/1/БО; `ИВТ-ТОП-12БО` → ИВТ-ТОП/1/2/БО;
`ПИ-101БО` → курс 1, номер 01 (ведущие нули допустимы). Разбор выполняется при создании/импорте, `ПИ-101БО` → курс 1, номер 1. `number` — числовой; ведущий ноль не является отдельным значением.
далее живём по полям; имя храним для отображения/выгрузок. Разбор выполняется при создании/импорте, далее живём по полям; имя храним для отображения/выгрузок.
--- ---
@@ -65,7 +94,12 @@
### progress ### progress
Данные привязаны к `lab_id` и студенту (сдачи, оценки, комментарии, защиты, назначения вариантов, Данные привязаны к `lab_id` и студенту (сдачи, оценки, комментарии, защиты, назначения вариантов,
публикации). Схема **не меняется**: доступ регулируется по цепочке `lab → course → (преподаватель | группа)`. публикации). Схема **в основном не меняется**: доступ регулируется по цепочке
`lab → course → (преподаватель | группа)`. Для профиля студента курса добавляются
`grade_comments.author_account_id` (nullable) и `lab_grades.updated_by_account_id`
(nullable); существующий индекс `(student_id, lab_id)` (`grade_comments_grade_idx`)
переиспользуется, дополнительно добавляется индекс `lab_grades(lab_id)`.
Остальное — рантайм-проверки.
--- ---
@@ -97,13 +131,15 @@ erDiagram
переводы, академ, возврат, выпуск, отчисление, восстановление. переводы, академ, возврат, выпуск, отчисление, восстановление.
### academic ### academic
- `courses(id, title, owner_account_id→accounts, status (ACTIVE|ARCHIVED), created_at, updated_at)`. - `courses(id, course_no, title, owner_account_id→accounts, status (ACTIVE|ARCHIVED), created_at, updated_at)`;
`course_no` — `BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный, для URL.
- `course_teachers(id, course_id, account_id, assigned_at, removed_at)` — доступ = `removed_at IS NULL`; история сохраняется. - `course_teachers(id, course_id, account_id, assigned_at, removed_at)` — доступ = `removed_at IS NULL`; история сохраняется.
- `course_groups(id, course_id, group_id, added_at, removed_at)`. - `course_groups(id, course_id, group_id, added_at, removed_at)`.
- `labs`: `+ course_id` (nullable — «общие» лабы), новый уникальный ключ `(number, course_id)` для лаб курса. - `labs`: `+ course_id` (nullable только на время миграции, затем `NOT NULL`); «общих» лаб нет;
новый уникальный ключ `(number, course_id)` для лаб курса.
- Материалы: только у **корня** (`chapters`) добавляем `+ course_id` (nullable) и `+ audience` - Материалы: только у **корня** (`chapters`) добавляем `+ course_id` (nullable) и `+ audience`
(`ALL | STUDENTS | TEACHERS`, применяется при `course_id IS NULL`); `sections`/`articles` наследуют (`ALL | STUDENTS | TEACHERS`, применяется при `course_id IS NULL`); `sections`/`articles` наследуют
видимость от главы. видимость от главы. Текущие материалы переносятся в курс C и получают `course_id`.
- `chapters.slug`: снять глобальный UNIQUE, ввести два частичных индекса (глобальные и внутри курса). - `chapters.slug`: снять глобальный UNIQUE, ввести два частичных индекса (глобальные и внутри курса).
- `article_assets`: видимость наследуется от статьи → раздела → главы (см. §6). - `article_assets`: видимость наследуется от статьи → раздела → главы (см. §6).
@@ -169,12 +205,60 @@ INSERT INTO membership_history(student_id, to_group_id, action, at)
SELECT student_id, group_id, 'ENROLL', enrolled_at FROM enrollments; SELECT student_id, group_id, 'ENROLL', enrolled_at FROM enrollments;
``` ```
**V8–V9 — история членства и статусы аккаунтов** реализованы на этапе 1
(`membership_history`, `accounts.status`).
**V10 — журнал аудита (добавлен на этапе 6, `V10__audit_events.sql`)**
```sql
CREATE TABLE identity.audit_events (
id uuid PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_account_id uuid,
actor_login varchar(100),
action varchar(64) NOT NULL,
entity_type varchar(64) NOT NULL,
entity_id varchar(128),
course_id uuid,
student_id uuid,
details text NOT NULL DEFAULT ''
);
CREATE INDEX audit_events_occurred_idx ON identity.audit_events (occurred_at DESC);
CREATE INDEX audit_events_action_idx ON identity.audit_events (action);
```
Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт
из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение —
`GET /api/v1/audit?limit=&action=&cursor=` и `/admin/audit` только для админа.
Страница аудита продолжается keyset-курсором `(occurred_at, id)`; offset `page`
остаётся для совместимости, `limit` — максимум 500. В UI — «Показать ещё» и
«В начало»; битый курсор даёт `400`.
**V11 — refresh-токены (добавлена при закрытии рисков аутентификации)**
```sql
CREATE TABLE identity.refresh_tokens (
id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES identity.accounts(id) ON DELETE CASCADE,
token_hash varchar(64) NOT NULL UNIQUE,
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
replaced_by uuid
);
CREATE INDEX refresh_tokens_account_idx ON identity.refresh_tokens (account_id);
CREATE INDEX refresh_tokens_expires_idx ON identity.refresh_tokens (expires_at);
```
Хранится только SHA-256-хеш raw-токена. Ротация при `POST /api/v1/auth/refresh`
помечает старый токен заменённым; повторное использование заменённого токена
трактуется как кража и отзывает все сессии аккаунта. `logout` и смена пароля
отзывают refresh; отчисление, выпуск, архив и закрытая группа не дают
обновить сессию.
### academic ### academic
**V6 — курс (пересоздание)** **V6 — курс (пересоздание)**
```sql ```sql
CREATE TABLE courses ( CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT uuidv7(), id uuid PRIMARY KEY DEFAULT uuidv7(),
course_no bigint GENERATED ALWAYS AS IDENTITY UNIQUE NOT NULL,
title varchar(200) NOT NULL, title varchar(200) NOT NULL,
owner_account_id uuid NOT NULL, owner_account_id uuid NOT NULL,
status varchar(16) NOT NULL DEFAULT 'ACTIVE', status varchar(16) NOT NULL DEFAULT 'ACTIVE',
@@ -199,36 +283,83 @@ CREATE TABLE course_groups (
CREATE UNIQUE INDEX course_groups_active_uidx ON course_groups(course_id, group_id) WHERE removed_at IS NULL; CREATE UNIQUE INDEX course_groups_active_uidx ON course_groups(course_id, group_id) WHERE removed_at IS NULL;
``` ```
**V7 — лабораторные привязываем к курсу** **V7 — лабораторные привязываем к курсу (nullable-фаза)**
```sql ```sql
-- «Курс по умолчанию» создаётся в сервисе (title='Общие лабораторные', owner=первый ADMIN) -- Курс «Промышленное программирование на языке C» создаётся bootstrap-сервисом
-- после первого входа админа (owner = этот админ). В V7 только добавляем nullable-колонку.
ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE RESTRICT; ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE RESTRICT;
UPDATE labs SET course_id = (SELECT id FROM courses ORDER BY created_at LIMIT 1) WHERE course_id IS NULL; ALTER TABLE labs DROP CONSTRAINT labs_number_semester_unique;
ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL; CREATE UNIQUE INDEX labs_number_course_uidx ON labs (number, course_id);
DROP CONSTRAINT labs_number_semester_unique;
ALTER TABLE labs ADD CONSTRAINT labs_number_course_unique UNIQUE (number, course_id);
CREATE INDEX labs_course_idx ON labs (course_id); CREATE INDEX labs_course_idx ON labs (course_id);
``` ```
**V7a — bootstrap курса C (сервис, не SQL-миграция)**
- Создать курс «Промышленное программирование на языке C» с `owner_account_id` текущего
админа; первый созданный курс получит `course_no = 1` автоматически.
- Привязать все текущие активные группы к курсу (`course_groups`).
- Проставить `labs.course_id` и `chapters.course_id` для всех существующих записей.
- Проверить дубли `(number, course_id)` и показать отчёт, если они есть.
**V8 — материалы: курс и аудитория, слаг** **V8 — материалы: курс и аудитория, слаг**
```sql ```sql
ALTER TABLE chapters ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE CASCADE; ALTER TABLE chapters ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE CASCADE;
ALTER TABLE chapters ADD COLUMN audience varchar(16); -- ALL | STUDENTS | TEACHERS (только при course_id IS NULL) ALTER TABLE chapters ADD COLUMN audience varchar(16); -- ALL | STUDENTS | TEACHERS (только при course_id IS NULL)
UPDATE chapters SET audience = 'ALL' WHERE course_id IS NULL; -- сохранить прежнее поведение («видят все») -- Bootstrap проставляет course_id = C для текущих глав; audience у курсовых глав остаётся NULL.
-- audience = 'ALL' используется только для будущих глобальных материалов.
ALTER TABLE chapters DROP CONSTRAINT chapters_slug_key; ALTER TABLE chapters DROP CONSTRAINT chapters_slug_key;
CREATE UNIQUE INDEX chapters_slug_global_uidx ON chapters (slug) WHERE course_id IS NULL; CREATE UNIQUE INDEX chapters_slug_global_uidx ON chapters (slug) WHERE course_id IS NULL;
CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHERE course_id IS NOT NULL; CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHERE course_id IS NOT NULL;
``` ```
Обновить URL-схему материалов: глобальные — как сейчас, курсовые — `/learn/{course}/{chapter}/{section}/{article}`; Обновить URL-схему материалов: глобальные — `/learn/{chapter}/{section}/{article}`;
старые ссылки на глобальные материалы сохраняются (совместимость). курсовые — `/learn/course/{course_no}/{chapter}/{section}/{article}`.
Старые ссылки на текущие материалы не поддерживаются (решение 2026-10-23).
**V9 — активы: наследуемая видимость** **V9 — активы: наследуемая видимость**
Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в
маршруте выдачи активов (см. §6). Отдельных колонок достаточно опционально; минимум — корректная авторизация. маршруте выдачи активов (см. §6). Отдельных колонок достаточно опционально; минимум — корректная авторизация.
> В реализации backfill связей вынесен в `V12__asset_links_backfill.sql`, а
> проверка доступа — в `/api/v1/content/assets/{name}` (см. [15](15-stage5-course-materials.md)).
**V10 — уникальное название курса (реальный файл `V10__course_title_unique.sql`)**
```sql
-- Защита от дублей курса при конкурентном bootstrap и повторном создании.
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));
```
Сервис дополнительно проверяет название без учёта регистра и возвращает `409`
на дубликат; индекс закрывает гонку между проверкой и вставкой.
**V11 — аудитория глобальных глав (реальный файл `V11__global_audience_all.sql`)**
```sql
-- Глобальные главы доступны публично; курсовые остаются с audience = NULL.
UPDATE academic.chapters SET audience = 'ALL' WHERE course_id IS NULL AND audience IS NULL;
```
Backfill нужен для глав, созданных до появления правила: публичное дерево
показывает только глобальные главы с `audience = 'ALL'`. Новые глобальные главы
создаются и импортируются сразу с `audience = 'ALL'`.
**Post-bootstrap шаг academic (вне Flyway) — курс C: завершение привязки лаб**
```sql
-- Выполняется сервисом CourseBootstrapService после переноса данных, когда все
-- лабы получили course_id. Обычный Flyway-старт применяет миграции раньше
-- бизнес-логики и не может дождаться создания курса и назначения владельца.
ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL;
```
### progress ### progress
Схема **не меняется**. Проверки доступа — рантайм (по `lab_id → course`). Схема **в основном не меняется**. Проверки доступа — рантайм (по `lab_id → course`).
**V13 — профиль студента курса: автор комментария**
```sql
ALTER TABLE grade_comments ADD COLUMN IF NOT EXISTS author_account_id uuid;
ALTER TABLE lab_grades ADD COLUMN IF NOT EXISTS updated_by_account_id uuid;
CREATE INDEX IF NOT EXISTS lab_grades_lab_idx ON lab_grades (lab_id);
```
V13 идемпотентна: если старая feature-миграция V10 уже применялась в dev-БД,
достаточно убрать её запись из истории Flyway (см. план реализации).
Индекс `grade_comments (student_id, lab_id)` уже существует (`grade_comments_grade_idx`).
Старые комментарии остаются без автора и отображаются как «автор не указан».
--- ---
@@ -246,6 +377,79 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
2. Доступ отзывается **немедленно** (не хранить список курсов/групп в JWT). 2. Доступ отзывается **немедленно** (не хранить список курсов/групп в JWT).
3. Файлы: преподаватель — только сдачи своих курсов; студент — только свои; **активы** — по видимости материала. 3. Файлы: преподаватель — только сдачи своих курсов; студент — только свои; **активы** — по видимости материала.
### 5.1. Профиль студента курса (требование 2026-10-23)
Преподаватель текущего курса может открыть профиль студента:
- по клику на ФИО в ведомости группы;
- по отдельной кнопке «Профиль».
Доступ:
- преподаватель — только если он активен в `course_teachers` (`removed_at IS NULL`),
а студент входит в группу, привязанную к этому курсу (`course_groups.removed_at IS NULL`);
- админ — любой студент любого курса;
- студент — свой профиль и только опубликованные оценки/комментарии
(опционально; в обязательный объём не входит);
- аноним — доступа нет;
- проверка выполняется на сервисах; чужой курс/студент/сдача → `403` или `404`.
Состав профиля:
- карточка студента: ФИО, №, группа, статус, год набора;
- сводка по курсу: количество лаб, сдано, проверено, средний балл, защиты;
- таблица по каждой лабе курса: номер/название, вариант и уровень, число попыток,
дата последней попытки, оценка, статус публикации, статус защиты, общий комментарий;
- комментарии преподавателей по каждой лабе: текст, тон, автор, дата;
общий комментарий оценки и отдельные `grade_comments`;
- все попытки сдачи по каждой лабе: номер, дата, файлы, скачивание любой попытки
(ZIP и, при необходимости, отдельные файлы).
Ограничения:
- преподаватель видит все лабы своего курса, включая черновики, но не чужие курсы;
- студент видит только опубликованное;
- скачивание файлов не даёт доступа к чужим сдачам;
- профиль не заменяет ведомость: оценки по-прежнему выставляются в ведомости;
- старые комментарии без автора отображаются как «автор не указан»;
- аудит скачиваний реализован на этапе 6 (см. §5.2).
### 5.2. Аудит (реализовано на этапе 6)
Чувствительные действия пишутся в `identity.audit_events` (`V10`): создание и
сброс преподавателя, жизненный цикл и архив групп, повышение и удаление,
действия с курсами/группами/материалами/лабораториями, просмотр профиля
преподавателем или админом, скачивания, оценки/публикация, раздача вариантов
и защиты. Свой профиль студент открывает без записи в аудит, чтобы не
засорять журнал и не нагружать identity на горячем пути. Админ смотрит
журнал на `/admin/audit` с фильтром по действию; время отображается в МСК.
Журнал читается keyset-курсором (`cursor`/`limit`, «Показать ещё»; offset
`page` — только совместимость), а не только
последними N событиями. `membership_history` и `promotion_runs/items`
остаются доменной историей и дополняют общий журнал.
### 5.3. Аутентификация, сессии и отзыв доступа
- **Access-токен** — JWT на 15 минут (`LMS_ACCESS_TOKEN_TTL`), роли
`ADMIN/TEACHER/STUDENT`, без списка курсов/групп внутри (скоуп проверяется
сервисами на каждом запросе).
- **Refresh-токен** — 7 дней (`LMS_REFRESH_TOKEN_TTL`), хранится хешем в
`identity.refresh_tokens` (`V11`), ротируется при каждом обновлении.
Абсолютный потолок сессии — 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`, `V12`):
ротация не продлевает `absolute_expires_at`. Повторное использование старого
токена отзывает все активные сессии аккаунта; выход, смена пароля,
отчисление/выпуск/архив и закрытая группа обновление запрещают. Истёкшие
токены старше retention удаляет ночная задача.
- **Прямой API-клиент** обязан обновлять пару через
`POST /api/v1/auth/refresh`; `POST /api/v1/auth/logout` отзывает refresh.
- **Web-сессия** — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`
(`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе), но не
дольше 30 дней с момента входа (`LMS_SESSION_ABSOLUTE_TIMEOUT`). После
абсолютного срока cookie получает `Max-Age=0`, а пользователь — повторный
вход. Access-токен автоматически обновляется за 2 минуты до истечения,
поэтому активная вкладка не требует повторного входа каждые 15 минут; при
выходе refresh отзывается.
--- ---
## 6. Материалы, публичный доступ и активы ## 6. Материалы, публичный доступ и активы
@@ -253,10 +457,14 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
- Аудитории: `ALL` (в т.ч. **неавторизованные**), `STUDENTS`, `TEACHERS`; глобальные публикует только админ. - Аудитории: `ALL` (в т.ч. **неавторизованные**), `STUDENTS`, `TEACHERS`; глобальные публикует только админ.
- Нужны **публичные маршруты** для `ALL` без аутентификации. `STUDENTS`/`TEACHERS`/курсовые материалы - Нужны **публичные маршруты** для `ALL` без аутентификации. `STUDENTS`/`TEACHERS`/курсовые материалы
анонимно недоступны ни при каких условиях. анонимно недоступны ни при каких условиях.
- **Активы (риск):** материалы ссылаются на файлы (`/api/v1/assets/**`). Для публичных глав активы должны - **Активы (реализовано):** видимость актива наследуется от статьи → раздела →
быть доступны, для приватных — нет. Решение: видимость актива наследуется от главы, и маршрут выдачи главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без
активов обязан это проверять (либо отдельный публичный префикс для `ALL`). Без этого публичные материалы связи со статьёй не публичен: аноним/студент/преподаватель получают `404`,
будут ломаться, а приватные — утекать. админ может скачать файл, открыть страницу `/admin/legacy-assets`, вызвать
`GET /api/v1/content/legacy-assets` или `scripts/legacy-assets.sh` для
привязки или удаления. Старые
внешние ссылки на несвязанные файлы у обычных пользователей перестают
работать — это осознанное secure-by-default поведение.
- Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex). - Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex).
--- ---
@@ -323,13 +531,16 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
--- ---
## 12. Интеграция «Контингент» / next.uniyar.ac.ru (задел) ## 12. Интеграция «Контингент» / next.uniyar.ac.ru — исключена из плана
- Позже — **отдельный сервис-адаптер** (anti-corruption layer); внешняя система — источник истины по Решением от 2026-10-28 интеграция **не входит в объём работ**: нет API-спецификации
курсу/группе/статусу; LMS — зеркало; синхронизация односторонняя + отчёт дрифта. и доступа к внешней системе, а без них можно сделать только непроверяемую заглушку.
- Сейчас закладываем: `student_no` уникальный и стабильный (ключ склейки); внешние id добавим миграцией. `student_no` остаётся уникальным и стабильным, поэтому при появлении нового ТЗ
- Обязательно: идемпотентность, журнал синхронизаций, отчёт расхождений, обработка академа/переводов/отчислений, адаптер можно добавить без переделки модели.
устойчивость к недоступности внешней системы, ПДн.
Если интеграция понадобится позже: отдельный anti-corruption layer, идемпотентная
синхронизация, журнал, отчёт дрифта, обработка академа/переводов/отчислений,
устойчивость к недоступности внешней системы и ПДн.
--- ---
@@ -340,14 +551,22 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
3. Одноимённые группы — уникальность по году набора; отображение с годом. 3. Одноимённые группы — уникальность по году набора; отображение с годом.
4. Академ vs групповой курс — правило перевода и автосоздание целевой группы. 4. Академ vs групповой курс — правило перевода и автосоздание целевой группы.
5. Публичные `ALL`-материалы и **активы** — проверка видимости, иначе утечка/ломка. 5. Публичные `ALL`-материалы и **активы** — проверка видимости, иначе утечка/ломка.
6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема, 6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема;
совместимость старых ссылок. старые ссылки на текущие материалы не поддерживаются.
7. Миграция существующих лаб/материалов без курса (курс по умолчанию / `audience='ALL'`). 7. Миграция существующих лаб/материалов в курс C; «общих» лаб нет.
8. Немедленный отзыв доступа (преподаватель/группа/публикация материала). 8. Немедленный отзыв доступа (преподаватель/группа/публикация материала).
9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации, архив/удаление. 9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации,
архив/удаление — реализован в `identity.audit_events` (`V10`), просмотр
`/admin/audit`.
10. Производительность: индексы `course_id`/`group_id`, пагинация материалов и групп. 10. Производительность: индексы `course_id`/`group_id`, пагинация материалов и групп.
11. Бэкфилл `enrollment_year` для существующих групп — приблизительный, требует подтверждения админом. 11. Бэкфилл `enrollment_year` для существующих групп — приблизительный, требует подтверждения админом.
12. Лабы: уникальность номера — в рамках курса (`(number, course_id)`), прежний ключ по семестру снимается. 12. Лабы: уникальность номера — в рамках курса (`(number, course_id)`), прежний ключ по семестру снимается.
13. Профиль студента курса: скоуп по курсу, все попытки, комментарии с автором,
аудит скачиваний, отсутствие N+1.
14. Статус защиты в ведомости: различать «готова» (нажал студент) и «кончилось время»
(timeout), с разным цветом и подсказкой.
15. Резервное копирование: обязательный backup перед деплоем/миграцией,
ежедневный backup по расписанию, retention, `verify` и restore на staging.
--- ---
@@ -356,10 +575,11 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
0. Схема БД и миграции (аддитивно, с бэкфиллом) — по §4. 0. Схема БД и миграции (аддитивно, с бэкфиллом) — по §4.
1. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ). 1. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ).
2. Курсы + роль `TEACHER` + назначения (с историей) + скоуп-авторизация и тесты. 2. Курсы + роль `TEACHER` + назначения (с историей) + скоуп-авторизация и тесты.
2a. Профиль студента курса: статистика по лабам, комментарии, все попытки, скачивание файлов.
3. Привязка лаб/материалов к курсу, аудитории материалов, публичные `ALL`-маршруты и активы, миграция данных. 3. Привязка лаб/материалов к курсу, аудитории материалов, публичные `ALL`-маршруты и активы, миграция данных.
4. Запись/снятие групп на курс. 4. Запись/снятие групп на курс.
5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск. 5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление. 6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`. 7. ~~Сервис-адаптер «Контингент»/`next.uniyar.ac.ru`~~ — исключён из плана.
Открытых вопросов нет: все решения зафиксированы в §1. Открытых вопросов нет: все решения зафиксированы в §1.

View File

@@ -0,0 +1,391 @@
# 08. План реализации: группы, курсы, преподаватели
**Статус:** план согласован, этапы 0–6 выполнены. Этап 6 закрыт: права
преподавателя на запись, аудит и CI добавлены; продуктовые риски (legacy-активы,
срок жизни JWT/сессии, пагинация аудита) закрыты 2026-10-28. Оставшиеся
ограничения — § 9.1 и [16-stage6-report.md](16-stage6-report.md).
**Основа:** [07-groups-courses.md](07-groups-courses.md).
**Дата решений:** 2026-10-23.
Документ фиксирует решения, принятые при разборе ТЗ, и порядок реализации.
При противоречии с `07-groups-courses.md` приоритет у раздела 1 этого плана;
само ТЗ приводится в соответствие на этапе 0.
---
## 1. Зафиксированные решения
| № | Вопрос | Решение |
|---|---|---|
| 1 | Лабораторные вне курса | Не нужны. Все лабораторные принадлежат курсу. Текущие лабы переносятся в курс «Промышленное программирование на языке C». Все текущие активные группы привязываются к этому курсу. `labs.course_id` сначала nullable, после переноса — `NOT NULL`. Перед миграцией проверяются дубли номеров. |
| 2 | Идентификатор курса в URL | UUID остаётся первичным ключом. Добавляется целочисленный `courses.course_no` (`BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный, неизменяемый). Курсовые материалы открываются по `/learn/course/{course_no}/...`. Все текущие материалы переносятся в курс C и доступны только студентам его групп, преподавателям курса и админу. Старые ссылки не поддерживаются. `audience` используется только для будущих глобальных материалов (`course_id IS NULL`). |
| 3 | Архив и вход | `accounts.status` (`ACTIVE/ARCHIVED`) — для всех пользователей; `students.status` (`ACTIVE/ACADEMIC_LEAVE/EXPELLED/GRADUATED/ARCHIVED`) — академический статус. Вход запрещён для `EXPELLED`, `GRADUATED`, `ARCHIVED` и архивных аккаунтов. `ACADEMIC_LEAVE` — вход в режиме только чтения. Web перепроверяет статус с кэшем 30–60 секунд; сервисы проверяют доступ к курсу при каждом запросе. |
| 4 | Журнал повышения | `promotion_runs` + `promotion_run_items` + `membership_history`. Dry-run сохраняется как план; применение идемпотентно; откат — по элементам журнала с проверкой конфликтов. |
| 5 | Объём | Полный ТЗ, этапы 0–6, один релиз с внутренними merge gate. |
| 6 | Запуск повышения | 31 августа в 00:00 МСК автоматически строится dry-run. Применение — только вручную админом после проверки плана. При пропуске даты dry-run строится при следующем старте. |
| 7 | Профиль студента курса | Преподаватель своего курса может открыть профиль студента по клику на ФИО или отдельной кнопке: статистика по лабам, комментарии преподавателей, скачивание файлов любой попытки сдачи. |
| 8 | Резервное копирование | Обязательный backup перед каждым деплоем/миграцией, ежедневный backup по расписанию, retention, проверка целостности и restore на staging. Без успешного backup деплой не продолжается. |
---
## 2. Новое требование: профиль студента курса
### 2.1. Доступ
- **Преподаватель** — только если он активен в `course_teachers` для этого курса
(`removed_at IS NULL`), а студент входит в группу, привязанную к этому курсу
(`course_groups.removed_at IS NULL`).
- **Админ** — любой студент любого курса.
- **Студент** — свой профиль и только опубликованные оценки/комментарии
(опционально; в обязательный объём текущего требования не входит).
- **Аноним** — доступа нет.
- Проверка выполняется на сервисах, а не только в web-BFF. Чужой курс/студент
должен давать `403` или `404`, а не данные.
### 2.2. Состав профиля
Карточка студента:
- ФИО, № студента, группа, статус, год набора;
- контактные/служебные поля, если появятся позже.
Сводка по курсу:
- количество лабораторных курса;
- сколько сдано, сколько проверено;
- средний балл;
- количество защит и их статусы.
Таблица по каждой лабораторной курса:
- номер и название лабы;
- вариант и уровень (если назначены);
- количество попыток сдачи;
- дата последней попытки;
- оценка;
- статус публикации оценки;
- статус защиты;
- общий комментарий к оценке;
- количество комментариев.
Комментарии преподавателей по каждой лабе:
- общий комментарий оценки (`lab_grades.general_comment`);
- отдельные комментарии (`grade_comments`) с текстом, тоном, автором и датой;
- для старых комментариев без автора — «автор не указан»;
- новые комментарии сохраняют `author_account_id`.
Все попытки сдачи по каждой лабе:
- номер попытки, дата, список файлов;
- скачивание любой попытки, а не только последней;
- скачивание в рамках своего курса; чужой `submissionId` запрещён;
- для преподавателя — ZIP попытки и, при необходимости, отдельные файлы;
- для студента — только свои попытки.
### 2.3. API (предварительно)
- `GET /api/v1/courses/{courseId}/students/{studentId}/profile` — агрегированный
профиль студента курса.
- `GET /api/v1/courses/{courseId}/submissions/{submissionId}/download` — ZIP попытки
для преподавателя/админа.
- `GET /api/v1/courses/{courseId}/submissions/{submissionId}/files/{name}` —
отдельный файл попытки (если включаем отдельные файлы).
- `GET /api/v1/me/courses/{courseId}/profile` — собственный профиль студента
(только опубликованное), опционально; в обязательный объём не входит.
`courseId` в API — UUID. В браузерных URL используется `course_no`.
### 2.4. UI
- В ведомости группы ФИО студента становится ссылкой на профиль.
- В колонке действий появляется отдельная кнопка «Профиль».
- Страница преподавателя: `/teacher/courses/{courseNo}/students/{studentId}`.
- Админ открывает тот же профиль по `/admin/courses/{courseNo}/students/{studentId}`
или через общий маршрут с проверкой роли.
- Профиль не показывает чужие курсы и глобальные материалы.
- Для архивных/выпущенных студентов профиль открывается в режиме истории, если
студент был в курсе; иначе — `404`.
### 2.5. Данные и миграции
- `grade_comments` добавить `author_account_id uuid NULL`; индекс
`(student_id, lab_id)` уже существует.
- Для общего комментария оценки добавить `lab_grades.updated_by_account_id`
(автор последнего изменения) и индекс `lab_grades(lab_id)`.
- Все попытки уже хранятся в `submissions` (`student_id`, `lab_id`, `seq`);
отдельная таблица не нужна.
- Для производительности: один агрегирующий запрос/эндпоинт, пакетная выборка
комментариев, попыток, защит и публикаций по списку `lab_id`; без N+1.
- Аудит скачиваний реализован на этапе 6 в `identity.audit_events`.
### 2.6. Ограничения
- Преподаватель видит все лабы своего курса, включая черновики, но не может
видеть лабы других курсов.
- Студент видит только опубликованные оценки и комментарии.
- Скачивание файлов не даёт доступа к чужим сдачам.
- Профиль не заменяет ведомость: оценки по-прежнему выставляются в ведомости,
профиль — просмотр и скачивание.
---
## 3. Этапы
### Этап 0. Контракты и схема
- Обновить `07-groups-courses.md` и ADR под решения раздела 1.
- Описать API профиля студента и формат ответа.
- Миграции identity/academic/progress.
- Preflight: группы, не подходящие под шаблон имени; дубли номеров лаб;
материалы без курса; комментарии без автора.
- Проверить миграции на пустой БД и на копии текущей.
- Определить механизм `labs.course_id NOT NULL`: обычный Flyway-старт не может
дождаться bootstrap-курса. Варианты — seed курса C в миграции с временно
nullable владельцем либо отдельный post-bootstrap шаг. Решение — в ADR.
**Выход:** схема, контракты, отчёт о рисках; код бизнес-логики ещё не пишем.
### Этап 1. Identity
- Роли `ADMIN/TEACHER/STUDENT`.
- Поля групп и парсер имени.
- Статусы студентов и аккаунтов.
- `membership_history`.
- Импорт Excel v2 с построчным отчётом.
- Админ-UI групп и пользователей.
### Этап 2. Academic
- `courses`, `course_teachers`, `course_groups`.
- `course_no` и URL курса.
- `labs.course_id`, перенос лаб в курс C, копирование лаб.
- `chapters.course_id/audience`, перенос материалов в курс C.
- Частичные уникальные индексы slug.
- Активы: связь с главой/статьёй и наследуемая видимость.
### Этап 3. Доступ и профиль (backend)
- `TEACHER` в JWT и security-конфигах.
- Внутренние access-эндпоинты academic/identity.
- Проверки курса, группы, студента и публикации на сервисах.
- Профиль студента курса: агрегирующий endpoint, все попытки, комментарии,
скачивание файлов.
- Negative-тесты: чужой курс, чужой студент, чужая сдача, аноним.
### Этап 4. Lifecycle
- Повышение: dry-run, журнал, применение, откат, выпуск.
- Академический отпуск и возврат.
- Отчисление, выпуск, архив.
- Усиленное удаление и аудит.
### Этап 5. UX
- Кабинет преподавателя.
- Статус защиты в ведомости: «готова» / «кончилось время» (timeout),
с разным цветом и подсказкой; сейчас в матрице всегда показывается «готова».
- Страница профиля студента: ссылка с ФИО и отдельная кнопка.
- Студенческие страницы курсов.
- Админ-страницы курсов, повышения, отпусков, архива, импорта и копирования лаб.
- Публичные глобальные материалы `ALL`.
### Этап 6. Hardening
- Права преподавателя на запись: лабораторные/уровни/варианты, ведомость,
привязка групп, авторские материалы курса.
- Полная security-матрица и negative-тесты «свой/чужой курс».
- Upgrade-тест миграций.
- Индексы; keyset-пагинация аудита, bulk-выдача групп и пакетные детали
лабораторных — выполнены.
- Аудит скачиваний и чувствительных действий (`identity.audit_events`,
`/admin/audit`).
- CI на Gitea + Woodpecker: `check` (`test ktlintCheck detekt evidenceCheck`)
и `smoke` на каждом push/MR; полный `release` — для тегов/вручную.
- Проверка backup: создание, retention, `verify`, restore на staging.
- Документация, deploy/runbook, smoke.
---
## 4. Ориентировочные миграции
Номера уточняются при реализации, чтобы не конфликтовать с максимумами master
(identity V3, **academic V5**, progress V12). Максимумы этой ветки:
identity V14, **academic V12**, progress V13.
| Сервис | Миграции | Содержание |
|---|---|---|
| identity | V4–V14 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен); `V12` — `absolute_expires_at`; `V13` — keyset-индексы аудита; `V14` — NOT NULL/уникальность когорты |
| academic | V6–V12 + post-bootstrap step | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `V10` — уникальный индекс `lower(title)` по курсам; `V11` — backfill `audience='ALL'` для глобальных глав; `V12` — backfill связей `article_assets` из markdown-ссылок; `SET NOT NULL` — post-bootstrap шаг вне Flyway |
| progress | V13 | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит реализован в identity `V10` |
### Переход со старой feature-миграции V10 на V13
Старая `progress V10__profile_authors.sql` — временная feature-миграция, её нет
в master.
- **Production / master:** есть только V11 и V12, записи V10 нет — V13 применяется
обычным порядком, ничего делать не нужно.
- **Dev/feature-БД, где `V10__profile_authors.sql` уже применена и имеет
`success=true`:** удалить только эту запись истории, ничего не удаляя из схемы;
V13 идемпотентна и сама создаст недостающие колонки и индекс. Используйте
`flyway repair` или точечно:
`DELETE FROM progress.flyway_schema_history WHERE version = '10' AND script = 'V10__profile_authors.sql' AND success;`.
- **Staging-БД**, которые ненадолго запускали master-коммит `bfdae41` (out-of-order
+ V10), тоже содержат запись V10 — для них действует тот же порядок.
- **Если V10 осталась с `success=false`:** сначала убедитесь, что её частичные
изменения откатились на уровне объектов, затем `flyway repair` или удаление
неуспешной строки; V13 досоздаст недостающее.
- Перед очисткой и деплоем проверьте состояние через
`deploy/preflight/progress-v10-v13.sql`.
- Переход **forward-only**: после V13 откат на сборку без V13 не пройдёт валидацию
Flyway. Не оставляйте `out-of-order` включённым.
- **Нельзя** переставлять V11/V12.
---
## 5. Агенты
| Агент | Зона | Границы |
|---|---|---|
| `lms-orchestrator` | контракты, ADR, merge, review | не пишет массово код |
| `lms-db` | миграции и тесты миграций | только `db/migration/**` и фикстуры |
| `lms-identity` | identity-service | только identity и его тесты |
| `lms-academic` | academic-service | только academic и его тесты |
| `lms-progress` | progress-service, профиль, сдачи | только progress и его тесты |
| `lms-web` | web, UI профиля, кабинеты | только `web/**` |
| `lms-security` | `libs/security-jwt`, gateway, security-review | без бизнес-логики |
| `lms-qa` | матрица ролей, upgrade, smoke | production-код не правит |
| `lms-docs` | docs, deploy/runbook | только документация |
---
## 6. Skills
- `lms-context` — инварианты и карта модулей.
- `lms-migrations` — Flyway, двухфазные миграции, preflight, legacy-БД.
- `lms-authz` — ролевая матрица, internal access, negative-тесты.
- `lms-identity-domain` — группы, статусы, история, импорт, повышение.
- `lms-academic-domain` — курсы, лабы, материалы, аудитории, активы.
- `lms-web-ui` — Thymeleaf/Bootstrap/JS, формы, навигация.
- `lms-testing` — Testcontainers, MockMvc с JWT, фикстуры.
- `lms-release` — lint/detekt/test, bootJar, `run.sh`, smoke.
---
## 7. Контроль качества
1. Миграции на пустой БД и на копии текущей; preflight-отчёт.
2. Security-матрица: аноним, студент свой/чужой, преподаватель свой/чужой,
админ, архивный/выпущенный.
3. Профиль: чужой курс/студент/сдача → `403/404`; все попытки скачиваются;
комментарии с автором; нет N+1.
4. `./gradlew ktlintCheck detekt test` после каждого этапа.
5. UI-smoke для четырёх ролей, старые и новые URL, публичные/приватные активы.
6. Аудит скачиваний и чувствительных действий.
7. Backup: обязательный перед деплоем, ежедневный, с проверкой restore.
8. Документация и runbook миграций.
---
## 8. Риски и открытые вопросы
- **Большой объём одного релиза.** Снижаем контрактами, merge gate и независимым QA.
- **Межсервисная авторизация.** Самый сложный участок; ADR до кода.
- **Перенос данных.** Нужны preflight и копия БД; группы с нестандартными именами
и дубли номеров лаб могут заблокировать `NOT NULL`.
- **Legacy-активы.** Закрыто: связанные активы наследуют видимость статьи
(миграция `V12`), а файлы без markdown-ссылки по умолчанию отдают `404`
анониму/студенту/преподавателю. Админ перечисляет их через
`GET /api/v1/content/legacy-assets`, страницу `/admin/legacy-assets` или
`scripts/legacy-assets.sh` для привязки или удаления.
- **JWT и сессии.** Закрыто: access-токен 15 минут, refresh 7 дней с ротацией
и отзывом, web-сессия 7 дней sliding с абсолютным потолком 30 дней.
Прямые API-клиенты используют `POST /api/v1/auth/refresh`; logout и смена
пароля отзывают refresh; истёкшие токены чистит ночная задача.
- **Производительность.** Аудит продолжается keyset-курсором, детали всех
лабораторных курса и группы курса отдаются пакетно. Отдельный запрос сдач
по выбранной лабораторной в ведомости остаётся, но это не N+1 по студентам.
- **Комментарии без автора.** Старые записи останутся с «автор не указан».
- **Архивные студенты.** Открывать ли профиль после отвязки группы — уточнить
на этапе 3; по умолчанию история курса доступна, если студент был в курсе.
---
## 9. Статус реализации
- **Этап 0 — выполнен.** Схема, contract API профиля, preflight, ADR,
проверка на копии БД. Отчёт: [10-stage0-report.md](10-stage0-report.md).
- **Этап 1 — выполнен.** Роли, группы-когорты, статусы, история, импорт v2,
админ-UI. Отчёт: [11-stage1-report.md](11-stage1-report.md).
- **Этап 2 — выполнен.** Курсы и `course_no`, история преподавателей и групп,
bootstrap курса C, привязка лабораторных к курсу, копирование лаб,
админ-UI курсов и лабораторных. Отчёт: [12-stage2-report.md](12-stage2-report.md).
- **Этап 3 — выполнен.** Аккаунты преподавателей, курсовая область видимости,
внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет и состав групп
преподавателя, профиль студента курса и скачивание всех попыток.
Отчёт: [13-stage3-report.md](13-stage3-report.md).
- **Этап 4 — выполнен.** Повышение (dry-run/apply/rollback, планировщик
31 августа МСК и догон), жизненный цикл студента, архив группы,
усиленное подтверждение удаления, блокировка входа для архивных
и выпущенных групп. Отчёт: [14-stage4-report.md](14-stage4-report.md).
- **Этап 5 — выполнен.** Студенческие страницы курсов, материалы по
`/learn/course/{course_no}/...` и аудитории, публичные материалы `ALL`,
скрытые материалы для преподавателя, перепроверка статуса web-сессии.
Отчёт: [15-stage5-course-materials.md](15-stage5-course-materials.md).
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**398**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V14`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **66 claims
(27 P0 / 39 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6
- **Импорт.** Старый формат не поддерживается, принимается только шаблон v2.
Номер группы — числовой; отдельного требования к ведущим нулям нет.
CSV/XLSX preflight покрыт тестами; гонка с параллельной вставкой логина
всё ещё может дать общий 409.
- **Пагинация и N+1.** Аудит продолжается keyset-курсором `(occurred_at, id)`
с offset-fallback; `GET /api/v1/labs/details` отдаёт все лабораторные курса
одним запросом; группы курса — одним вызовом. Сдачи ведомости собираются
одним запросом на все выбранные лабы; группы и плоский список материалов
поддерживают `limit`/`offset`. Страница группы и профиль имеют собственные
лимиты; дерево материалов по-прежнему загружается целиком для навигации.
- **Legacy-активы.** Файлы без статьи уже не публичны; админская страница
`/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. Остаётся
прогнать инвентаризацию на production/staging и привязать или удалить файлы.
- **Визуальная регрессия.** Playwright покрывает сценарии и mobile, но
screenshot-diff отсутствует.
- **Сессии.** Web-сессия скользящая 7 дней, абсолютный потолок 30 дней
(`LMS_SESSION_ABSOLUTE_TIMEOUT`); истёкшие refresh-токены чистит ночная
задача (`LMS_REFRESH_TOKEN_RETENTION`).
- **CI.** Конфигурация `.woodpecker/` и `ci-preflight.sh` готовы; Woodpecker
server/agent и первый реальный pipeline ещё нужно развернуть и подтвердить
(`deploy/WOODPECKER.md`).
- **Внешняя интеграция «Контингент»/`next.uniyar.ac.ru` исключена из плана**
до появления нового ТЗ и доступа к API.
## 10. Definition of Done
- Все решения раздела 1 реализованы.
- Текущие лабы и материалы работают в курсе C.
- Преподаватель видит только свои курсы и профили своих студентов, пишет
в лабы/ведомость/группы/материалы только своего курса.
- Профиль показывает статистику, комментарии и все попытки; файлы скачиваются.
- Повышение, академотпуск, выпуск и архив работают по ТЗ.
- Аудит чувствительных действий пишется в `identity.audit_events` и доступен
админу на `/admin/audit` с keyset-пагинацией.
- Активы наследуют видимость материала; файлы без привязки не публичны и
доступны только админу через страницу `/admin/legacy-assets`.
- Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней /
7 дней sliding с абсолютным потолком 30 дней; logout, смена пароля и
закрытие доступа отзывают refresh.
- Security-матрица и миграционный upgrade-тест зелёные (identity до `V14`).
- CI (Gitea + Woodpecker) на каждом push/MR прогоняет
`test ktlintCheck detekt evidenceCheck` и smoke полного стека; полный
`release` обязателен для тегов.
- Backup перед деплоем обязателен; restore проверен на staging.
- Документация, deploy/runbook и smoke обновлены.

View File

@@ -0,0 +1,292 @@
# 09. Контракт API: профиль студента курса
**Статус:** контракт этапа 0, реализация — этапы 3 и 5.
**Связанные документы:** [07-groups-courses.md](07-groups-courses.md),
[08-groups-courses-plan.md](08-groups-courses-plan.md),
[ADR 0001](adr/0001-groups-courses-foundation.md).
## 1. Назначение
Преподаватель своего курса открывает профиль студента и видит:
- статистику по лабораторным курса;
- комментарии преподавателей по каждой лабе;
- все попытки сдачи и может скачать файлы любой попытки.
Профиль ограничен одним курсом. Он не заменяет ведомость и не даёт доступа
к другим курсам или чужим сдачам.
## 2. Авторизация и скоуп
| Роль | Доступ |
|---|---|
| `ADMIN` | любой студент любого курса |
| `TEACHER` | только активный преподаватель курса (`course_teachers.removed_at IS NULL`) и только студент группы, привязанной к курсу (`course_groups.removed_at IS NULL`) |
| `STUDENT` | только свой профиль и только опубликованные оценки/комментарии (опционально) |
| Аноним | нет |
Проверки выполняются на сервисах:
1. JWT валиден, роль допустима.
2. Курс существует и не архивирован (для преподавателя).
3. Для `TEACHER` есть активная запись в `course_teachers`.
4. Студент существует, его аккаунт не архивный.
5. Группа студента привязана к курсу (`course_groups.removed_at IS NULL`).
6. Для скачивания сдача принадлежит этому студенту и лабе этого курса.
Чужой курс, студент или сдача → `403`/`404` без раскрытия данных.
## 3. Основной endpoint
```http
GET /api/v1/courses/{courseId}/students/{studentId}/profile
Authorization: Bearer <JWT>
```
Параметры:
- `courseId` — UUID курса;
- `studentId` — UUID аккаунта студента (`students.account_id`).
Ответ `200 application/json`:
```json
{
"course": {
"id": "3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e",
"courseNo": 1,
"title": "Промышленное программирование на языке C"
},
"student": {
"id": "7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"studentNo": "123456",
"fullName": "Иванов Иван Иванович",
"groupId": "11111111-2222-3333-4444-555555555555",
"groupName": "ПИ-21БО",
"status": "ACTIVE",
"enrollmentYear": 2025
},
"summary": {
"labsTotal": 8,
"submitted": 6,
"graded": 5,
"averageScore": 4.2,
"defensesTotal": 5,
"defensesReady": 3
},
"labs": [
{
"labId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"number": 1,
"title": "Введение в C",
"deadlineAt": "2026-10-01T20:59:00Z",
"variant": {
"id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"number": "3",
"title": "Вариант 3"
},
"level": {
"id": "cccccccc-dddd-eeee-ffff-000000000000",
"code": "base",
"title": "Базовый"
},
"attemptsCount": 2,
"lastSubmissionAt": "2026-09-28T18:12:00Z",
"grade": {
"score": 4.5,
"published": true,
"generalComment": "Хорошая работа, но есть замечания по стилю.",
"updatedBy": {
"id": "dddddddd-eeee-ffff-0000-111111111111",
"name": "Петров Пётр Петрович"
}
},
"comments": [
{
"id": "eeeeeeee-ffff-0000-1111-222222222222",
"text": "Разделите функцию на части.",
"tone": "neutral",
"author": {
"id": "dddddddd-eeee-ffff-0000-111111111111",
"name": "Петров Пётр Петрович"
},
"createdAt": "2026-09-29T09:30:00Z"
}
],
"defense": {
"sessionId": "ffffffff-0000-1111-2222-333333333333",
"status": "ready",
"readyReason": "timeout",
"code": null,
"assignmentMd": "Дополнительное задание..."
},
"submissions": [
{
"id": "00000000-1111-2222-3333-444444444444",
"seq": 1,
"createdAt": "2026-09-27T16:00:00Z",
"files": ["main.c", "Makefile"],
"downloadUrl": "/api/v1/courses/3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e/submissions/00000000-1111-2222-3333-444444444444/download"
},
{
"id": "00000000-5555-6666-7777-888888888888",
"seq": 2,
"createdAt": "2026-09-28T18:12:00Z",
"files": ["main.c", "Makefile", "README.md"],
"downloadUrl": "/api/v1/courses/3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e/submissions/00000000-5555-6666-7777-888888888888/download"
}
]
}
]
}
```
Правила:
- `labs` — все лабы курса для преподавателя/админа; для студента — только
опубликованные.
- `grade` и `comments` для студента отдаются только если оценка опубликована
(`grade_publications` / `grade_publication_students`).
- `comments` включает общий комментарий? Нет: общий комментарий — поле
`grade.generalComment`; отдельные комментарии — массив `comments`.
- `author` может быть `null`, если автор не сохранён (старые данные).
- `averageScore` — среднее по выставленным оценкам, `null` если оценок нет.
- `submissions` — последние попытки по убыванию `seq`, по умолчанию не более
50 (`submissionLimit`, максимум 500; `0` — все); `submissionsTotal` — всего попыток,
`attemptsCount` — тоже полное число. Файлы — имена без пути.
- `downloadUrl` относительный; web-BFF добавляет авторизацию.
## 4. Скачивание попытки
```http
GET /api/v1/courses/{courseId}/submissions/{submissionId}/download
Authorization: Bearer <JWT>
```
- Возвращает ZIP со всеми файлами попытки.
- Проверки: сдача принадлежит студенту и лабе курса; преподаватель активен в
курсе; админ — всегда.
- Имя файла: `submission-{labId-prefix}-try{seq}.zip`.
- Ошибки: `403` — чужая сдача/курс, `404` — сдача не найдена.
Опционально:
```http
GET /api/v1/courses/{courseId}/submissions/{submissionId}/files/{filename}
```
- Отдаёт один файл.
- Имя проверяется на path traversal; файл должен быть в каталоге попытки.
## 5. Внутренние эндпоинты
Не маршрутизируются gateway, защищены `X-Internal-Token` и пользовательским JWT
там, где он нужен.
### identity
```http
GET /api/v1/internal/students/{studentId}/context
```
```json
{
"accountId": "7a1b2c3d-...",
"role": "STUDENT",
"accountStatus": "ACTIVE",
"studentStatus": "ACTIVE",
"studentNo": "123456",
"fullName": "Иванов Иван Иванович",
"groupId": "11111111-...",
"groupName": "ПИ-21БО",
"enrollmentYear": 2025
}
```
### academic
```http
GET /api/v1/internal/courses/{courseId}/access?accountId={accountId}&groupId={groupId}
```
```json
{
"courseId": "3f2a7c1e-...",
"courseStatus": "ACTIVE",
"teacherActive": true,
"groupAttached": true
}
```
```http
GET /api/v1/internal/courses/{courseId}/labs
```
Возвращает лабы курса с `id`, `number`, `title`, `deadlineAt`, `status`,
`pointsMax`, `variant/level` при необходимости.
## 6. Ошибки
| Код | Когда |
|---|---|
| `401` | нет/просрочен JWT |
| `403` | роль не подходит, чужой курс, преподаватель не активен, студент не в группе курса |
| `404` | курс, студент, лаба или сдача не найдены |
| `409` | профиль недоступен из-за конфликта данных (например, студент без группы) |
Тело ошибки — общий `ApiError` проекта.
## 7. Производительность
- Один агрегирующий запрос на профиль, без N+1.
- Пакетная выборка по списку `labId`:
- оценки — `lab_grades` по `student_id` и `lab_id IN (...)`;
- комментарии — `grade_comments` по `student_id` и `lab_id IN (...)`;
- сдачи — `submissions` по `student_id` и `lab_id IN (...)`;
- защиты — `defense_sessions` по `student_id` и `lab_id IN (...)`;
- публикации — `grade_publications`/`grade_publication_students` по группе и лабам.
- Список лаб курса кэшируется на время запроса.
- Для больших курсов — лимит попыток: `submissionLimit` (по умолчанию 50,
максимум 500, `0` — все) и `submissionsTotal` для индикатора «показаны последние N»;
`attemptsCount` не зависит от лимита.
- Индексы: `submissions(student_id, lab_id, seq)` уже уникален;
`grade_comments(student_id, lab_id)` уже есть; `lab_grades(lab_id)` добавлен;
`defense_sessions(student_id, lab_id)` уникален.
## 8. UI
- В ведомости группы ФИО — ссылка на профиль.
- В колонке действий — кнопка «Профиль».
- Маршрут преподавателя: `/teacher/courses/{courseNo}/students/{studentId}`.
- Админ: `/admin/courses/{courseNo}/students/{studentId}` или общий маршрут
с проверкой роли.
- В профиле:
- карточка студента;
- сводка;
- таблица лаб;
- блок комментариев;
- список попыток с кнопками скачивания.
- Статус защиты: «готова» / «кончилось время» (timeout).
## 9. Тесты
- `ADMIN` видит любого студента.
- `TEACHER` своего курса — `200`; чужого курса — `403`.
- `TEACHER` чужого студента — `403`.
- `STUDENT` свой профиль — `200`; чужой — `403`.
- Аноним — `401`.
- Скачивание своей попытки — `200`; чужой — `403`.
- Студент не видит неопубликованные оценки/комментарии.
- Все попытки возвращаются (в пределах `submissionLimit`), а не только последняя;
`submissionsTotal`/`attemptsCount` показывают полное число.
- Профиль на 10 лабораторных делает не более 15 SQL-запросов (без N+1).
- Комментарии содержат автора или `null`.
- Запрос не делает N+1 (проверка по числу SQL-запросов в интеграционном тесте).
## 10. Открытые вопросы
- Показывать ли профиль для архивных/отвязанных студентов (история или `404`).
- Нужен ли студенческий просмотр своего профиля в первом релизе.
- ~~Формат аудита скачиваний: общий `audit_events` или отдельная таблица.~~
Решено на этапе 6: общий `identity.audit_events` (`V10`).
- Ограничение количества попыток в ответе и пагинация — follow-up.

99
docs/10-stage0-report.md Normal file
View File

@@ -0,0 +1,99 @@
# 10. Отчёт этапа 0: схема, контракты, preflight
**Дата:** 2026-10-23.
**Статус:** этап 0 выполнен на локальной копии БД; перед production нужен
повторный preflight на свежей копии.
## 1. Что сделано
- ADR с ключевыми решениями: [adr/0001-groups-courses-foundation.md](adr/0001-groups-courses-foundation.md).
- Контракт API профиля студента: [09-teacher-student-profile.md](09-teacher-student-profile.md).
- Миграции:
- identity `V4–V13`: роли, группы-когорты, статусы студентов, повышение,
история членства, статусы аккаунтов; `V10` (`audit_events`) добавлена
позже, на этапе 6; `V11` (`refresh_tokens`) — при закрытии рисков
аутентификации, тоже после этого отчёта; `V12` (`absolute_expires_at`) и
`V13` (keyset-индексы аудита) — при финальной доработке;
- academic `V6–V12`: курсы, преподаватели, группы курса, `labs.course_id`,
материалы курса/аудитории, индексы активов; `V10` (уникальный индекс
`lower(title)` по курсам) добавлена на этапе 2, после этого отчёта;
`V11` backfill-ом проставляет `audience='ALL'` глобальным главам;
`V12` backfill-ом восстанавливает связи `article_assets` из markdown-ссылок;
- progress `V13`: автор комментария и последнего изменения оценки.
- Preflight-скрипты:
- `deploy/preflight/groups-courses.sql` — до миграций;
- `deploy/preflight/groups-courses-post.sql` — после миграций/bootstrap;
- `deploy/preflight/progress-v10-v13.sql` — переход старой feature-миграции
progress V10 на V13.
- UI: статус защиты в ведомости различает «готова» и «кончилось время».
- Резервное копирование:
- `deploy/backup.sh` — БД + решения + материалы, checksums, retention,
`list`, `verify`, `restore`;
- `deploy/deploy.sh up` делает обязательный backup перед обновлением;
- systemd timer для ежедневного backup;
- инструкция `deploy/BACKUP.md`.
## 2. Проверка на пустой БД
Все миграции identity, academic и progress применены последовательно на чистой
БД PostgreSQL 18. Ошибок нет. Тесты сервисов на Testcontainers проходят.
## 3. Проверка на копии локальной БД
Скопирована локальная БД `lms` (41 аккаунт, 40 студентов, 2 группы, 1 лаба,
3 главы, 16 разделов, 35 статей, 6 комментариев).
Preflight до миграций:
- групп, не подходящих под шаблон, нет;
- конфликтующих кортежей когорт нет;
- дублей номеров лабораторных нет;
- дублей slug глав нет;
- студентов без группы нет.
Миграции применены успешно.
После миграций:
- `labs_without_course = 1` — ожидаемо до bootstrap;
- `chapters_without_course = 3` — ожидаемо до bootstrap;
- `courses = 0` — ожидаемо до bootstrap;
- `comments_without_author = 6` — ожидаемо для старых комментариев;
- группы разобраны полностью, `enrollment_year` заполнен.
## 4. Симуляция bootstrap
На копии вручную выполнено то, что будет делать bootstrap-сервис:
1. Создан курс C с владельцем-админом, `course_no = 1`.
2. Админ назначен преподавателем курса.
3. Обе активные группы привязаны к курсу.
4. Все лабы и главы получили `course_id`.
5. Выполнено `ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL`.
Postflight:
- `labs_without_course = 0`;
- `chapters_without_course = 0`;
- курс C ровно один;
- `comments_without_author = 6` — старые данные, ожидаемо;
- группы без полного разбора отсутствуют.
## 5. Тесты и линтеры
- `./gradlew :services:identity-service:test :services:academic-service:test :services:progress-service:test :web:test` — успешно.
- `./gradlew ktlintCheck detekt` — успешно.
- `node --check web/src/main/resources/static/js/admin-group.js` — успешно.
- `bash -n deploy/backup.sh deploy/deploy.sh` — успешно.
- `./deploy/backup.sh backup` на локальной БД — успешно; `verify` проверил
`db.dump`, `submissions.tar.gz`, `assets.tar.gz` по SHA256.
- `git diff --check` — успешно.
## 6. Что осталось на этап 2
- Реализовать bootstrap-сервис курса C (создание курса, привязка групп,
назначение админа, перенос лаб/глав).
- Выбрать механизм post-bootstrap `SET NOT NULL` (CLI, admin endpoint или
отдельная Flyway-локация).
- Связать существующие активы со статьями при миграции контента.
- Повторить preflight на свежей копии production-БД перед деплоем.

95
docs/11-stage1-report.md Normal file
View File

@@ -0,0 +1,95 @@
# 11. Отчёт этапа 1: identity
**Дата:** 2026-10-23.
**Статус:** этап 1 выполнен; этап 2 (academic) — следующий.
## 1. Что сделано
### Роли и аккаунты
- Добавлена роль `TEACHER` в `LmsRole`; БД уже допускает её миграцией `V4`.
- `Account` получил `status` (`ACTIVE/ARCHIVED`) и `archivedAt`.
- Вход запрещён для архивных аккаунтов и студентов в статусах
`EXPELLED/GRADUATED/ARCHIVED`.
### Группы-когорты
- `GroupEntity`: `stream`, `course`, `number`, `degree`, `enrollmentYear`,
`status` (`ACTIVE/GRADUATED/ARCHIVED`).
- `name` больше не уникально; уникальность — по кортежу когорты.
- `GroupNameParser` разбирает `ПИ-11БО`, `ИВТ-ТОП-12БО` и т.п.
- `GroupCohortResolver` валидирует степень, курс, номер и год набора,
вычисляет год набора по умолчанию.
- Создание группы через админку — по полям, имя собирается автоматически.
### Студенты и история
- `Student`: `status` и `enrollmentYear`.
- `MembershipHistory`: `ENROLL`, `TRANSFER`, `IMPORT`, `EXPELL` и остальные
действия; записи создаются при зачислении, переводе, импорте и отчислении.
- При отчислении студент получает `EXPELLED`, аккаунт и данные сохраняются.
- Повторное зачисление возвращает `ACTIVE` и пишет `TRANSFER`.
### Импорт Excel/CSV v2
Шаблон:
```text
поток | курс | номер | степень | год набора | ФИО | № | база
```
- Поддерживаются `.xls`, `.xlsx`, `.csv`, разделители `;`, `,`, tab.
- ФИО можно одной колонкой или отдельными `Фамилия/Имя/Отчество`.
- Все строки должны быть одной группой.
- Проверяются степень, диапазон курса, год набора, пустые поля,
дубли `№` внутри файла.
- Импорт атомарный: при ошибках данные не меняются, возвращается отчёт
с номерами строк.
- Ключ слияния — `student_no`; группа ищется по кортежу, при отсутствии
создаётся.
- Новые студенты получают временный пароль; пароли показываются один раз.
### UI
- Страница групп: год набора, статус, создание по полям, описание нового импорта.
- Страница группы: год набора, статус группы, статус студента.
- Страница пользователей: статус студента.
- При ошибке импорта показывается таблица «строка — ошибка».
## 2. Тесты
Добавлены:
- `GroupNameParserTest` — разбор имени, диапазоны курсов;
- `StudentImportParserTest` — валидный шаблон, дубли, разные группы,
недопустимый курс, неизвестная шапка;
- `GroupServiceIntegrationTest` — создание когорты, дубликат, импорт,
атомарность ошибок, перевод, отчисление, запрет входа после отчисления.
Проверки:
- `./gradlew :services:identity-service:test :web:test` — успешно;
- `ktlintCheck` и `detekt` для identity/web — успешно.
## 3. Ограничения этапа
- Импорт поддерживает одну группу на файл; мультигрупповой файл — позже.
- Номер группы числовой (`01` и `1` — одно значение). Отдельный строковый
номер и различие `ПИ-11БО`/`ПИ-101БО` как разных когорт не требуются;
имя группы по-прежнему строится из полей.
- Роль `TEACHER` уже есть в домене, но кабинет преподавателя и назначения
на курс — этапы 2–3.
- `ACADEMIC_LEAVE` пока может входить; режим «только чтение» — этап 3.
- Отчисление пока простое; причины, подтверждение и аудит — этап 4.
- Старый формат импорта (только студенческие колонки) больше не поддерживается.
## 4. Следующий этап
Этап 2 — academic:
- курсы, `course_teachers`, `course_groups`;
- `course_no` и `/learn/course/{course_no}/...`;
- `labs.course_id` и перенос лаб в курс C;
- `chapters.course_id/audience`, перенос материалов;
- активы и копирование лабораторных;
- админ-UI курсов.

158
docs/12-stage2-report.md Normal file
View File

@@ -0,0 +1,158 @@
# 12. Отчёт этапа 2: academic (курсы и лабораторные)
> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID.
**Дата:** 2026-10-27.
**Статус:** этап 2 выполнен; этап 3 (доступ и профиль студента курса) — следующий.
## 1. Что сделано
### Курсы
- Добавлены таблицы `courses`, `course_teachers`, `course_groups` (миграция `V6`).
- `courses.course_no` — `BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный номер
для человекочитаемых URL; UUID остаётся первичным ключом.
- CRUD курсов: создание, просмотр, переименование, перевод в архив и возврат
в работу. Название нормализуется (trim), пустое заменяется на «Новый курс».
- Название курса уникально без учёта регистра (миграция `V10`, индекс по
`lower(title)`); повторное создание возвращает `409`.
- Назначение и снятие преподавателей, привязка и отвязка групп хранятся
с историей: снятие/отвязка проставляют `removed_at`, запись не удаляется.
Повторное назначение после снятия создаёт новую активную запись.
- Для архивированного курса запрещены новые назначения преподавателей
и привязка групп.
### Bootstrap курса C
- `CourseBootstrapService` идемпотентно создаёт (или находит) курс
«Промышленное программирование на языке C», назначает владельцем текущего
админа и переносит в него существующие данные.
- Конкурентные запуски сериализуются advisory-локом
`pg_advisory_xact_lock(hashtext('academic.course.bootstrap'))`.
- Перед переносом проверяются дубли номеров лабораторных без курса. При дублях
bootstrap завершается `409`, транзакция откатывается, курс не создаётся.
- Все активные группы, переданные из web, привязываются к курсу (повторный
запуск не создаёт дублей).
- Всем лабораторным и главам без курса проставляется `course_id = C`;
у курсовых глав `audience` сбрасывается в `NULL`.
- После переноса, если лабораторных без курса не осталось, сервис в рантайме
выполняет `ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL`.
Это **post-bootstrap шаг, а не Flyway-файл**: обычный старт Flyway не может
дождаться создания курса и назначения владельца.
### Лабораторные
- `labs.course_id` добавлен nullable-миграцией `V7`; уникальность номера —
в рамках курса: `(number, course_id)`.
- Создание и импорт лабораторной требуют курс; архивированный курс отклоняется
с `400`.
- Список лабораторных фильтруется по `courseId`; API и UI показывают номер
и название курса у каждой лабораторной.
- Копирование лабораторной в другой курс (`LabCopyService`): копируются
описание, уровни и варианты с новыми id, копия создаётся черновиком.
Оценки, сдачи, защиты и назначения вариантов не копируются.
- Копирование запрещено в тот же курс и в архивированный курс; занятый номер
в целевом курсе даёт `409`.
### Админ-UI
- `/admin/courses` — список курсов с номером, названием, статусом и счётчиками
преподавателей, групп и лабораторных; создание курса; блок первичного
bootstrap, пока курсов нет.
- `/admin/courses/{id}` — карточка курса: архивирование/возврат, привязка
и отвязка групп, назначение и снятие преподавателей по UUID аккаунта,
список лабораторных курса.
- `/admin/labs` — фильтр по курсу, колонка курса, выбор курса (обязательный)
в формах импорта и создания.
- `/admin/labs/{id}` — карточка лабораторной показывает курс; кнопка
«Копировать в курс» открывает форму выбора целевого курса, номера и названия.
- В навигацию админа добавлен пункт «Курсы».
### Миграции
- `V6__courses.sql` — курсы, преподаватели, группы курса.
- `V7__labs_course.sql` — `labs.course_id`, уникальность `(number, course_id)`.
- `V8__chapters_course.sql` — `chapters.course_id/audience`, частичные индексы slug.
- `V9__article_assets_indexes.sql` — индекс `article_assets(article_id)`.
- `V10__course_title_unique.sql` — уникальный индекс `lower(title)` по курсам.
- `V11__global_audience_all.sql` — backfill `audience='ALL'` для глобальных глав.
- `V12__asset_links_backfill.sql` — backfill связей `article_assets` из
markdown-ссылок для видимости активов.
## 2. API
Все эндпоинты курсов доступны только роли `ADMIN`.
| Метод | Путь | Назначение |
|---|---|---|
| `GET` | `/api/v1/courses` | список курсов со счётчиками |
| `POST` | `/api/v1/courses` | создать курс, владелец — текущий админ |
| `GET` | `/api/v1/courses/{id}` | карточка курса с историей преподавателей и групп |
| `PUT` | `/api/v1/courses/{id}` | изменить название и/или статус |
| `POST` | `/api/v1/courses/{id}/teachers` | назначить преподавателя |
| `DELETE` | `/api/v1/courses/{id}/teachers/{accountId}` | снять преподавателя |
| `POST` | `/api/v1/courses/{id}/groups` | привязать группу |
| `DELETE` | `/api/v1/courses/{id}/groups/{groupId}` | отвязать группу |
| `POST` | `/api/v1/courses/bootstrap` | создать курс C и перенести данные |
| `GET` | `/api/v1/labs?courseId=…` | список лабораторных, опционально по курсу |
| `POST` | `/api/v1/labs` | создать лабораторную с обязательным `courseId` |
| `POST` | `/api/v1/labs/import?courseId=…` | импорт ZIP в указанный курс |
| `POST` | `/api/v1/labs/{id}/copy` | копировать лабораторную в другой курс |
`LabSummary` и `LabDetail` дополнены полями `courseId`, `courseNo`,
`courseTitle`. В браузерных URL курса используется `course_no`; API работает
с UUID.
## 3. Порядок bootstrap
1. Админ открывает `/admin/courses`. Пока курсов нет, показывается блок
первичной настройки.
2. Web передаёт в `POST /api/v1/courses/bootstrap` название (необязательно)
и id всех активных групп.
3. Сервис берёт advisory-лок, проверяет UUID групп, находит или создаёт курс
по названию без учёта регистра (владелец — текущий админ).
4. Привязывает активные группы, проверяет дубли номеров лабораторных без курса.
5. Проставляет `course_id` лабораторным и главам.
6. Если лабораторных без курса не осталось, выполняет `SET NOT NULL` для
`labs.course_id`.
7. Возвращает `courseId`, `courseNo`, название и счётчики: сколько групп
привязано, лабораторных и глав перенесено, сколько осталось без курса.
8. Повторный запуск безопасен: существующий курс переиспользуется, уже
привязанные группы и перенесённые данные не дублируются.
## 4. Тесты
Добавлены интеграционные тесты на PostgreSQL (Testcontainers):
- `CourseServiceIntegrationTest` (6 тестов): отказ bootstrap при дублях
номеров без создания курса, создание курса и перенос групп/лабораторных/глав,
идемпотентность bootstrap, генерация уникальных `course_no`, запрет
дубликата названия без учёта регистра, история назначений преподавателей
и групп, отказ при некорректном UUID группы.
- `LabCopyIntegrationTest` (5 тестов): копирование с уровнями и вариантами,
обязательный курс при создании, запрет копирования в тот же курс, в
архивированный курс и создание лабораторной в архивированном курсе.
Прогон 2026-10-27: `./gradlew :services:academic-service:test :web:test` —
успешно, в новых наборах 0 failures / 0 errors. `git diff --check` — успешно.
## 5. Известные пробелы (этап 3)
- **Материалы курса и аудитории.** Схема `chapters.course_id/audience` готова
(`V8`), но URL `/learn/course/{course_no}/...` и проверка аудитории ещё не
реализованы: читалка материалов пока работает по прежним глобальным правилам.
- **Активы — закрыто после этапа 5.** `V9` добавил индекс;
`V12__asset_links_backfill.sql` восстановил связи `article_assets` из
markdown-ссылок, а `/api/v1/content/assets/{name}` проверяет наследуемую
видимость статьи → раздела → главы. Остаточный случай — файлы без ссылок
(см. [15](15-stage5-course-materials.md)).
- **Проверка аккаунтов преподавателей.** Academic принимает UUID без обращения
к identity: не проверяются существование аккаунта, роль `TEACHER` и статус
`ACTIVE`. Нужна внутренняя проверка identity при назначении.
- **Кабинет преподавателя и скоупинг.** API курсов пока admin-only; кабинет
`/teacher/...`, доступ преподавателя только к своим курсам и студентам,
профиль студента курса и negative-тесты «чужой курс/студент/сдача» —
этап 3.
- **Повторный bootstrap из UI.** Кнопка bootstrap показывается только когда
курсов нет; повторный запуск возможен через API. Группы, появившиеся позже,
привязываются вручную на странице курса.

195
docs/13-stage3-report.md Normal file
View File

@@ -0,0 +1,195 @@
# 13. Отчёт этапа 3: доступ и профиль студента курса
> **Историческая справка:** в этом отчёте браузерные URL показаны с UUID курса. После перехода 2026-10-29 текущие URL используют `courseNo`; REST API по-прежнему принимает UUID.
**Дата:** 2026-10-27.
**Статус:** этап 3 выполнен; этап 4 (повышение и жизненный цикл) — следующий.
## 1. Что сделано
### Аккаунты преподавателей
- Добавлен административный API `/api/v1/teachers`: список, создание,
сброс пароля. Создаётся только аккаунт роли `TEACHER`.
- Логин проверяется на длину (минимум 3 символа) и уникальность; пустой пароль
заменяется сгенерированным. Новый пароль помечается как временный
(`mustChangePassword`), показывается один раз.
- В админке появилась страница `/admin/teachers` с созданием преподавателя
и сбросом пароля; одноразовый пароль выводится во всплывающем уведомлении.
### Курсовая область видимости
- `CourseAccessService` в academic-service определяет доступные курсы по роли:
- `ADMIN` — все курсы, включая архивные;
- `TEACHER` — только активные курсы с активным назначением
`course_teachers` (`removed_at IS NULL`);
- `STUDENT` — только активные курсы активных групп, в которых студент
состоит сейчас (группы запрашиваются у identity-service);
- анонимный пользователь до сервиса не доходит — gateway требует JWT.
- `GET /api/v1/courses/mine` возвращает курсы текущего пользователя.
- `GET /api/v1/courses/{id}` проверяет доступ к курсу. Админские операции
создания, переименования, архивирования, назначения преподавателей
и привязки групп остались только для `ADMIN`.
- Список лабораторных без `courseId` фильтруется по доступным курсам;
с `courseId` доступ проверяется явно. Карточка лабораторной тоже проверяет
доступ через её курс. Лабораторная без курса доступна только админу.
- Недоступный курс/лабораторная дают `403`, несуществующая — `404`.
Недоступность identity при проверке групп даёт `502`, а не `403`.
### Внутренние API identity
Добавлены read-only эндпоинты для межсервисных вызовов:
| Метод | Путь | Назначение |
|---|---|---|
| `GET` | `/api/v1/internal/students/{studentId}/context` | карточка студента: ФИО, №, статус, год набора, текущая группа |
| `POST` | `/api/v1/internal/accounts/lookup` | пакетные логины авторов комментариев и оценок |
| `GET` | `/api/v1/internal/groups/{groupId}` | группа и текущий состав без учётных данных |
Эндпоинты защищены заголовком `X-Internal-Token`; сравнение токена —
constant-time. Gateway не маршрутизирует `/api/v1/internal/**`, поэтому снаружи
эти пути недоступны.
### Кабинет преподавателя
- `/teacher` — список назначенных курсов с количеством групп и лабораторных.
- `/teacher/courses/{id}` — карточка курса: привязанные группы и лабораторные
в режиме просмотра. Редактирование курса и лабораторных остаётся у админа.
- `/teacher/courses/{courseId}/groups/{groupId}` — состав группы, привязанной
к курсу, со ссылками на профиль студента.
- Доступ к `/teacher/**` разрешён роли `TEACHER` и админу; студент
перенаправляется в свой кабинет, аноним — на вход. После входа преподаватель
попадает на `/teacher`.
### Профиль студента курса
- Progress-service собирает агрегированный профиль одним запросом:
`GET /api/v1/courses/{courseId}/students/{studentId}/profile`.
- В ответе: курс, карточка студента, сводка и полный список лабораторных курса.
- Для каждой лабораторной: вариант и уровень, число попыток, дата последней
сдачи, оценка и её публикация, общий комментарий, комментарии преподавателей,
защита (статус, код, задание, остаток времени), все попытки с файлами
и ссылкой на скачивание.
- Скачивание любой попытки:
`GET /api/v1/courses/{courseId}/submissions/{submissionId}/download`
(ZIP). Проверяется, что сдача относится к лабораторной этого курса;
чужой `submissionId` и чужой курс отклоняются.
- BFF-страницы профиля:
- `/teacher/courses/{courseId}/students/{studentId}`;
- `/admin/courses/{courseId}/students/{studentId}`.
- Авторство: миграция `V13__profile_authors.sql` добавляет
`grade_comments.author_account_id` и `lab_grades.updated_by_account_id`;
новые комментарии и оценки сохраняют автора. Старые записи показываются
без автора. Индекс `lab_grades(lab_id)` ускоряет выборку по списку лаб.
- В ведомости группы ФИО студента и кнопка «Профиль» ведут на профиль, если
группа открыта из карточки курса. На странице группы преподавателя те же
ссылки доступны из состава группы.
## 2. Правила доступа
| Объект | ADMIN | TEACHER | STUDENT |
|---|---|---|---|
| Курс | любой, включая архивный | только активный назначенный | только активный курс текущей группы |
| Лабораторные курса | все | все курса, включая черновики | все лабораторные доступного курса |
| Состав группы курса | любая привязанная группа | группа должна быть активной привязкой курса | доступ к курсу по своей группе |
| Профиль студента | любой студент любого курса | студент должен состоять в активной группе курса | только свой профиль |
| Оценки и комментарии | все | все, включая неопубликованные | только опубликованные |
| Скачивание попытки | любая попытка курса | только студент из активной группы курса | отдельный download-эндпоинт недоступен |
| Внутренние API identity | по сервисному токену | по сервисному токену | по сервисному токену |
Дополнительно:
- Преподаватель чужого курса получает `403`; студент чужого профиля — `403`.
- Студент видит только опубликованные оценки и комментарии. Черновики
и неопубликованные комментарии скрыты.
- Профиль не даёт доступа к другим курсам студента.
- Автор комментария/оценки показывается логином; старые записи без автора —
«—». Логины подтягиваются одним пакетным запросом.
## 3. Токен и конфигурация
- Общий секрет: `LMS_INTERNAL_TOKEN`.
- Где задаётся: `lms.internal.token` в `application.yml` identity, academic
и progress. Локально допускается значение по умолчанию
`dev-internal-token`; в production оно обязательно заменяется.
- `docker-compose.prod.yml` передаёт токен в identity, academic и progress;
`.env.prod.example` содержит заготовку без секрета.
- Заголовок: `X-Internal-Token`. Пустой или неверный токен — `403`.
- Gateway не публикует внутренние пути; вызовы идут напрямую между сервисами
по внутренней сети Docker.
## 4. UI
- `/admin/teachers` — создание преподавателя, список, сброс пароля,
отображение временного пароля один раз.
- `/teacher` — «Мои курсы».
- `/teacher/courses/{id}` — группы и лабораторные курса, переход к составу
группы.
- `/teacher/courses/{courseId}/groups/{groupId}` — студенты группы, ФИО
и кнопка «Открыть» ведут в профиль.
- `/teacher/courses/{courseId}/students/{studentId}` и
`/admin/courses/{courseId}/students/{studentId}` — единый шаблон профиля
с разными маршрутами скачивания и возврата.
- В навигации: «Преподаватели» у админа, «Мои курсы» у преподавателя.
- Профиль показывает сводку (лабораторных, сдано, оценено, средний балл,
защиты), каждую лабораторную, комментарии, защиты и все попытки.
## 5. Тесты
Добавлены и расширены наборы:
- identity: `InternalControllerIntegrationTest` (12) — контекст студента,
пакетные логины, состав группы, неверный/пустой токен, неизвестные id;
`TeacherIntegrationTest` (6) — создание, генерация пароля, список,
сброс пароля, запрет для не-админа.
- academic: `CourseAccessServiceTest` (16) — роли, чужие/архивные курсы,
снятые назначения, студенты без групп, `mine`, доступ к лабораторным;
`CourseRosterServiceTest` (6) — состав группы, чужая группа/преподаватель,
недоступность identity.
- progress: `StudentProfileIntegrationTest` (5) — неопубликованные данные
для преподавателя, только опубликованное для студента, чужой курс/студент,
скачивание чужой сдачи.
- web: `StudentProfileTemplateTest`, `ProfileDtoJsonTest`,
`TeacherGroupTemplateTest`, `TeacherInterceptorTest`,
`CourseGroupRosterDtoTest` — контракты, шаблоны, роли и ссылки.
По результатам последнего прогона (2026-10-27) перечисленные наборы прошли
с `0 failures / 0 errors`.
## 6. Известные пробелы (исторические, на момент этапа 3)
> Список ниже сохранён как история; актуальный статус — в разделе 7 и
> [16-stage6-report.md](16-stage6-report.md).
- **Контент курса.** URL материалов вида `/learn/course/{course_no}/...`,
проверка `chapters.course_id/audience`, наследуемая видимость активов
и выдача файлов по курсу ещё не реализованы. Схема подготовлена на этапе 2.
- **Права преподавателя.** Кабинет преподавателя — просмотр: курс, группы,
состав, лабораторные и профили. Редактирование лабораторных, оценок
и раздачи вариантов из кабинета не добавлялось.
- **Названия групп в кабинете.** На странице курса преподавателя группа
показывается UUID: academic отдаёт `groupId`, а имя группы не входит
в контракт карточки курса. Нужен batch-запрос имён или расширение DTO.
- **Собственный профиль студента.** API допускает `STUDENT` только для своего
профиля, но отдельной студенческой BFF-страницы профиля нет; текущие
страницы профиля — преподавательская и админская.
- **Аудит скачиваний.** Скачивание попыток пока не пишется в аудит;
это этап 6.
## 7. Обновление после этапа 6
- **Права преподавателя на запись** закрыты: преподаватель своего курса
управляет лабораторными/уровнями/вариантами, выставляет оценки и
комментарии, публикует их, раздаёт варианты, назначает/запускает защиты,
привязывает группы и авторит материалы курса. Ведомость переиспользует
`/admin/groups/{groupId}?courseId=...` без админских контролов; сервисы
проверяют курс и отклоняют чужие лабы/студентов/группы.
- **Имена групп в кабинете преподавателя** разрешены: страница курса
показывает название, при недоступности — id.
- **Контент курса и активы** закрыты на этапе 5: URL
`/learn/course/{course_no}/...`, проверка `chapters.course_id/audience`,
наследуемая видимость активов; на этапе 6 добавлено авторство материалов
преподавателем (`/teacher/courses/{id}/content`), глобальные материалы
остаются admin-only.
- **Аудит скачиваний** реализован: `identity.audit_events`, внутренний
`POST /api/v1/internal/audit` и админский `/admin/audit`.

176
docs/14-stage4-report.md Normal file
View File

@@ -0,0 +1,176 @@
# 14. Отчёт этапа 4: повышение и жизненный цикл
**Дата:** 2026-10-27.
**Статус:** этап 4 выполнен; этап 5 (UX и контент курса) — следующий.
## 1. Повышение
### Алгоритм
План строится по активным группам и студентам:
- Активная группа с курсом меньше предела (`БО` — 4, `МО` — 2) переводится
на следующий курс: меняются `course` и имя по шаблону
`поток-курсномерстепень`.
- Активная группа на последнем курсе получает статус `GRADUATED`.
- Активные студенты выпускаемой группы получают `GRADUATED`.
- Студенты в академическом отпуске переводятся в параллель следующего набора
того же курса: `enrollmentYear = год повышения - (курс - 1)`.
Если такой группы нет, она создаётся в плане; при применении — в БД.
- Студенты в статусах `EXPELLED`, `GRADUATED`, `ARCHIVED` в план не попадают.
- Группы без курса/степени/потока/номера и студенты без зачисления
пропускаются с предупреждением; план при этом не блокируется.
Порядок применения: сначала группы (от старших курсов), затем студенты.
Каждый элемент хранит `before`/`after` в JSONB. Перед применением все `before`
сверяются с текущим состоянием; при расхождении вся транзакция отменяется
с `409`, частичного применения нет.
### API
Все эндпоинты требуют роль `ADMIN`:
| Метод | Путь | Назначение |
|---|---|---|
| `GET` | `/api/v1/promotion` | список запусков |
| `GET` | `/api/v1/promotion/{id}` | план/результат с элементами |
| `POST` | `/api/v1/promotion/dry-run` | построить план; тело `{ "year": 2026 }` необязательно |
| `POST` | `/api/v1/promotion/{id}/apply` | применить запланированный dry-run |
| `POST` | `/api/v1/promotion/{id}/rollback` | откатить завершённое применение |
Правила:
- `year` — 2000–2100; без года берётся текущий.
- Повторный dry-run за год возвращает существующий план в статусе `PLANNED`.
- Применять можно только `DRY_RUN` в статусе `PLANNED`. Запуск переходит
в `APPLY/RUNNING`, затем `COMPLETED`.
- Один успешный `APPLY` на год защищён частичным уникальным индексом
`promotion_runs(year) WHERE mode = 'APPLY' AND status IN ('RUNNING','COMPLETED')`;
конкурентное применение даёт `409`.
- Повторное применение уже завершённого запуска идемпотентно возвращает
результат и не создаёт новых записей истории.
- Откат доступен только для `COMPLETED`. Перед откатом проверяется, что
текущее состояние совпадает с `after`; ручные изменения дают `409`.
Восстанавливаются курс, имя и статус групп, статусы/группы студентов,
удаляются созданные планом пустые целевые группы. История не стирается:
добавляются записи `RETURN` с причиной «Откат повышения».
### Планировщик
- `PromotionScheduler` строит dry-run 31 августа в `00:00` по
`Europe/Moscow` (`cron = "0 0 0 31 8 *"`).
- При старте сервиса выполняется догон: если сегодня 31 августа или позже
и за год ещё нет запусков, dry-run строится сразу.
- Догон идемпотентен: advisory lock не даёт двум инстансам создать два плана.
- Планировщик включён по умолчанию; отключается
`lms.promotion.scheduler-enabled=false` (используется в тестах).
- Автоматически применяется только dry-run. Применение — всегда вручную
админом после проверки плана.
## 2. Жизненный цикл студента
Эндпоинты требуют роль `ADMIN`:
| Метод | Путь | Тело | Результат |
|---|---|---|---|
| `POST` | `/api/v1/students/{id}/academic-leave` | `reason?` | перевод в параллель следующего набора, статус `ACADEMIC_LEAVE` |
| `POST` | `/api/v1/students/{id}/return` | `groupId`, `reason?` | возврат в указанную активную группу, статус `ACTIVE` |
| `POST` | `/api/v1/students/{id}/expel` | `reason?` | статус `EXPELLED`, зачисление сохраняется |
| `POST` | `/api/v1/students/{id}/archive` | — | статус студента `ARCHIVED`, аккаунт `ARCHIVED` |
| `POST` | `/api/v1/students/{id}/restore` | `groupId`, `reason?` | восстановление из архива/отчисления в активную группу |
Правила:
- Академотпуск доступен только `ACTIVE`; группа должна иметь заполненные
поток, курс, номер и степень. Целевая параллель ищется по когорте
и создаётся при отсутствии.
- Возврат доступен только из `ACADEMIC_LEAVE`; целевая группа должна быть
активной.
- Отчислить нельзя уже отчисленного, выпущенного или архивного студента.
Зачисление остаётся для истории, вход закрывает статус.
- Архив закрывает и студента, и аккаунт (`archived_at`); зачисление
сохраняется. Восстановление возвращает аккаунт в `ACTIVE` и переводит
студента в выбранную группу.
- Каждое действие пишет `membership_history` с типом, причиной и актором.
## 3. Архив группы, удаление и вход
- `POST /api/v1/groups/{id}/archive` переводит группу в `ARCHIVED`,
не меняя статусы студентов.
- Вход студента блокируется, если его текущая группа `ARCHIVED` или
`GRADUATED` (`AuthService.ensureGroupOpen`). Студент без группы может
войти, если его собственный статус и аккаунт активны.
- Удаление пользователя требует параметр `confirm`, совпадающий с логином
или ФИО студента (trim, точное совпадение). Иначе — `400`, данные
не удаляются. В админской форме появилось обязательное поле подтверждения.
- «Убрать из группы» осталось отдельным действием: оно удаляет зачисление,
но не является отчислением. Для отчисления с историей используется
«Отчислить».
## 4. UI
- `/admin/promotion` — список запусков, построение dry-run за выбранный год.
- `/admin/promotion/{id}` — сводка, предупреждения, таблица элементов
«было/стало», кнопки «Применить план» и «Откатить» по статусу.
- На странице группы:
- «Академ» — для активного студента;
- «В архив» — для активного студента;
- «Отчислить» — для активного студента;
- «Вернуть в группу» — для `ACADEMIC_LEAVE`;
- «Восстановить в группу» — для `ARCHIVED`/`EXPELLED`;
- «Убрать из группы» — отдельно от отчисления.
- «В архив» для группы — на странице группы; кнопка скрыта для уже
архивной группы.
- В навигацию админа добавлен пункт «Повышение».
- Шаблоны и представления покрыты тестами, включая подписи статусов,
счётчики и состояния before/after.
## 5. Тесты
Добавлены наборы:
- `PromotionServiceIntegrationTest` (6): dry-run не меняет данные,
применение переводит группы/выпускает студентов/двигает академ,
повторное применение идемпотентно, откат восстанавливает данные
и удаляет созданную группу, конфликт ручных изменений, guard даты
и дедупликация планировщика.
- `StudentLifecycleIntegrationTest` (11): академотпуск с созданием
и переиспользованием параллели, возврат, отчисление с блокировкой входа,
вход из архивной группы, активной группы и без группы, восстановление
после отчисления и архива, архив группы без смены статусов студентов,
подтверждение удаления.
- `AdminStage4TemplateTest` (5) и `PromotionDtoTest` (2): журнал повышения,
кнопки apply/rollback, lifecycle-кнопки по статусам, поле подтверждения
удаления, JSON-контракты.
- `AuthServiceLoginBackoffTest` обновлён под новую зависимость проверки
группы.
По результатам последнего прогона (2026-10-27) перечисленные наборы прошли
с `0 failures / 0 errors`.
## 6. Известные пробелы и forward-only
- **Только ручное применение.** Планировщик создаёт только dry-run;
автоматического применения и уведомлений нет.
- **Один план на год.** Повторный dry-run за год возвращает уже сохранённый
`PLANNED`-план; пересборки или удаления плана в UI нет. После завершённого
применения новый dry-run за тот же год запрещён до отката.
- **Откат целиком.** Поэлементного отката нет; при конфликте откатывается
весь запуск. После ручных изменений сначала нужно вернуть данные
к состоянию `after`.
- **История не удаляется.** Откат добавляет `RETURN`, а не стирает
исходные записи; журнал остаётся аудиторским следом.
- **Групповой архив односторонний.** Отдельного endpoint/UI для возврата
группы из архива нет; студенты архивной группы не входят.
- **Lifecycle из ведомости.** Возврат и восстановление в UI выполняются
в текущую группу; выбор другой группы доступен только через API.
Массовых операций по студентам нет.
- **JWT и блокировка.** Вход закрывается при следующей аутентификации;
уже выданный JWT действует до истечения (до 30 дней). Это остаточный
риск, зафиксированный в плане; аудит и hardening — этап 6.
*Историческая оценка этапа 4; закрыто в identity `V11` (access 15 минут,
refresh 7 дней с отзывом) — см. [16](16-stage6-report.md).*
- **Нет новых миграций.** Используются таблицы `promotion_runs`,
`promotion_run_items`, `membership_history`, `accounts.status`
из миграций `V7`–`V9` этапа 1. JSONB пишется через `JdbcTemplate`
из-за несовместимости Jackson 2/3 в Hibernate.

View File

@@ -0,0 +1,72 @@
# Этап 5. Материалы курса
**Статус:** реализовано в academic-service и web (без изменения identity/progress).
## Что изменилось
- Публичные `/api/v1/content/tree` и `/api/v1/content/{chapter}/{section}/{article}`
отдают только **глобальные** главы (`course_id IS NULL`) с аудиторией `ALL`.
Курсовые главы через публичный контракт больше не доступны.
- Материалы курса открываются по `/api/v1/courses/{courseId}/content/...` и требуют
аутентификации. Доступ проверяется через `CourseAccessService`:
админ — любой курс, преподаватель — только назначенный, студент — только курс
активной группы.
- Администратор и преподаватель курса видят скрытые главы/разделы/статьи;
студент — только опубликованные.
- В браузере материалы курса открываются по `/learn/course/{course_no}` и
`/learn/course/{course_no}/{chapter}/{section}/{article}`. Анонимный пользователь
перенаправляется на `/login`, чужой курс даёт `403`.
- Ссылки на материалы курса добавлены в кабинеты студента (карточки лабораторных),
преподавателя и администратора.
- Админский просмотр и авторство глобальных материалов (`treeAll`, `/api/v1/content/all/...`,
`/learn` для админа) сохранены.
- Новые глобальные главы создаются и импортируются с `audience = "ALL"`.
Миграция `V11__global_audience_all.sql` backfill-ом проставляет `ALL` старым
глобальным главам; курсовые главы остаются с `audience = NULL`.
## Видимость активов (закрыто после этапа 5)
Разрыв этапа 5 закрыт: активы привязаны к статьям и наследуют их видимость.
- Добавлены entity/repository `article_assets`; импорт контента сохраняет
связи для файлов, на которые ссылаются статьи.
- Миграция `V12__asset_links_backfill.sql` backfill-ом восстанавливает связи
для уже загруженных статей: ищет в `articles.body_md` ссылки вида
`/api/v1/content/assets/{name}`, создаёт строки `article_assets` с MIME по
расширению и `size = 0`. Повторный запуск не создаёт дубликатов.
- Публичный API `/api/v1/content/assets/{name}` проверяет доступ по всем
статьям, к которым привязан файл (достаточно одной видимой):
- актив без связей в `article_assets` — secure-by-default: `404` для
анонима, студента и преподавателя; только `ADMIN` может скачать файл для
инвентаризации/очистки;
- глобальная опубликованная статья `ALL` — публична, в том числе анонимно;
- глобальные `STUDENTS`/`TEACHERS` — только с JWT и соответствующей ролью;
- курсовой актив — только с JWT и доступом к курсу: админ видит любой курс,
преподаватель — свой (включая скрытые материалы), студент — только
опубликованные материалы своего курса. Скрытый актив для студента отдаёт
`404` (не раскрывает существование), чужой курс — `403`.
- Web-прокси `/api/v1/content/assets/{name}` передаёт Bearer-токен из web-сессии,
поэтому защищённые картинки на страницах `/learn/...` открываются, а на
публичных страницах прокси остаётся анонимным.
## Legacy-активы без привязки (закрыто)
`V12` закрывает файлы, на которые есть ссылки в markdown. Если файл был
загружен, но ни одна статья на него не ссылается, он больше **не публичен**:
аноним, студент и преподаватель получают `404`, чтобы не раскрывать
существование файла. Админ скачивает такой файл и получает его имя на
странице `/admin/legacy-assets`, через `GET /api/v1/content/legacy-assets`
или `scripts/legacy-assets.sh`, после чего привязывает файл к статье или
удаляет. Старые внешние ссылки на несвязанные файлы у обычных пользователей
перестают работать — это осознанное поведение secure-by-default, а не
регрессия. Эндпоинт и страница инвентаризации защищены `ROLE_ADMIN`.
## Авторство материалов преподавателем (добавлено на этапе 6)
Преподаватель своего курса редактирует главы, разделы и статьи на
`/teacher/courses/{courseNo}/content`: создание/переименование/удаление, markdown-
редактор с предпросмотром, публикация/снятие с публикации. API —
`/api/v1/courses/{courseId}/content/**`; любая изменяемая сущность проверяется
на принадлежность курсу. Глобальные материалы (`course_id IS NULL`,
аудитории `ALL/STUDENTS/TEACHERS`) по-прежнему создаёт и публикует только
админ.

360
docs/16-stage6-report.md Normal file
View File

@@ -0,0 +1,360 @@
# 16. Отчёт этапа 6: проверка, hardening и передача
**Дата:** 2026-10-27, обновлено 2026-10-28 после закрытия продуктовых рисков.
**Ветка:** `feature/groups-courses`.
**Статус:** этап 6 закрыт. Продуктовые риски (legacy-активы, срок жизни JWT,
сессии, пагинация аудита и N+1 имён групп) закрыты; оставшиеся ограничения
перечислены в разделе 4 и не блокируют передачу.
## 1. Итоговый объём этапов 0–6
| Этап | Что реализовано | Отчёт |
|---|---|---|
| 0 | Схема и контракты: identity `V4–V13` (`V10` — аудит, `V11` — refresh, `V12` — absolute, `V13` — keyset-индексы), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) |
| 1 | Роли `ADMIN/TEACHER/STUDENT`, группы-когорты и парсер имени, статусы студентов и аккаунтов, `membership_history`, импорт v2 | [11](11-stage1-report.md) |
| 2 | Курсы, `course_no` для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI | [12](12-stage2-report.md) |
| 3 | Аккаунты преподавателей, курсовая область видимости, внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет преподавателя, профиль студента курса и скачивание всех попыток | [13](13-stage3-report.md) |
| 4 | Повышение (dry-run/apply/rollback, планировщик 31 августа МСК), академотпуск, отчисление, выпуск, архив/восстановление, усиленное удаление, блокировка входа | [14](14-stage4-report.md) |
| 5 | Курсовые материалы `/learn/course/{course_no}/...`, публичные глобальные материалы `ALL`, скрытые материалы для преподавателя, перепроверка статуса web-сессии | [15](15-stage5-course-materials.md) |
| 6 | Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит `identity.audit_events`, CI merge gate, hardening и deploy-runbook | этот отчёт |
Ключевые инварианты, действующие после этапов 0–6:
- все лабораторные принадлежат курсу; существующие лабораторные, главы и
активные группы перенесены в курс C;
- API работает с UUID курса, браузерные URL — с `course_no`;
- преподаватель видит только активные назначенные курсы; студент — только курсы
активной группы; админ — все курсы;
- студент видит только опубликованные оценки и комментарии;
- активы наследуют видимость статьи → раздела → главы: курсовые требуют доступа
к курсу, глобальные — аудитории, а файлы без связи со статьёй по умолчанию
недоступны (`404`) всем, кроме админа; админ перечисляет их через
`GET /api/v1/content/legacy-assets`;
- access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия
скользящая на 7 дней, но не дольше абсолютного потолка 30 дней, и сама
обновляет access-токен; истёкшие refresh-токены очищаются ночью;
- аудит продолжается keyset-курсором `(occurred_at, id)`, а детали всех
лабораторных курса отдаются одним пакетным запросом без N+1;
- преподаватель своего курса может писать: лабораторные/уровни/варианты,
ведомость, привязка групп и авторские материалы курса; чужие курс, лаба,
студент и группа отклоняются на сервисах;
- вход закрыт для отчисленных, выпущенных и архивных студентов, а также для
архивных аккаунтов; web дополнительно перепроверяет статус;
- повышение и жизненный цикл пишут историю; применение повышения идемпотентно;
- чувствительные действия пишутся в центральный `identity.audit_events`,
админ смотрит журнал на `/admin/audit`;
- CI на каждый push/MR прогоняет `test ktlintCheck detekt`.
## 2. Проверка
### 2.1. Сборка, тесты и линтеры
Выполнено 2026-10-27:
```bash
./gradlew --no-daemon test ktlintCheck detekt
```
Результат: `BUILD SUCCESSFUL`, код возврата `0`, все модули зелёные.
Для получения актуальных счётчиков тесты запущены принудительно:
```bash
./gradlew test --rerun-tasks
```
| Модуль | Тестов | Failures | Errors | Skipped |
|---|---:|---:|---:|---:|
| identity-service | 105 | 0 | 0 | 0 |
| academic-service | 106 | 0 | 0 | 0 |
| progress-service | 38 | 0 | 0 | 0 |
| web | 129 | 0 | 0 | 0 |
| gateway | 2 | 0 | 0 | 0 |
| markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **385** | **0** | **0** | **0** |
На момент этапа 6 LDD-baseline — **385 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
каждый push/MR добавлен smoke-workflow, полный release обязателен для тегов.
### 2.2. Upgrade-тест миграций
Проверка выполнена на одноразовом PostgreSQL 18 (Testcontainers/контейнер,
не production-БД):
1. Развёрнута схема master: identity `V1–V3`, academic `V1–V5`,
progress `V1–V9`, `V11`, `V12`.
2. Загружены синтетические данные: администратор и два студента, две группы
(`ПИ-11БО`, `ПИ-21БО`), зачисления, семестр, лабораторная, глава/раздел/
статья/актив, оценка, комментарий и попытка сдачи.
3. Поверх master применены миграции ветки:
- identity `V4–V13`;
- academic `V6–V12`;
- progress `V13`.
4. Выполнены postflight-запросы и повторное применение `V13`.
Результат: **успешно, без ошибок**.
- **identity:** группы разобраны в `stream/course/number/degree/enrollment_year`
(`ПИ-11БО` → поток `ПИ`, курс 1, номер 1, `БО`, год набора 2026; `ПИ-21БО` →
курс 2, год 2025); статус `ACTIVE`; созданы `promotion_runs`,
`promotion_run_items`; зачисления перенесены в `membership_history`
действием `ENROLL`; добавлен `accounts.status`; `V10` создаёт
`identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`;
`V11` создаёт `identity.refresh_tokens` (SHA-256-хеш, `expires_at`,
`revoked_at`, `replaced_by`, индексы по аккаунту и сроку); `V12` добавляет
`absolute_expires_at` с бэкфиллом и `NOT NULL`; `V13` создаёт композитные
индексы `(occurred_at DESC, id DESC)` и `(action, occurred_at DESC, id DESC)`
под keyset-пагинацию.
- **academic:** добавлены `labs.course_id`, `chapters.course_id/audience`,
индексы `labs_number_course_uidx`, `chapters_slug_global_uidx`,
`chapters_slug_course_uidx`, `article_assets_article_idx`,
`courses_title_lower_uidx`. `V12__asset_links_backfill.sql`
восстанавливает связи `article_assets` из markdown-ссылок
`/api/v1/content/assets/{name}`; файлы без ссылок остаются несвязанными.
Курс C миграциями не создаётся — его создаёт
bootstrap после входа админа, поэтому `labs_without_course` до bootstrap
ожидаемо больше нуля.
- **progress:** `grade_comments.author_account_id` и
`lab_grades.updated_by_account_id` созданы как nullable `uuid`; индекс
`lab_grades_lab_idx` существует и валиден; старый комментарий остался без
автора, как и задумано.
- **V13 идемпотентна:** повторное применение `V13` (дважды) завершилось
сообщениями `already exists, skipping` и не изменило схему. Это же
проверяет `ProfileAuthorsMigrationTest` на Testcontainers.
- **progress V10 отсутствует:** в ветке нет
`progress-service/.../V10__profile_authors.sql`; в истории прогресса
применяются `V11`, `V12`, затем `V13`. У academic `V10` — это другой файл,
`V10__course_title_unique.sql` (уникальный индекс названия курса).
- Переход **forward-only**: после `V13` откат на сборку без неё не пройдёт
валидацию Flyway; `out-of-order` включать нельзя.
### 2.3. Security-проверки
Проверено автоматическими тестами (прогон этапа 6 — 385 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс».
| Сценарий | Ожидание | Факт |
|---|---|---|
| Аноним → курс/материалы курса | нет доступа | `401` на `/api/v1/courses/{id}/content/tree`; `/api/v1/courses/by-no/{no}` — `401` |
| Аноним → публичные материалы | только глобальные `ALL` | публичное дерево содержит только глобальные главы с `audience = ALL`; курсовая глава через `/api/v1/content/...` — `404` |
| Аноним → актив глобальной `ALL`-статьи | публично | `200` |
| Аноним → курсовой/`STUDENTS`/`TEACHERS` актив | `404` | `404`, существование файла не раскрывается |
| Студент своего курса → актив опубликованной статьи | доступ | `200`; скрытый курсовой актив — `404` |
| Студент/преподаватель чужого курса → курсовой актив | `403` | `403` |
| Преподаватель своего курса → скрытый курсовой актив | доступ | `200`; админ видит любой актив |
| Глобальный `STUDENTS`-актив → преподаватель | `403` | `403`; студент получает `200` |
| Legacy-актив без связи со статьёй | `404` для анонима/студента/преподавателя, файл доступен только админу | `404` всем, кроме `ADMIN`; `GET /api/v1/content/legacy-assets` под `ROLE_ADMIN` перечисляет несвязанные файлы |
| Access/refresh | короткий access + ротация refresh | access JWT 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh 7 дней (`LMS_REFRESH_TOKEN_TTL`), абсолютный потолок сессии 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`); `POST /api/v1/auth/refresh` ротирует пару, не продлевая `absolute_expires_at`; повторное использование старого токена отзывает все сессии аккаунта; истёкшие токены чистит ночная задача |
| Logout / смена пароля | сессии отзываются | `POST /api/v1/auth/logout` отзывает refresh; смена пароля — `revokeAll` |
| Архив/отчисление/закрытая группа → refresh | обновление запрещено | `ensureSessionAllowed` при ротации: `EXPELLED`, `GRADUATED`, `ARCHIVED`, архивный аккаунт и `GRADUATED`/`ARCHIVED` группа дают `401` |
| Web-сессия | 7 дней sliding, без повторного входа, быстрый отзыв | cookie `LMS_SESSION`, `Max-Age` 7 дней (`LMS_SESSION_TIMEOUT`), `HttpOnly`/`SameSite=Lax`; access обновляется за 2 минуты до истечения, cookie перевыпускается на каждом ответе; статус перепроверяется не реже чем раз в 45 секунд, `active=false` или `401/403` инвалидируют сессию, недоступность identity не разлогинивает всех; выход отзывает refresh |
| Админ → курс, профиль, скачивание | полный доступ | доступ к любому курсу и студенту, скачивание любой попытки курса |
| Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; `GET /api/v1/courses/{id}/groups` отдаёт привязанные группы с именами одним вызовом (без N+1) |
| Преподаватель своего курса → запись | лабы/уровни/варианты, ведомость, группы, материалы | оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress `CourseScopeService` и academic course/content authoring |
| Преподаватель чужого курса | `403` | `403` на чтение и запись: материалы, курс по `course_no`, состав группы, профиль, лабы, оценки, группы |
| Аудит → админ | чтение журнала | `GET /api/v1/audit?limit=&action=&cursor=` (offset `page` — для совместимости) и `/admin/audit` доступны только `ADMIN`; в UI — keyset-переход «Показать ещё», битый курсор — `400` |
| Аудит → запись из сервисов | только по токену | `POST /api/v1/internal/audit` с `X-Internal-Token`; без/неверный токен — `403` |
| Студент → свой профиль | только своё и опубликованное | скрытые оценки/комментарии отсутствуют; чужой `studentId` — `403` |
| Студент → чужой курс/группа | `403` | доступ только к курсам активной группы; архивный курс — `403` |
| Чужая сдача / лаба другого курса | `403` | скачивание отклоняется; своя попытка курса скачивается ZIP |
| Внутренние API identity | только по токену | неверный/пустой `X-Internal-Token` — `403`, сравнение constant-time; неизвестный id — `404` |
| Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS/refresh/logout и публичного `/api/v1/content/**` |
| Gateway → заголовки личности | клиент не подменяет | `X-Account-Id/Login/Role` удаляются и проставляются только после проверки JWT |
Остаточные ограничения перечислены в разделе 4: ручная инвентаризация
legacy-активов на production, сдачи по выбранной лабораторной в ведомости,
непроверенный первый CI-прогон и отсутствие визуальной регрессии. Продуктовые
риски legacy-активов, срока access-токена, пагинации, N+1 деталей лабораторных,
абсолютного лимита сессии и ведущих нулей закрыты или сняты как не относящиеся
к требованиям.
## 3. Чек-лист деплоя
### 3.1. Перед деплоем
- [ ] **Backup обязателен.** Выполнить `./deploy/backup.sh backup` и
`./deploy/backup.sh verify <dir>`. `./deploy/deploy.sh up` сам вызывает
`backup.sh backup --if-running`; при ошибке backup деплой прерывается.
Backup на том же сервере не считается единственной защитой.
- [ ] В `.env.prod` задан **`LMS_INTERNAL_TOKEN`** — длинная случайная строка
(`openssl rand -hex 32`), одинаковая для identity, academic и progress.
Значение по умолчанию `dev-internal-token` в production не использовать.
Там же задан `LMS_DB_PASSWORD`; `.env.prod` не коммитится.
- [ ] Проверить, что на сервере нет незакоммиченных изменений и выполнен
`git pull` нужной ветки/тега.
- [ ] **CI зелёный.** `.woodpecker/` (Gitea + Woodpecker 3.x) на каждом
push/MR выполняет workflow `check` (`test ktlintCheck detekt evidenceCheck`)
и `smoke` (`scripts/ldd-smoke.sh`: полный стек, health, вход админа, один
курс). Полный `release` обязателен для тегов и запускается вручную для
веток. Агент — Docker backend на отдельной VM, шаги в nested dind; Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit; установка —
[`deploy/WOODPECKER.md`](../deploy/WOODPECKER.md).
- [ ] Заданы сроки сессии: `LMS_ACCESS_TOKEN_TTL=15m`,
`LMS_REFRESH_TOKEN_TTL=7d`, `LMS_SESSION_TIMEOUT=7d`,
`LMS_SESSION_ABSOLUTE_TIMEOUT=30d`, `LMS_REFRESH_TOKEN_RETENTION=30d`
(значения по умолчанию подходят; менять только осознанно). Очистка
refresh запускается по `LMS_REFRESH_TOKEN_CLEANUP_CRON` (по умолчанию 03:30).
### 3.2. Порядок выкладки
1. **Flyway.** `./deploy/deploy.sh up` собирает образы и запускает
`postgres → identity → academic → progress → gateway → web`. Миграции
применяются автоматически при старте сервисов:
identity `V4–V13` (`V10` — `identity.audit_events`, `V11` —
`identity.refresh_tokens`, `V12` — `absolute_expires_at`, `V13` —
keyset-индексы аудита), academic `V6–V12`, progress `V13`.
Отдельный ручной запуск Flyway не нужен.
2. **Bootstrap курса C.** После входа админа открыть `/admin/courses`.
Пока курсов нет, показан блок первичной настройки. Bootstrap идемпотентно
создаёт/находит курс «Промышленное программирование на языке C»,
привязывает активные группы, переносит лабораторные и главы и, если
лабораторных без курса не осталось, выполняет
`ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL`.
Это **post-bootstrap шаг в рантайме, а не Flyway-миграция**. Повторный
запуск безопасен. Если в БД уже есть курс C, bootstrap не требуется.
3. **Postflight.** Выполнить
`psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql`
и проверить:
- `labs_without_course = 0`;
- `chapters_without_course = 0` (если все текущие материалы курсовые);
- ровно один курс C;
- `comments_without_author` соответствует старым комментариям;
- группы без полного разбора отсутствуют;
- таблица `identity.audit_events` существует (`V10`);
- таблица `identity.refresh_tokens` существует (`V11`);
- у `refresh_tokens` есть `absolute_expires_at NOT NULL` (`V12`);
- индекс `audit_events_occurred_id_idx` существует (`V13`).
4. **V10 → V13 — только dev/staging.** На production/master записи
`progress V10__profile_authors.sql` нет, ничего удалять не нужно. Не путать
с academic `V10__course_title_unique.sql` — он относится к другой схеме и
должен применяться как обычно.
Если БД когда-либо применяла старую feature-миграцию V10:
- сделать backup;
- проверить `deploy/preflight/progress-v10-v13.sql` (`columns_ok` и
`index_ok` должны быть `true`);
- удалить **только строку истории** V10 (`success=true`) через
`flyway repair` или точечный `DELETE` из примера в скрипте; объекты
схемы не удалять;
- при `success=false` сначала убедиться, что частичные объекты откатились;
- не переставлять `V11`/`V12` и не включать `out-of-order`.
После `V13` переход forward-only.
### 3.3. Smoke после деплоя
- [ ] `docker compose -f docker-compose.prod.yml --env-file .env.prod ps` —
все контейнеры `running`; `curl -fsS http://127.0.0.1:8090/actuator/health`
отдаёт `UP`.
- [ ] **Админ:** вход, `/admin/courses` (курс C и счётчики), `/admin/groups`,
`/admin/labs`, `/admin/teachers`, `/admin/promotion`.
- [ ] **Преподаватель:** вход, `/teacher`, карточка своего курса (имена групп,
привязка/отвязка группы по `available-groups`), ведомость
`/admin/groups/{groupId}?courseNo=...` без админских контролов, оценка и
публикация, уровень/вариант и раздача, защита (код/reset), профиль студента,
скачивание попытки и ZIP группы, авторство материалов
`/teacher/courses/{courseNo}/content`.
- [ ] **Студент:** вход, дашборд, лабораторная, сдача/история, курсовые
материалы; видны только опубликованные оценки и комментарии.
- [ ] **Материалы курса:** админ и преподаватель видят скрытые статьи, студент —
только опубликованные; чужой курс даёт `403`, аноним уходит на `/login`.
- [ ] **Активы:** картинки курсовой статьи открываются у студента своего курса
и преподавателя; чужой курс — `403`, скрытая картинка у студента — `404`,
публичная `ALL` — без входа; файл без привязки к статье — `404` для всех,
кроме админа. Админ открывает `/admin/legacy-assets` (или
`GET /api/v1/content/legacy-assets`, или `scripts/legacy-assets.sh`) и при
необходимости привязывает или удаляет файлы.
- [ ] **Копирование лабораторной:** админ копирует лабораторную в другой курс;
создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и
назначения не копируются; занятый номер даёт `409`.
- [ ] **Профиль:** преподаватель своего курса видит сводку, комментарии, все
попытки и скачивает ZIP; чужой студент/курс — `403`.
- [ ] **Повышение:** `/admin/promotion` строит dry-run за год, данные не
меняются; применение — только после проверки плана; откат доступен для
завершённого запуска. На production не применять план «на всякий случай».
- [ ] **Аудит:** `/admin/audit` открывается у админа, показывает события
(например, `PROFILE_VIEW`, `SUBMISSION_DOWNLOAD`, `GRADE_SET`,
`CONTENT_*`, `TEACHER_*`) с фильтром по действию и временем МСК;
«Показать ещё» переходит по keyset-курсору (`cursor`), «В начало»
возвращает к первой странице; преподаватель/студент получают `403`.
- [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней, но не дольше
30 дней с момента входа; при работе дольше 15 минут страница продолжает
работать без повторного входа (access обновляется автоматически). Выход и
смена пароля требуют повторного входа; вход отчисленного/архивного студента
и refresh закрытой группы отклоняются.
- [ ] Проверить логи `web`, `gateway`, `identity`, `academic`, `progress` на
`ERROR`/`WARN` после smoke.
## 4. Известные пробелы и follow-up
1. **Старый формат импорта не поддерживается.** Импорт принимает только
шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение
зафиксировано в [11](11-stage1-report.md). Номер группы — числовой, поэтому
отдельного требования к ведущим нулям нет.
2. **Инвентаризация legacy-активов на реальном хранилище.** UI
`/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы; остаётся
прогнать их на production/staging и привязать или удалить найденные файлы.
Это гигиена хранилища, а не открытый риск доступа: обычные пользователи
уже получают `404`.
3. **Ведомость запрашивает сдачи по выбранной лабораторной.** Детали всех
лабораторных курса теперь пакетные (`GET /api/v1/labs/details`), но
ведомость при открытии конкретной лабораторной делает отдельный запрос
сдач. Это не N+1 по студентам и не блокер.
4. **Browser E2E без визуальной регрессии.** 6 сценариев × Chromium/Firefox/
WebKit = 18/18, но нет screenshot-diff и полного набора мобильных страниц.
На Fedora WebKit запускается через Docker-образ Playwright, потому что
системных Ubuntu-библиотек нет.
5. **Первый реальный CI-прогон не подтверждён.** Конфигурация `.woodpecker/`,
smoke и `scripts/ci-preflight.sh` готовы, но Woodpecker-сервер и агент на
сервере ещё нужно развернуть и проверить первый push-MR pipeline и tag-job.
6. **Внешняя интеграция «Контингент» исключена из плана.** Отдельного
anti-corruption слоя, синхронизации и внешних идентификаторов не будет без
нового ТЗ и доступа к API.
7. **Mutation testing не 100%.** Остаются эквивалентные мутанты и
Kotlin/Spring-специфичные мутации; PIT harness — JUnit 4.
8. **Нагрузочный smoke — не SLA.** Одна машина, синтетические данные;
125 чтений и p95 < 1 с — ориентир, а не гарантия production.
## 5. Рекомендуемые следующие шаги
Приоритет — сверху вниз:
1. **Прогнать инвентаризацию legacy-активов на production/staging** через
`/admin/legacy-assets` или `scripts/legacy-assets.sh`, привязать нужные
файлы к статьям, остальные удалить.
2. **Развернуть Woodpecker и проверить CI.** Поднять server и Docker-backend
агент на отдельной VM по [`deploy/WOODPECKER.md`](../deploy/WOODPECKER.md),
включить репозиторий, дождаться зелёных `check`/`smoke` на push-MR и
`release` на теге.
3. **Дальнейшее расширение E2E** — визуальная регрессия и остальные мобильные
страницы, если UI продолжит расти.
4. **Пакетные сдачи ведомости**, если появятся курсы, где открытие ведомости
станет заметно медленным.
## 6. Итог
Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица,
upgrade-тест миграций, видимость активов и deploy-runbook проверены.
Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и
web-сессия с отзывом и абсолютным потолком, keyset-пагинация аудита,
пакетные детали лабораторных и bulk-выдача групп. Оставшиеся ограничения
(ручная инвентаризация на production, сдачи по выбранной лабораторной,
непроверенный первый CI-прогон) задокументированы в разделе 4 и не блокируют
передачу.
## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур на момент этапа 6: реестр `docs/evidence/claims.json` (**59 claims, 27 P0 / 32 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
mutation testing и браузерный Playwright E2E. Результат:
**59/59 claims proven, P0 gaps = 0, 385 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.
Workflow `smoke` выполняется на каждом push/MR, полный `release` обязателен
для тегов и доступен вручную для веток. E2E нашёл и закрыл реальный дефект
Jackson 3/Kotlin defaults. Подробности и остаточные ограничения —
[`docs/evidence/baseline-2026-10-27.md`](evidence/baseline-2026-10-27.md).

321
docs/17-handoff.md Normal file
View File

@@ -0,0 +1,321 @@
# 17. Handoff: состояние проекта после закрытия продуктовых рисков
**Дата:** 2026-10-29.
**Ветка:** `feature/groups-courses`.
**История:** `git log --oneline`; последние изменения — Woodpecker CI и
изоляция тестовой среды на отдельной VM.
**Пуш:** не выполнялся.
**Рабочее дерево:** чистое. `exports/` и `texput.log` игнорируются и в коммиты не входят.
Этот файл — точка восстановления контекста. Если после сжатия контекста
непонятно, что уже сделано и с чего продолжать, читать сначала его, затем
[16-stage6-report.md](16-stage6-report.md) и
[evidence/README.md](evidence/README.md).
---
## 1. Что уже сделано
### 1.1. Доменная модель и роли
- Роли `ADMIN`, `TEACHER`, `STUDENT`.
- Группы-когорты: `stream`, `course`, `number`, `degree`, `enrollment_year`,
`status`; история членства `membership_history`. `V14` делает поля когорты
`NOT NULL` и уникальность полной; архивная группа возвращается из архива.
- Импорт групп Excel v2 (CSV/XLSX с row-level preflight), перевод/академ/
отчисление/выпуск/архив/восстановление.
- Удаление студента с усиленным подтверждением (`login` или ФИО).
- Вход закрыт для отчисленных, выпущенных, архивных студентов и архивных
аккаунтов; архивные/выпущенные группы также блокируют вход.
- Ведущие нули номера группы **не являются требованием**: номер числовой,
отдельной поддержки `01` нет. Не добавлять `number_label` без нового ТЗ.
### 1.2. Курсы и лабораторные
- Курс C «Промышленное программирование на языке C» создаётся bootstrap-ом;
все лабораторные и главы переносятся в курс.
- `labs.course_id` становится `NOT NULL` в runtime после bootstrap
(`CourseBootstrapService`), а не отдельной Flyway-миграцией.
- У курса есть человекочитаемый `course_no`; материалы открываются по
`/learn/course/{course_no}/...`.
- Bootstrap идемпотентен, сериализуется advisory lock, проверяет дубли
номеров лабораторных.
- Копирование лабораторной в другой курс; перенос между курсами запрещён.
- Копия не переносит прогресс, оценки, сдачи, защиты и назначения.
### 1.3. Права преподавателя
- Преподаватель своего курса может:
- вести лабораторные/уровни/варианты;
- работать с ведомостью: оценки, комментарии, публикация, уровни, варианты,
распределение, защиты;
- привязывать/отвязывать группы курса;
- авторствовать материалы курса (главы/разделы/статьи, публикация).
- Все проверки скоупа выполняются на сервисах; чужие курс/лаба/студент/группа
дают `403` или `404`.
- Глобальные материалы и админские функции остаются только у `ADMIN`.
### 1.4. Безопасность (закрытые продуктовые риски)
**Legacy-активы**
- Файл без записи в `article_assets` больше не публичен:
аноним/студент/преподаватель → `404`; админ → доступ.
- Инвентаризация: `GET /api/v1/content/legacy-assets` (ADMIN), страница
`/admin/legacy-assets` и `scripts/legacy-assets.sh` для работы со стендом.
- Старые прямые ссылки на бесхозные файлы у обычных пользователей ломаются
осознанно.
- Студенческие страницы материалов не показывают админские действия статей
(удаление/правка/публикация/перемещение); регрессия закрыта тестом
`LearnSectionTemplateTest` в master-хотфиксе `920d96a`.
**JWT и сессия**
- Access JWT: **15 минут** (`LMS_ACCESS_TOKEN_TTL`).
- Refresh-токен: **7 дней** (`LMS_REFRESH_TOKEN_TTL`), хранится как SHA-256
hash, ротируется при обновлении.
- Абсолютный потолок сессии: **30 дней** (`LMS_SESSION_ABSOLUTE_TIMEOUT`);
ротация refresh не продлевает `absolute_expires_at` (миграция `V12`).
- Web-сессия: **7 дней sliding** (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`;
каждый ответ перевыпускает cookie с новым `Max-Age`, но не дольше остатка
абсолютного срока; access обновляется автоматически за 2 минуты до истечения.
- Logout и смена пароля отзывают refresh; отчисленный/архивный студент,
архивный аккаунт и закрытая группа не могут обновить сессию.
- Повторное использование старого refresh отзывает все активные сессии
аккаунта.
- Истёкшие refresh-токены старше 30 дней удаляет ночная задача
(`LMS_REFRESH_TOKEN_RETENTION`, `LMS_REFRESH_TOKEN_CLEANUP_CRON`).
- Gateway публикует без access-JWT: `/auth/login`, `/auth/setup`,
`/auth/refresh`, `/auth/logout`, `/auth/health`, `/auth/jwks.json`,
`/content/**`, `/actuator`.
- Миграции `identity V11__refresh_tokens.sql` и `V12__refresh_absolute.sql`.
### 1.5. Аудит
- `identity.audit_events` (V10), внутренний API `POST /api/v1/internal/audit`,
админский `GET /api/v1/audit`.
- Пишутся доступы/изменения курсов, групп, материалов, лабораторных, оценки,
публикации, защиты, профиль (staff), скачивания, lifecycle, promotion.
- `/admin/audit`: фильтр по действию, keyset-курсор `(occurred_at, id)`,
«Показать ещё» и «В начало»; offset `page` остался для совместимости.
- `V13` добавляет индексы `(occurred_at DESC, id DESC)` и
`(action, occurred_at DESC, id DESC)`.
### 1.6. Материалы и активы
- Курсовые материалы `/learn/course/{course_no}/...`; публичные глобальные
материалы `audience=ALL`; `STUDENTS`/`TEACHERS` требуют роль.
- Видимость `article_assets` наследуется от статьи/раздела/главы.
- `V12__asset_links_backfill.sql` восстанавливает связи из markdown-ссылок.
- Legacy-файлы без ссылок теперь закрыты (см. §1.4).
### 1.7. Повышение и жизненный цикл
- Promotion: dry-run, apply, idempotency, rollback, cancel, scheduler
31 августа МСК и catch-up, advisory locks, conflict detection.
- Student lifecycle: academic leave/return/expel/archive/restore.
- Concurrent apply/rollback покрыты интеграционными тестами.
- Apply/rollback на сгенерированных когортах покрыты model-based тестом.
### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`.
- **66 claims**: 27 P0 + 39 P1; **66/66 proven, P0 gaps = 0**.
- **398 автотестов**, 0 failures/errors/skipped.
- Mutation testing:
- `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%.
- Browser E2E Playwright: 6 сценариев × Chromium/Firefox/WebKit = **18/18**
(admin, teacher, student/anonymous, сдача + mobile, cursor-аудит,
инвентаризация активов).
- Runtime/load: 100 групп / 1000 студентов, 125 параллельных чтений,
0 ошибок, p95 < 1 c.
- CI: Gitea + Woodpecker 3.x, конфигурация в `.woodpecker/`:
- `check` на push/MR: тесты, ktlint, detekt, `evidenceCheck`;
- `smoke` на push/MR: после `check` — preflight + полный стек, health,
вход админа, один курс;
- `release` на тег или вручную: тесты, миграции, mutation, runtime/load,
browser E2E, финальный evidence.
- workflows требуют метку `isolation=dedicated-vm`; шаги идут в nested dind
без хост-Docker сокета, агент — на отдельной VM без production-доступа.
- `scripts/ci-preflight.sh` проверяет Java 21, Docker Compose v2, Python 3,
Node и Playwright. Установка server/agent — `deploy/WOODPECKER.md`.
- Первый реальный Woodpecker pipeline ещё не подтверждён.
- Полный `./scripts/ldd-release.sh` локально прошёл; browser E2E — 18/18.
### 1.9. Миграции
| Сервис | Диапазон | Что важно |
|---|---|---|
| identity | `V4–V14` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`), absolute (`V12`), keyset-индексы (`V13`), финальные ограничения когорты (`V14`) |
| academic | `V6–V12` | курсы, `labs.course_id`, главы/аудитории, индексы, `article_assets`, backfill |
| progress | `V13` | автор комментария/оценки; V13 идемпотентна |
- **Progress V10 отсутствует.** Не создавать и не включать `out-of-order`.
- Если dev/staging когда-то применял старую feature-миграцию progress V10,
удалить только строку истории через `flyway repair`/SQL из
`deploy/preflight/progress-v10-v13.sql`, объекты не удалять.
- Production/master: progress V11/V12 уже есть, затем V13.
- `labs.course_id NOT NULL` — post-bootstrap, не Flyway.
- `V14` намеренно падает на legacy-группах с NULL-когортой; SQL для проверки —
в `deploy/DEPLOY.md`.
### 1.10. Деплой и backup
- `deploy/backup.sh`: БД + submissions + assets, checksums, retention,
verify/restore; `deploy.sh up` требует backup.
- Ежедневный systemd timer.
- Production `.env.prod`: обязательны `LMS_DB_PASSWORD`, `LMS_INTERNAL_TOKEN`;
сроки сессии можно переопределить.
- `docker-compose.prod.yml` прокидывает:
`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`,
`LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION`,
`LMS_REFRESH_TOKEN_CLEANUP_CRON`, `LMS_INTERNAL_TOKEN`.
- Перед деплоем: backup, preflight, затем deploy, bootstrap курса C (если
курсов нет), postflight, smoke.
- Gateway не маршрутизирует `/api/v1/internal/**`.
---
## 2. Проверки: что и как запускать
```bash
# 1. Полный локальный gate
./gradlew test ktlintCheck detekt evidenceCheck
# 2. Полный LDD release (долго, нужны Docker/Java/Node + Chromium/Firefox/WebKit)
./scripts/ldd-release.sh
# 3. Быстрый smoke (то же, что workflow smoke)
./scripts/ldd-smoke.sh
# 4. Отдельные проверки
./scripts/ldd-migration-upgrade.sh
./scripts/ldd-mutation.sh
./scripts/ldd-runtime.sh # с LDD_SCALE=1 — 100 групп/1000 студентов
./scripts/ldd-browser.sh
# 5. Отчёт
cat build/reports/evidence/index.md
```
Последний подтверждённый результат:
- `398` тестов, 0 failures;
- `66/66` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS;
- smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
- `git diff --check` — чисто.
Артефакты: `build/evidence/{runtime,migration,mutation,browser,smoke}.json`.
---
## 3. Что осталось внести / закрыть
Это уже не P0-риски, а эксплуатационный backlog. Порядок recommended.
1. **Инвентаризация legacy-активов на реальном хранилище.**
Страница `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы.
Нужно на production/staging получить список бесхозных файлов, привязать их
к статьям или удалить. Старые внешние ссылки на такие файлы уже не работают
у обычных пользователей.
2. **Первый реальный CI-прогон.**
Развернуть Woodpecker server и Docker-backend агент на отдельной VM по
`deploy/WOODPECKER.md`, включить репозиторий в Woodpecker и дождаться
зелёных workflow `check`/`smoke` на push-MR и `release` на теге. Шаги идут
в nested dind; VM не имеет доступа к production и production-секретам.
3. **Визуальная регрессия и мобильный набор E2E.**
Сейчас 6 сценариев × 3 браузера, без screenshot-diff. Добавлять по мере
роста UI.
4. **Гонка импорта.**
Preflight закрывает известные конфликты, но параллельная вставка логина
всё ещё даёт общий 409.
5. **Restore на staging.**
Backup/restore-скрипты готовы; нужен реальный прогон на стенде с логом.
«Контингент»/`next.uniyar.ac.ru` **исключён из плана** решением от 2026-10-28:
нет API-спецификации и доступа к внешней системе. При появлении нового ТЗ
вернуться к отдельному anti-corruption слою.
---
## 4. Важные gotchas
- Не возвращать публичный доступ к legacy-активам.
- Не добавлять `number_label`/строковый номер группы: по требованиям номер
числовой, ведущего нуля быть не может.
- Не делать access JWT снова 30-дневным. 30 дней — только абсолютный потолок
сессии.
- Gateway обязан пропускать `/api/v1/auth/refresh` и `/api/v1/auth/logout`
без access-JWT; иначе автообновление и logout ломаются.
- `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`,
`LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION` и
`LMS_REFRESH_TOKEN_CLEANUP_CRON` должны попадать в environment
соответствующих контейнеров; сейчас `docker-compose.prod.yml` это делает.
- Session cookie `LMS_SESSION` перевыпускается фильтром на каждом ответе, но
`Max-Age` ограничен остатком абсолютного срока; не убирать ни фильтр, ни
проверку `AUTHENTICATED_AT`, иначе 30-дневный потолок перестанет работать.
- Keyset-аудит использует `(occurred_at, id)`; не менять сортировку на одну
`id` или `occurred_at` без пересчёта курсора. Offset `page` — только
совместимость.
- `article_assets` без связи → 404 для всех, кроме админа. Это намеренно.
- `/api/v1/labs/details` — пакетный контракт progress; не заменять его обратно
на цикл `/labs/{id}` без load-теста.
- WebKit на Fedora требует Docker-образа Playwright; `ldd-browser.sh` сам
переключается, не ломать этот fallback.
- Не коммитить `exports/`, `texput.log`, `data/`, `backups/`, `.env.prod`.
- Не включать Flyway `out-of-order`; progress V10 не существует.
- `labs.course_id NOT NULL` применяется bootstrap-сервисом в runtime.
- Перед production deploy — backup; без него deploy.sh не продолжит.
- Внутренние пути `/api/v1/internal/**` не должны появляться в gateway.
- `LMS_INTERNAL_TOKEN` должен быть одинаковым у identity/academic/progress.
---
## 5. Если контекст потерян: порядок восстановления
1. `git status --short --branch` — ожидается чистое дерево на
`feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-29.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 398 тестов
и 66 claims, 66/66 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и
первого реального CI-прогона.
## 6. Карта ключевых файлов
- `docs/07-groups-courses.md` — ТЗ.
- `docs/08-groups-courses-plan.md` — план и статус.
- `docs/16-stage6-report.md` — итоговый отчёт, deploy и ограничения.
- `docs/evidence/claims.json` — LDD-реестр.
- `docs/evidence/baseline-2026-10-29.md` — фактический baseline.
- `services/identity-service/.../auth/refresh/` — refresh-токены, absolute,
cleanup.
- `services/identity-service/.../db/migration/V11__refresh_tokens.sql`,
`V12__refresh_absolute.sql`, `V13__audit_keyset_indexes.sql`,
`V14__cohort_constraints.sql`.
- `services/identity-service/.../audit/` — аудит и keyset-курсор.
- `services/academic-service/.../content/` — материалы/активы.
- `services/academic-service/.../course/CourseGroupService.kt` — bulk-группы.
- `services/academic-service/.../lab/LabService.kt` — `details(courseId)`.
- `services/progress-service/.../AcademicClient.kt` — пакетный вызов лаб.
- `services/progress-service/.../SubmissionService.kt` — пакетные сдачи
ведомости; `profile/StudentProfileService.kt` — контракт профиля и лимит
попыток.
- `services/progress-service/.../CourseScopeService.kt` — course-scope.
- `web/.../session/SessionTokenService.kt`, `SessionLifetime.kt`,
`SessionCookieRefreshFilter.kt`.
- `web/.../admin/AdminAuditController.kt`, `LegacyAssetsController.kt`.
- `services/gateway/.../JwtAuthenticationFilter.kt`.
- `scripts/ldd-*.sh`, `scripts/ci-preflight.sh`, `scripts/ci-dind.sh`,
`scripts/ci-build-image.sh`, `scripts/legacy-assets.sh`.
- `.woodpecker/{check,smoke,release}.yaml` — CI на Gitea + Woodpecker.
- `ci/Dockerfile` — прогретый образ `lms-ci:1` для изолированного dind.
- `deploy/backup.sh`, `deploy/deploy.sh`, `deploy/DEPLOY.md`,
`deploy/WOODPECKER.md`.

185
docs/18-goal-audit.md Normal file
View File

@@ -0,0 +1,185 @@
# 18. Сверка цели и фактического состояния
**Дата:** 2026-10-29.
**Ветка:** `feature/groups-courses`.
**Источники цели:** `07-groups-courses.md`, `08-groups-courses-plan.md`,
`09-teacher-student-profile.md`, `16-stage6-report.md`, `17-handoff.md`.
Документ фиксирует, где приложение уже соответствует целевой модели, где
остались отличия и какие решения приняты по спорным местам. Это не отчёт
«всё готово»: статус `partial` означает реальный незакрытый пункт.
## 1. Сводка
| Область | Статус | Что осталось |
|---|---|---|
| Роли, группы-когорты, статусы, история | implemented | Финальные ограничения когорты, возврат из архива и блок истории членства на странице группы |
| Академический отпуск | **исправлено** | Вход read-only, сдача/защиты заблокированы на progress-service |
| Черновики лабораторных | **исправлено** | Студент не видит черновик в списке/деталях/по UUID |
| «Убрать из группы» | **исправлено** | Действие удалено из UI и API; остались перевод/академ/отчисление/архив |
| Импорт v2 | partial | Флаг восстановления и row-level отчёт для CSV/XLSX есть; гонка с параллельной вставкой логина всё ещё даёт общий 409 |
| Курсы, преподаватели, группы курса | implemented | — |
| Материалы и активы | implemented | Глобальные `STUDENTS`/`TEACHERS` теперь работают; импорт глобальных глав всё ещё создаёт только `ALL` |
| Профиль студента | implemented | Контракт 09 (`variant/level/updatedBy/name`), лимит попыток и SQL-тест «без N+1» сделаны |
| Повышение | partial | Догон при старте ищет только текущий год, не предыдущий; нет теста apply-конфликта |
| Аудит | implemented | `GROUP_CREATE/STUDENT_CREATE/GROUP_IMPORT/GROUP_ENROLL/GROUP_ARCHIVE/GROUP_RESTORE` проверены интеграционным тестом |
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы/группы/сдачи, пагинация групп и плоского списка материалов, лимит попыток профиля; остаются visual regression и SLA-нагрузка |
| LDD | implemented | 66 claims (27 P0 / 39 P1); `check` доказывает P0/unit, runtime P1 — release |
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
| «Контингент» | out of scope | Исключён решением 2026-10-28 |
## 2. Что исправлено в этой итерации
1. **Read-only академотпуск.**
- `AuthResponse`/`MeResponse` содержат `readOnly`; web-сессия хранит флаг.
- `StudentWriteGuard` в progress-service блокирует submit, start и finish защиты.
- UI скрывает сдачу/защиту и показывает объяснение.
- Тесты: `StudentLifecycleIntegrationTest#academic leave login is read only`,
`SubmissionServiceTest#academic leave student cannot submit`,
`DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense`.
2. **Черновики лабораторных скрыты от студентов.**
- `CourseAccessService.requireLabAccess` отдаёт `404` студенту для черновика.
- `GET /api/v1/labs` и `/labs/details` фильтруют черновики для студента.
- Тесты: `LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id`,
`CourseAccessServiceTest#student cannot access a draft lab of own course`.
3. **Удалено действие «Убрать из группы».**
- Удалены endpoint, web-маршрут, API-клиент, кнопка и сервисный метод.
- Тесты шаблонов проверяют отсутствие действия.
4. **Импорт больше не воскрешает неактивных студентов без флага.**
- `restoreInactiveStatuses` (по умолчанию `false`), `ImportResult.skipped`.
- Без флага сохраняются `EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE`, строка попадает в отчёт.
- С флагом студент активируется, архивный аккаунт разблокируется, пишется `IMPORT`.
- Тесты: `GroupServiceIntegrationTest#import does not resurrect...`,
`#import restores expelled student...`, `#import preserves academic leave...`,
`#import still updates an active student`, шаблонные тесты отчёта.
5. **Глобальные материалы `STUDENTS`/`TEACHERS`.**
- Дерево и статьи фильтруются по роли JWT; аноним — только `ALL`.
- Админ выбирает аудиторию при создании и может изменить её.
- Тесты: `GlobalAudienceAccessIntegrationTest` (5 сценариев).
6. **LDD gate на чистом checkout.**
- `LDD-AUTH-005` теперь P0 с unit-тестом gateway; полный refresh/handshake вынесен в P1 `LDD-AUTH-008` (runtime).
- `evidenceCheck` без `build/evidence` проходит по P0; runtime P1 закрываются в release.
7. **Backup/env.**
- `LMS_REFRESH_TOKEN_CLEANUP_CRON` в примерах закавычен.
- `backup.sh` больше не `source`-ит `.env.prod` (не исполняет cron как команду).
- `deploy.sh up` после backup вызывает `verify-latest`.
8. **Robots/noindex.**
- `robots.txt`; `X-Robots-Tag: noindex, nofollow` для admin/teacher/student/login/api/курсовых материалов.
9. **Профиль.**
- Старые комментарии без автора показывают «автор не указан».
- Студенческий профиль не показывает черновики лабораторных.
10. **Браузерные URL курсов — `courseNo`.**
Страницы преподавателя, админа, материалы и профиль студента принимают
человеческий номер курса; REST API продолжает работать с UUID.
11. **Импорт: ошибки до записи.**
- Занятый логин и неактивная целевая группа возвращают `applied=false`
с номером строки и не создают частичных записей.
- Тесты: `GroupServiceIntegrationTest#import reports occupied login...`,
`#import reports inactive target group...`, XLSX-варианты
`#xlsx import reports occupied login before writing()`,
`#xlsx import reports inactive target group before writing()`.
12. **Профиль приведён к контракту 09.**
- `variant.id`, `level.id/code`, `grade.updatedBy`, `author.name`.
- ФИО автора берётся из identity (fallback — логин).
- `submissionLimit` (по умолчанию 50) и `submissionsTotal`; `attemptsCount`
остаётся полным.
- SQL-тест: 10 лабораторных — не более 15 запросов.
- Тесты: `StudentProfileIntegrationTest`, `ProfileDtoJsonTest`,
`StudentProfileTemplateTest`, `InternalControllerIntegrationTest`.
13. **Финальные ограничения когорты.**
- `V14__cohort_constraints.sql`: `NOT NULL` для 5 полей, полный unique index,
строгие check-констрейнты; legacy-строки блокируют миграцию с явной ошибкой.
- `scripts/ldd-migration-upgrade.sh` проверяет `cohort_not_null=5` и
`cohort_unique_full=1` на пути master→branch.
14. **Возврат группы из архива.**
- `POST /api/v1/groups/{id}/restore`, кнопка на странице группы, аудит
`GROUP_RESTORE`; повторный возврат активной группы — 400.
15. **Пагинация и пакетные сдачи.**
- `GET /api/v1/groups?page=&limit=` и страница `/admin/groups`.
- `GET /api/v1/admin/content/articles?offset=&limit=`.
- `SubmissionService.latestPerStudentByLab` — один запрос на все лабы
ведомости вместо запроса на каждую.
16. **Аудит ростера.**
- Интеграционный тест проверяет шесть событий и актора.
17. **История членства на странице группы.**
- `GET /api/v1/groups/{id}/history` отдаёт студента, действие,
откуда/куда и причину; шаблон показывает последние 200 событий в МСК.
- Тесты: `GroupServiceIntegrationTest`, `AdminStage4TemplateTest`.
## 3. Остаточные отличия от цели
### P1
- **Backup restore на staging** не подтверждён артефактом/логом.
- **Первый реальный CI pipeline** на Woodpecker VM не подтверждён.
### Осознанные границы
- **Автоматический догон повышения за предыдущий календарный год не делаем.**
Планировщик не может безопасно восстановить историческое состояние групп:
текущие `course` уже могут быть изменены. Догон в том же году после 31 августа
работает; для прошлого года админ запускает dry-run с явным годом и проверяет
план вручную. Это защищает от повторного повышения по устаревшему состоянию.
### P2
- **Импорт: гонка.** Preflight закрывает известные конфликты, но гонка с
параллельной вставкой логина всё ещё даёт общий 409 вместо строки отчёта.
- **Визуальная регрессия.** Playwright проверяет сценарии и мобильный viewport,
но screenshot-diff нет.
- Mutation testing не 100%, нагрузочный smoke не SLA.
### Операционные
- Инвентаризация legacy-активов на реальном хранилище.
- Установка/проверка daily backup timer на сервере.
- Первый push-MR и tag pipeline в Woodpecker.
- Restore на staging.
## 4. Принятые решения
- `ACADEMIC_LEAVE`: блокировать только сдачу и защиты; чтение и скачивание
своих файлов разрешены.
- «Убрать из группы»: действие удалено полностью.
- Импорт терминальных/академических статусов: только по явному флагу
`restoreInactiveStatuses`.
- Глобальные материалы: `ALL/STUDENTS/TEACHERS` поддерживаются; `STUDENTS`
видит студент, `TEACHERS` — преподаватель, аноним — только `ALL`.
- «Контингент» остаётся вне scope.
## 5. LDD
- Реестр: `docs/evidence/claims.json`, **66 claims** (27 P0 / 39 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **66/66 claims**, 398 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с.
## 6. Следующий порядок работ
1. Остались операционные P1: restore на staging и первый реальный
Woodpecker pipeline.
2. P2: гонка импорта, визуальная регрессия, повышение mutation-покрытия
и SLA-нагрузка.
3. Перед production: ручной разбор legacy-когорт по SQL из `deploy/DEPLOY.md`,
затем backup и деплой.
4. После каждого изменения прогонять `./scripts/ldd-release.sh` и обновлять
`docs/evidence/baseline-2026-10-29.md`.

View File

@@ -14,7 +14,21 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы | | [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы |
| [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов | | [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов |
| [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip | | [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip |
| [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск, интеграция с «Контингент» | | [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск; «Контингент» исключён из плана |
| [08-groups-courses-plan.md](08-groups-courses-plan.md) | План реализации ТЗ 07: решения, этапы 0–6, профиль студента курса, агенты и skills |
| [09-teacher-student-profile.md](09-teacher-student-profile.md) | Контракт API профиля студента курса для преподавателя |
| [10-stage0-report.md](10-stage0-report.md) | Отчёт этапа 0: миграции, preflight и проверка на копии БД |
| [11-stage1-report.md](11-stage1-report.md) | Отчёт этапа 1: роли, группы-когорты, статусы, история, импорт v2 |
| [12-stage2-report.md](12-stage2-report.md) | Отчёт этапа 2: курсы, bootstrap курса C, привязка и копирование лабораторных, миграция V10 |
| [13-stage3-report.md](13-stage3-report.md) | Отчёт этапа 3: преподаватели, доступ к курсам, внутренние API, кабинет и профиль студента курса |
| [14-stage4-report.md](14-stage4-report.md) | Отчёт этапа 4: повышение, академотпуск, отчисление, архив, восстановление и удаление |
| [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и secure-by-default legacy-файлы |
| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения |
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
| [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения |
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
| [evidence/baseline-2026-10-29.md](evidence/baseline-2026-10-29.md) | LDD-baseline: 66 claims, 398 тестов, upgrade, runtime/scale, mutation и browser E2E |
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе ## Кратко о системе

View File

@@ -0,0 +1,151 @@
# ADR 0001. Фундамент групп, курсов и доступа
**Статус:** принято.
**Дата:** 2026-10-23.
**Контекст:** реализация [ТЗ 07](../07-groups-courses.md) и
[плана 08](../08-groups-courses-plan.md).
## Контекст
Сейчас в LMS нет сущности курса-дисциплины, ролей преподавателя, статусов
студентов и истории членства. Лабораторные привязаны к семестру, материалы —
к глобальному дереву глав. Доступ различает только `ADMIN` и «всё остальное»;
проверки в основном находятся в web-BFF.
Нужно перейти к модели:
- курс-дисциплина с преподавателями и группами;
- группы-когорты с потоком, курсом обучения, номером, степенью и годом набора;
- студенты со статусами и историей;
- лабораторные и материалы, принадлежащие курсу;
- проверки доступа на сервисах;
- профиль студента курса для преподавателя.
## Решения
### 1. Курс C и `course_no`
- Текущий единственный курс — «Промышленное программирование на языке C».
- UUID остаётся первичным ключом курса.
- Для URL добавляется `courses.course_no`:
`BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE NOT NULL`.
- Курсовые материалы открываются по
`/learn/course/{course_no}/{chapter}/{section}/{article}`.
- `course_no` не является секретом; доступ проверяется отдельно.
- Старые ссылки на текущие материалы не поддерживаются: они не публиковались.
### 2. Все лабораторные и материалы принадлежат курсу
- «Общих» лабораторных нет.
- Все текущие лабы, главы, разделы, статьи и активы переносятся в курс C.
- `labs.course_id` вводится как nullable на время миграции, затем становится
`NOT NULL`.
- `chapters.course_id` остаётся nullable, потому что глобальные материалы
сохраняются как отдельный сценарий. `audience` применяется только к глобальным
главам (`course_id IS NULL`).
- `sections` и `articles` наследуют видимость от главы.
- Активы наследуют видимость от статьи → раздела → главы.
### 3. Двухфазная миграция и bootstrap
Обычный Flyway-старт применяет миграции до бизнес-логики, а курс C должен
принадлежать реальному админу. Поэтому:
1. Миграции добавляют nullable-поля и новые таблицы.
2. После первого входа админа bootstrap-сервис:
- создаёт курс C с `owner_account_id` админа;
- привязывает к нему все текущие активные группы;
- проставляет `labs.course_id` и `chapters.course_id`;
- проверяет дубли `(number, course_id)` и сообщает о конфликтах.
3. После успешного bootstrap отдельным шагом применяется
`ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL`.
`SET NOT NULL` не входит в обычную цепочку Flyway: иначе миграция выполнится
раньше bootstrap и упадёт на существующих лабах. Точный механизм шага
(CLI, admin endpoint или отдельная Flyway-локация) фиксируется на этапе 2.
### 4. Проверки доступа на сервисах
- JWT содержит только `sub`, `role`, `login`, `name`; списки курсов и групп
в токен не кладутся.
- Каждый сервис проверяет доступ по своим данным и данным источника истины:
- identity — аккаунт, студент, группа, статус;
- academic — курс, преподаватель, группа курса, лабораторная, материал;
- progress — оценки, сдачи, защиты, публикации.
- Для межсервисных проверок используются внутренние эндпоинты
`/api/v1/internal/**`:
- identity: контекст студента (группа, статус, год набора);
- academic: доступ к курсу (активный преподаватель, привязанная группа,
статус курса), список лаб курса.
- Внутренние эндпоинты не маршрутизируются gateway и защищены отдельным
сервисным токеном (`X-Internal-Token`), а не только пользовательским JWT.
- Кросс-схемные SQL-запросы и внешние ключи между `identity`, `academic` и
`progress` не добавляются.
- Отзыв доступа к курсу/группе/публикации действует сразу, потому что
проверяется на каждом запросе по текущим данным.
### 5. Статусы и вход
- `accounts.status`: `ACTIVE | ARCHIVED`.
- `students.status`: `ACTIVE | ACADEMIC_LEAVE | EXPELLED | GRADUATED | ARCHIVED`.
- Вход запрещён для `EXPELLED`, `GRADUATED`, `ARCHIVED` и архивных аккаунтов.
- `ACADEMIC_LEAVE` — вход в режиме только чтения.
- Web перепроверяет статус с кэшем 30–60 секунд; сервисы проверяют статус
студента и группы при доступе к курсу.
- ~~JWT остаётся 30-дневным~~ — решение пересмотрено: access 15 минут,
refresh 7 дней с ротацией и абсолютным потолком 30 дней (`V11`–`V12`).
Для браузера токен не доступен.
### 6. Повышение курса
- `promotion_runs` — шапка запуска.
- `promotion_run_items` — по-элементный план/результат для групп и студентов.
- `membership_history` — история студента; ссылается на запуск.
- 31 августа в 00:00 МСК автоматически создаётся `DRY_RUN`.
- Применение — только вручную админом после проверки плана.
- Один успешный `APPLY` на год; откат — по элементам журнала с проверкой
конфликтов.
### 7. Профиль студента курса
- Преподаватель активного курса открывает профиль студента по клику на ФИО
или отдельной кнопке.
- Профиль агрегирует:
- статистику по лабам курса;
- комментарии преподавателей (общий и отдельные) с автором и датой;
- все попытки сдачи и скачивание файлов любой попытки.
- Доступ проверяется на сервисах: преподаватель — только свой курс и студенты
его групп; админ — любой; студент — только свой профиль (опционально).
- Контракт API: [09-teacher-student-profile.md](../09-teacher-student-profile.md).
### 8. Резервное копирование
- Backup обязателен перед каждым деплоем/миграцией; при ошибке backup деплой
прерывается.
- Backup включает БД (`pg_dump --format=custom`), volume решений и volume
материалов, `SHA256SUMS` и manifest.
- Ежедневный backup по systemd timer; retention по умолчанию 14 дней.
- Восстановление — только с подтверждением `RESTORE`; после restore проверяется
health web.
- Backup на том же сервере не считается достаточным: нужна внешняя копия и
периодическая проверка restore на staging.
- Инструкция: [deploy/BACKUP.md](../../deploy/BACKUP.md).
## Последствия
- Схема становится шире, но миграции аддитивные и не ломают старый код.
- Появляется отдельный bootstrap-шаг; его нельзя пропускать при развёртывании.
- Нужны внутренние эндпоинты и сервисный токен.
- Нужно обновить security-конфиги, gateway и web-интерфейс.
- Старые ссылки на материалы перестанут работать; это осознанное решение.
- До включения `NOT NULL` в `labs.course_id` возможны лабы без курса; preflight
и bootstrap-отчёт обязательны.
- Появляется обязательный backup-шаг; без него деплой не выполняется.
## Открытые вопросы
- Механизм post-bootstrap `SET NOT NULL`: CLI, admin endpoint или отдельная
Flyway-локация. Решение — этап 2.
- Формат аудита скачиваний профиля: общий `audit_events` или отдельная таблица.
- Поведение профиля для студентов, отвязанных от курса/архивных: история или
`404`. Решение — этап 3.

117
docs/evidence/README.md Normal file
View File

@@ -0,0 +1,117 @@
# LDD: доказательное тестирование
Этот каталог — реестр утверждений о системе и свидетельств того, что они
выполняются. LDD здесь означает не «тесты вместо требований», а
**риск-ориентированную проверку**: требование → проверяемое утверждение →
независимый оракул → воспроизводимое свидетельство → допуск.
## Уровни
- **P0** — утверждение о безопасности, сохранности данных, доступах или
ключевом доменном правиле. P0 без зелёного свидетельства валит
`./gradlew evidenceCheck`.
- **P1** — важное поведение/UX/контракт. Пробел показывается в отчёте, но не
блокирует сборку.
- **runtime** — проверка на реально поднятом стеке. Быстрый smoke
(`scripts/ldd-smoke.sh`) выполняется в CI на каждом push/MR; полный
runtime/scale — в workflow `release` и обязателен перед релизом.
## Реестр
`claims.json` содержит:
- `id` — стабильный идентификатор (`LDD-ACCESS-001`);
- `title` — короткая формулировка;
- `criticality` — `P0`/`P1`;
- `status` — `active` или `gap`;
- `requirement` — ссылка на ТЗ/отчёт;
- `claim` — проверяемое утверждение (что именно истинно);
- `oracle` — как это проверяется;
- `evidence` — список `fully.qualified.Class#test name()` из JUnit XML;
- `runtime` — идентификаторы проверок из `build/evidence/runtime.json`;
- `limitations` — что утверждение **не** покрывает.
## Запуск
```bash
# 1. Все тесты и линтеры
./gradlew test ktlintCheck detekt
# 2. Гейт свидетельств (P0 без evidence = fail)
./gradlew evidenceCheck
# 3. Быстрый smoke полного стека (то же, что workflow smoke)
./scripts/ldd-smoke.sh
# 4. Полный runtime-smoke + scale load на одноразовой БД
./scripts/ldd-runtime.sh
# 5. Проверка upgrade с master-схемы
./scripts/ldd-migration-upgrade.sh
# 6. Mutation testing критичных P0-компонентов
./scripts/ldd-mutation.sh
# 7. Браузерный E2E (Playwright: Chromium/Firefox/WebKit)
./scripts/ldd-browser.sh
# 8. Полный release gate
./scripts/ldd-release.sh
```
Для E2E один раз установите зависимости и браузеры: `cd e2e && npm install && npx playwright install chromium firefox webkit`.
Mutation harness использует JUnit 4: JUnit5-плагин PIT несовместим с JUnit
Platform 6 из Spring Boot 4.
Отчёт: `build/reports/evidence/index.md`.
## Правила
1. Новое P0-требование без свидетельства не мержится.
2. Свидетельство должно быть воспроизводимым в CI, а не «проверил руками».
3. Тест должен падать при поломке соответствующей логики. Для критичных
пакетов это измеряется mutation testing: сейчас `CourseAccessService` —
75% test strength, `PromotionPlanner` — 69%.
4. Один тест может подтверждать несколько claims, но claim не должен
опираться на тест, который проверяет только реализацию.
5. `limitations` обязателен: LDD не должен создавать иллюзию полного
доказательства.
6. При переименовании теста обновляется `claims.json`; устаревшая ссылка —
это gap, а не «забыли».
7. Runtime-свидетельства обязательны перед релизом; CI на каждом push/MR
выполняет unit/integration-слой и быстрый smoke, полный release gate —
для тегов (вручную для веток).
## Текущий baseline
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков:
- **398 автоматических тестов**, 0 failures/errors;
- **66 claims**: 27 P0 + 39 P1, **66/66 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V14, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,
копирование лаб, keyset-пагинация, пакетные детали лабораторных,
абсолютный потолок сессии, runtime, mutation и browser E2E;
- mutation testing: `CourseAccessService` 75% test strength,
`PromotionPlanner` 69%;
- Playwright: 6 сценариев (admin, teacher, student/anonymous, сдача+mobile,
cursor-аудит, инвентаризация активов) × Chromium/Firefox/WebKit = **18/18**,
все зелёные;
- масштабный load: 100 групп / 1000 студентов, 125 параллельных чтений,
0 ошибок, p95 < 1 с (порог p95 < 2 с);
- CI: Gitea + Woodpecker, workflow `smoke` (`ldd-smoke.sh`) на каждом push/MR;
полный `release` (`ldd-release.sh`) обязателен для тегов и ручной для веток.
Установка — `deploy/WOODPECKER.md`.
В ходе E2E найден и исправлен реальный дефект: Jackson 3 не применял
Kotlin-модуль, поэтому DTO с default-значениями требовали все поля JSON
(например, создание студента без `firstName`/`basis` давало 400).
Остаточные ограничения: ручная инвентаризация legacy-активов на production,
сдачи по выбранной лабораторной в ведомости, непроверенный первый CI-прогон на
реальных runner'ах и отсутствие визуальной регрессии. Legacy-активы, срок
access-токена, пагинация, N+1 деталей лабораторных, абсолютный лимит сессии и
«ведущие нули» больше не являются открытыми рисками.
Результаты — в `baseline-2026-10-29.md`; предыдущий срез — `baseline-2026-10-27.md`.

View File

@@ -0,0 +1,322 @@
# LDD baseline: 2026-10-27/28
Три LDD-цикла проверки текущей функциональности LMS; последний выполнен
2026-10-28 после закрытия продуктовых рисков. Добавлены реестр claims, gate
`evidenceCheck`, runtime/migration/mutation/smoke-скрипты и браузерный E2E на
Playwright. В ходе проверки найден и исправлен реальный дефект Jackson 3.
## 1. Что проверялось
Реестр: `docs/evidence/claims.json`, **51 утверждение**.
| Критичность | Claims | Proven | Gaps |
|---|---:|---:|---:|
| P0 | 25 | 25 | 0 |
| P1 | 26 | 26 | 0 |
| **Итого** | **51** | **51** | **0** |
Домены:
- доступы: аноним/студент/преподаватель/админ, чужие курс/лаба/студент/группа,
внутренние API identity;
- повышение: dry-run, apply, идемпотентность, rollback, cancel;
- жизненный цикл: академ, возврат, отчисление, архив, восстановление, вход;
- профиль студента и скачивание сдач;
- материалы и активы, включая secure-by-default legacy-файлы;
- миграции V11–V13, полный upgrade с master-схемы;
- аудит: токен, admin-only, сохранность после удаления, representative-хуки,
keyset-пагинация;
- аутентификация: короткий access-токен, ротация/отзыв refresh, web-сессия,
абсолютный потолок 30 дней и очистка истёкших токенов;
- группы курса и детали лабораторных без N+1, авторство материалов, ведомость,
UI/контракты, импорт, копирование лабораторных;
- runtime-smoke, масштабный load, mutation testing, браузерный E2E и CI smoke.
## 2. Команды и результаты
### 2.1. Автоматические тесты
```bash
./gradlew test --rerun-tasks
```
Результат: **385 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate
```bash
./gradlew evidenceCheck
```
Результат: **59/59 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`.
Gate устроен так:
- каждый claim ссылается на конкретный JUnit-тест `Class#method`;
- P0 без зелёного теста валит сборку;
- P1-пробел показывается в отчёте;
- runtime-проверки читаются из `build/evidence/*.json` и тоже должны быть
`passed`, если claim на них ссылается.
### 2.3. Upgrade с master-схемы
```bash
./scripts/ldd-migration-upgrade.sh
```
Сценарий: одноразовая PostgreSQL 18, master identity `V1–V3`, academic
`V1–V5`, progress `V1–V9,V11,V12`, синтетические данные, затем ветка:
identity `V4–V13`, academic `V6–V12`, progress `V13`.
Проверено:
- `identity.audit_events` (`V10`), `identity.refresh_tokens` (`V11`),
`absolute_expires_at` (`V12`) и keyset-индекс `audit_events_occurred_id_idx`
(`V13`) созданы;
- `students`, `groups`, `membership_history` сохранились;
- `labs.course_id`, `chapters.course_id`, `article_assets` созданы;
- `grade_comments.author_account_id`, `lab_grades.updated_by_account_id` созданы;
- оценки и комментарии сохранились;
- повторное применение `V13` идемпотентно.
Результат: **PASS**, evidence: `build/evidence/migration.json`.
### 2.4. Runtime-smoke полного стека и CI smoke
```bash
./scripts/ldd-runtime.sh
./scripts/ldd-smoke.sh # то же, что CI workflow smoke
```
Сценарий: одноразовая БД, `bootJar`, запуск identity/academic/progress/gateway/web,
реальные HTTP-вызовы через gateway.
Проверено:
- health всех пяти сервисов = `UP`;
- `adminExists=false` до setup, создание админа, `/auth/me` = `ADMIN`;
- создание группы, курса, преподавателя, привязка группы, создание лабы;
- преподаватель входит и видит свой курс, лабу и состав группы;
- чужой преподаватель получает `403` на курс, лабу и аудит;
- админ видит audit-события `COURSE_CREATE`, `TEACHER_CREATE`,
`COURSE_TEACHER_ASSIGN`, `COURSE_GROUP_ATTACH`, `LAB_CREATE`;
- web `/login` отдаёт `200`, публичное дерево материалов доступно, курсовые
материалы анонимно — `401`.
`ldd-smoke.sh` выполняет тот же полный стек, health, вход админа и один
доменный сценарий; это обязательный workflow `.woodpecker/smoke.yaml` на
каждом push/MR, поэтому базовый runtime-контур проверяется до merge, а не
только перед релизом.
Результат: **PASS**, evidence: `build/evidence/runtime.json` и
`build/evidence/smoke.json`.
### 2.5. Legacy-активы: secure-by-default
Проверено автоматическими тестами доступа к активам:
- файл без строки в `article_assets` отдаёт `404` анониму, студенту и
преподавателю (существование не раскрывается);
- админ может скачать такой файл;
- `GET /api/v1/content/legacy-assets` защищён `ROLE_ADMIN` и перечисляет
несвязанные файлы; админская страница `/admin/legacy-assets` и
`scripts/legacy-assets.sh` дают тот же список для привязки или удаления;
- связанный актив по-прежнему наследует видимость статьи → раздела → главы:
публичная `ALL` — без входа, курсовая — только участникам курса, скрытая
для студента — `404`.
Старые внешние ссылки на несвязанные файлы у обычных пользователей перестают
работать. Это осознанное изменение поведения: безопасность важнее совместимости
с «осиротевшими» активами.
Результат: **PASS**, claims `LDD-ASSET-001`, `LDD-ASSET-002`.
### 2.6. Аутентификация, refresh и web-сессия
- identity `V11__refresh_tokens.sql` хранит только SHA-256-хеш refresh-токена,
срок, отзыв и цепочку замен;
- access JWT — 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh — 7 дней
(`LMS_REFRESH_TOKEN_TTL`);
- `POST /api/v1/auth/refresh` ротирует пару; повторное использование старого
токена отзывает все сессии аккаунта;
- logout и смена пароля отзывают refresh; отчисленный, выпущенный, архивный
студент, архивный аккаунт и закрытая группа не могут обновить сессию;
- `V12__refresh_absolute.sql` задаёт `absolute_expires_at`; ротация не
продлевает абсолютный срок, после 30 дней refresh отклоняется;
- web-сессия — 7 дней sliding (`LMS_SESSION_TIMEOUT`), но не дольше 30 дней
(`LMS_SESSION_ABSOLUTE_TIMEOUT`); cookie `LMS_SESSION`
(`HttpOnly`, `SameSite=Lax`, `Max-Age` перевыпускается на каждом ответе и
ограничен остатком абсолютного срока);
- access обновляется автоматически за 2 минуты до истечения, поэтому активная
вкладка не требует входа каждые 15 минут; logout отзывает refresh;
- истёкшие refresh-токены старше retention (30 дней) удаляет ночная задача
(`LMS_REFRESH_TOKEN_CLEANUP_CRON`), свежие отозванные токены сохраняются для
detection reuse.
Прямой API-клиент должен сам использовать `POST /api/v1/auth/refresh`;
автоматического обновления вне web BFF нет.
Результат: **PASS**, claims `LDD-AUTH-001`, `LDD-AUTH-002`, `LDD-AUTH-003`,
`LDD-AUTH-006`, `LDD-AUTH-007`.
### 2.7. Пагинация, N+1 и масштаб
- `GET /api/v1/audit` принимает keyset-курсор `cursor` (opaque `occurredAt|id`),
`limit` (максимум 500) и `action`; offset `page` остался для совместимости.
Курсорные страницы не пересекаются, битый курсор — `400`; в UI «Показать ещё»
идёт по курсору, «В начало» возвращает к первой странице.
- `GET /api/v1/labs/details` отдаёт все лабораторные курса с уровнями и
вариантами одним запросом; progress делает ровно один HTTP-вызов вместо
запроса на каждую лабораторную.
- `GET /api/v1/courses/{id}/groups` возвращает привязанные группы с именами
одним вызовом; страница преподавателя не делает N+1 по именам групп.
- Полный load использует seed на **100 групп / 1000 студентов** (SQL, только
release-контур) и **125 параллельных чтений** курса, лаб, профиля, групп и
аудита: **0 ошибок, p95 < 1 с** (порог p95 < 2 с).
Остаётся запрос сдач по выбранной лабораторной в ведомости — это не N+1 по
студентам и не продуктовый риск доступа.
Результат: **PASS**, claims `LDD-AUDIT-004`, `LDD-AUDIT-005`, `LDD-PERF-001`,
`LDD-PERF-002`, `LDD-RUNTIME-003`.
### 2.8. Mutation testing
```bash
./scripts/ldd-mutation.sh
```
PIT запускается через CLI с JUnit 4 harness: JUnit5-плагин PIT несовместим
с JUnit Platform 6 из Spring Boot 4.
| Компонент | Мутаций | Убито | Test strength | Порог |
|---|---:|---:|---:|---:|
| `CourseAccessService` | 56 | 42 | **75%** | 70% |
| `PromotionPlanner` | 162 | 109 | **69%** | 60% |
Результат: **PASS**, evidence: `build/evidence/mutation.json`.
Остаток — эквивалентные мутации, Kotlin-интринсики и ветки, не входящие в
текущий P0-контур.
### 2.9. Браузерный E2E
```bash
./scripts/ldd-browser.sh
```
Playwright в Chromium, Firefox и WebKit на полном стеке, одноразовая БД:
- admin: вход, `/admin/courses` с курсом, `/admin/audit` с событием;
- teacher: вход, курс, ведомость (группа/студент, без админских кнопок),
профиль студента, создание главы курса;
- anonymous: `/admin/audit` → `/login`;
- student: вход, `/admin/audit` → `/student`, `/learn/course/{no}`;
- admin: cursor-пагинация аудита («Показать ещё» → «Продолжение» → «В начало»);
- admin: страница `/admin/legacy-assets` со списком или пустым состоянием.
Результат: **18/18 passed** (6 сценариев × 3 браузера), evidence:
`build/evidence/browser.json`. Дополнительно проверены сдача файла студентом и
mobile-размер teacher-страницы. На Fedora WebKit запускается через Docker-образ
Playwright, потому что системных Ubuntu-библиотек нет.
### 2.10. Конкурентные apply/rollback
`PromotionConcurrencyIntegrationTest` (без `@Transactional`, два потока,
advisory lock): два одновременных apply идемпотентны и пишут history один раз;
два одновременных rollback дают ровно один успех и один `ConflictException`,
состояние восстанавливается один раз.
### 2.11. Release gate и CI
`./scripts/ldd-release.sh` последовательно запускает тесты/линтеры, upgrade,
mutation, runtime + scale и browser E2E, затем финальный `evidenceCheck`.
В `.woodpecker/` (Gitea + Woodpecker 3.x):
- `check` — `test ktlintCheck detekt evidenceCheck` на push/MR;
- `smoke` — `ldd-smoke.sh` (полный стек, health, вход админа, один курс)
на каждом push/MR, после `check`;
- `release` — полный `ldd-release.sh` с Chromium/Firefox/WebKit и Node:
обязателен для тегов, для веток запускается вручную.
- workflows требуют метку `isolation=dedicated-vm`; агент работает на
отдельной VM, шаги — в `lms-ci:1` с nested dockerd и без хост-Docker сокета.
- smoke/release начинаются с `scripts/ci-preflight.sh`, который проверяет
Java 21, Docker Compose v2, Python 3, Node и Playwright.
- Первый реальный прогон на Woodpecker-агенте ещё не подтверждён; локальные
скрипты и конфигурация проверены, установка — `deploy/WOODPECKER.md`.
### 2.12. Найденный дефект
Первый browser E2E упал на создании студента: API вернул `400`, потому что
Jackson 3 не применял Kotlin-модуль, и DTO с default-значениями требовали все
поля JSON. Исправлено: `jackson-module-kotlin` для Jackson 3 +
`JsonMapperBuilderCustomizer` в identity/academic/progress/web. Добавлен
регрессионный тест `TeacherIntegrationTest.student creation accepts omitted
optional json fields`.
## 3. Что это доказывает
- Критичные P0-инварианты доступа, повышения, жизненного цикла, профиля,
материалов, миграции, аудита и аутентификации имеют зелёные автоматические
свидетельства.
- Приложение собирается, мигрирует с master-схемы и поднимается полным
стеком на чистой БД.
- Ключевой доменный поток «админ → группа → курс → преподаватель → лаба»
проходит через реальные HTTP/gateway/БД и браузер.
- Чужой преподаватель не получает доступ к курсу/лабе/аудиту на реальном стеке.
- Legacy-актив без привязки не утекает обычным пользователям, а админ может
провести инвентаризацию.
- Refresh-токены ротируются и отзываются; закрытый статус блокирует продление
сессии, абсолютный потолок 30 дней ограничивает сессию, а web-сессия
переживает 15-минутный access-токен.
- Аудит листается keyset-курсором, детали лабораторных и группы курса
отдаются пакетно без N+1.
- Аудит пишется и читается; upgrade не теряет синтетические данные;
истёкшие refresh-токены очищаются.
- Мутационная прочность критичных компонентов измерена, а не заявлена.
- Масштабный load на 100 группах / 1000 студентов проходит без ошибок.
- Реальный дефект DTO/Jackson найден именно E2E-контуром и закрыт тестом.
## 4. Чего это не доказывает
1. **Mutation testing не 100%.** Остаются эквивалентные мутанты и
Kotlin/Spring-специфичные мутации; PIT harness — JUnit 4, а не JUnit 5.
2. **Property-based тесты покрывают не все ветки.** Есть генераторы для
матрицы доступа, планировщика и 10 транзакционных apply/rollback-кейсов,
но не для всех сочетаний полей и распределённой нагрузки.
3. **Браузерный E2E — 3 браузера, 6 сценариев.** Нет визуальной регрессии и
полного мобильного набора страниц; WebKit на Fedora требует Docker-образа.
4. **Нагрузочный smoke — не benchmark.** Одна машина, синтетические данные,
без SLA production; 125 чтений и p95 < 1 с — ориентир, а не гарантия.
5. **Сдачи по выбранной лабораторной в ведомости** запрашиваются отдельно;
это не N+1 по студентам, но и не пакетная выборка.
6. **Инвентаризация legacy-активов на production не выполнена.** UI, API и
скрипт готовы, но реальные файлы нужно привязать или удалить вручную.
7. **Первый CI-прогон не подтверждён.** Конфигурация `.woodpecker/` и
preflight готовы, но Woodpecker-сервер и Docker-backend агент на отдельной
VM ещё нужно развернуть и проверить на первом push-MR и теге.
8. **Полный release не на каждом коммите.** На push/MR идёт smoke,
а mutation/browser/scale — для тегов или вручную.
9. **Интеграция «Контингент» исключена из плана** — без нового ТЗ и доступа к
API она не проверялась и не реализуется.
10. **Номер группы числовой.** Отдельного требования к ведущим нулям нет;
`01` и `1` — одно значение, это не открытый риск.
## 5. Вывод
Текущая функциональность прошла LDD-контур: **59/59 claims proven, P0 gaps = 0**,
**385 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
масштабный load зелёные. Это достаточное основание продолжать доработки, но не
«формальное доказательство всей системы».
Следующий цикл LDD:
1. прогнать инвентаризацию legacy-активов на production/staging через
`/admin/legacy-assets` или `scripts/legacy-assets.sh` и привязать/удалить
найденные файлы;
2. развернуть Woodpecker и подтвердить первый push-MR pipeline и tag-job;
3. при необходимости расширить E2E на визуальную регрессию и остальные
мобильные страницы;
4. пакетно отдавать сдачи в ведомости, если появятся медленные курсы.

View File

@@ -0,0 +1,44 @@
# LDD baseline: 2026-10-29
Актуальный прогон после закрытия P1/P2-остатка: контракт профиля, финальные
ограничения когорты, возврат группы из архива, пагинация, пакетные сдачи,
row-level XLSX-импорт и audit-события ростера.
## Результат
| Проверка | Результат |
|---|---|
| JUnit/интеграционные тесты | **398**, 0 failures/errors/skipped |
| ktlint + detekt | зелёные |
| Claims | **66** (27 P0 / 39 P1), **66/66 proven**, P0 gaps = 0 |
| Migration master→branch | PASS; identity `V4–V14`, повторные V13/V14 |
| Mutation `CourseAccessService` | 75% test strength (порог 70%) |
| Mutation `PromotionPlanner` | 69% test strength (порог 60%) |
| Browser E2E | 18/18 (Chromium/Firefox/WebKit) |
| Load smoke | 125 запросов, 0 ошибок, p95 0.918 с (порог < 1 с) |
## Что добавлено в этой итерации
- профиль соответствует `docs/09`: `variant.id`, `level.id/code`,
`grade.updatedBy`, `author.name`; `submissionLimit`/`submissionsTotal` и
SQL-тест «без N+1» на 10 лабораторных;
- `V14__cohort_constraints.sql`: `NOT NULL` для пяти полей когорты, полный
unique index и строгие check-ограничения; legacy-строки останавливают
миграцию с явной ошибкой;
- возврат группы из архива (`POST /api/v1/groups/{id}/restore`) с аудитом;
- пагинация `/admin/groups` и плоского списка материалов;
- `latestPerStudentByLab` — один запрос сдач на все лабораторные ведомости;
- XLSX-тесты preflight-ошибок импорта (занятый логин, неактивная группа);
- аудит `GROUP_CREATE/STUDENT_CREATE/GROUP_ENROLL/GROUP_IMPORT/GROUP_ARCHIVE/GROUP_RESTORE`;
- история членства на странице группы (последние 200 событий).
## Остаточные ограничения
- гонка импорта с параллельной вставкой логина всё ещё даёт общий 409;
- screenshot-diff/визуальной регрессии нет;
- mutation testing не 100%, нагрузочный smoke не является SLA;
- restore на staging, первый реальный Woodpecker pipeline, инвентаризация
legacy-активов и daily backup timer требуют доступа к стенду/серверу.
Артефакты прогона: `build/evidence/{runtime,migration,mutation,browser,smoke}.json`,
отчёт gate — `build/reports/evidence/index.md`.

979
docs/evidence/claims.json Normal file
View File

@@ -0,0 +1,979 @@
{
"version": 1,
"description": "LDD claims для текущей функциональности LMS. P0 без зелёного свидетельства валит ./gradlew evidenceCheck.",
"claims": [
{
"id": "LDD-ACCESS-001",
"title": "Преподаватель не получает доступ к чужому курсу, лабе, студенту или группе",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Для любого преподавателя T и ресурса R чужого курса сервис возвращает 403/404 и не отдаёт данные R.",
"oracle": "HTTP-статус отказа и отсутствие данных в ответе",
"evidence": [
"ru.ysu.lms.academic.course.CourseAccessServiceTest#teacher cannot access a foreign course()",
"ru.ysu.lms.academic.course.CourseRosterServiceTest#foreign teacher is forbidden()",
"ru.ysu.lms.progress.TeacherWriteAccessIntegrationTest#teacher is forbidden to grade foreign lab()",
"ru.ysu.lms.progress.TeacherWriteAccessIntegrationTest#teacher is forbidden to grade student from another course()"
],
"limitations": "Проверены основные API-точки; UI-навигация не является границей безопасности."
},
{
"id": "LDD-ACCESS-002",
"title": "Студент видит только свои курсы, группу и опубликованные данные",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Студент чужой группы не получает доступ к курсу, а в своём профиле видит только опубликованные оценки и комментарии.",
"oracle": "403 для чужого курса; отсутствие неопубликованных данных в ответе",
"evidence": [
"ru.ysu.lms.academic.course.CourseAccessServiceTest#student cannot access course of a foreign group()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#student sees only published grade and comments of own profile()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher cannot read or download student from group outside course but admin can()"
],
"limitations": "Студенческий BFF-профиль не является отдельной страницей; проверен профильный API."
},
{
"id": "LDD-ACCESS-003",
"title": "Анонимный доступ ограничен глобальными материалами ALL",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Аноним не читает курсовые и приватные материалы/активы; публичное дерево содержит только глобальные главы audience=ALL.",
"oracle": "401/404 для приватного; 200 только для ALL; состав публичного дерева",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous cannot read course content()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#anonymous tree contains only global chapters with ALL audience()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#course asset requires authentication()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
],
"limitations": "Legacy-активы без article_assets закрыты отдельными claims LDD-ASSET-002/003."
},
{
"id": "LDD-ACCESS-004",
"title": "Администратор сохраняет полный доступ",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Администратор не ограничен курсовой областью и может читать/писать любой курс и профиль.",
"oracle": "Успешный доступ к чужому курсу/лабе/студенту для ADMIN",
"evidence": [
"ru.ysu.lms.academic.course.CourseAccessServiceTest#admin bypasses checks and has no course restriction()",
"ru.ysu.lms.academic.course.CourseAccessServiceTest#admin my courses contains every course()",
"ru.ysu.lms.progress.TeacherWriteAccessIntegrationTest#admin keeps access without course scope checks()"
],
"limitations": "Проверены сервисные проверки, не все административные UI-маршруты."
},
{
"id": "LDD-ACCESS-005",
"title": "Внутренние API identity защищены сервисным токеном и не раскрывают учётные данные",
"criticality": "P0",
"status": "active",
"requirement": "docs/13-stage3-report.md, §3",
"claim": "Без корректного X-Internal-Token внутренние API возвращают 403; ответы не содержат паролей, tempPassword и лишних данных.",
"oracle": "403 на неверный/пустой токен; отсутствие чувствительных полей в JSON",
"evidence": [
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#invalid token is forbidden()",
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#groups response exposes no credentials or students()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#invalid or missing internal token is forbidden and writes nothing()"
],
"limitations": "Проверены контекст студента, состав группы, список групп и аудит."
},
{
"id": "LDD-ACCESS-006",
"title": "Преподаватель управляет лабораторными только своего курса",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "TEACHER может создавать и изменять лабы/уровни/варианты своего курса, но не чужого; STUDENT не может писать.",
"oracle": "200 для своего курса; 403 для чужого; 403 для студента",
"evidence": [
"ru.ysu.lms.academic.lab.LabTeacherAccessIntegrationTest#teacher cannot update lab of foreign course()",
"ru.ysu.lms.academic.lab.LabTeacherAccessIntegrationTest#teacher can create lab in own course()",
"ru.ysu.lms.academic.lab.LabTeacherAccessIntegrationTest#student cannot update or create labs()"
],
"limitations": "Импорт ZIP проверен отдельными тестами парсера/копирования."
},
{
"id": "LDD-PROM-001",
"title": "Dry-run повышения не меняет данные и дедуплицируется по году",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Построение dry-run создаёт только план; данные групп/студентов не меняются; повторный запуск за год не создаёт дубль.",
"oracle": "Снимок данных до/после и количество запусков",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#dry run only plans and does not change data()",
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#scheduled dry run guard respects date and deduplicates by year()"
],
"limitations": "Проверено на синтетических данных Testcontainers."
},
{
"id": "LDD-PROM-002",
"title": "Применение повышения переводит группы и студентов по правилам",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Apply повышает курс групп, выпускает на пределе и переводит академических студентов в целевую группу.",
"oracle": "Группы, статусы, зачисления и membership_history после apply",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#apply promotes groups graduates and moves academic students()"
],
"limitations": "Проверен один сценарий; граничные сочетания требуют property-based тестов."
},
{
"id": "LDD-PROM-003",
"title": "Повторное применение повышения идемпотентно",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Повторный apply того же run не создаёт повторных изменений и истории.",
"oracle": "Состояние и количество history-записей после первого и второго apply",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#repeated apply is idempotent()"
],
"limitations": "Конкурентный apply проверяется advisory lock, но отдельного нагрузочного теста нет."
},
{
"id": "LDD-PROM-004",
"title": "Откат повышения восстанавливает состояние и обнаруживает конфликт",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Rollback возвращает before-state; при ручной правке после apply сообщает конфликт и не затирает данные.",
"oracle": "Снимок before/after и 409 при конфликте",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#rollback restores data and removes created target group()",
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#rollback reports manual conflict()"
],
"limitations": "Проверены группы и студенты; rollback файлов/аудита не применим."
},
{
"id": "LDD-LIFE-001",
"title": "Жизненный цикл студента пишет историю и переводит зачисление",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8",
"claim": "Академ/возврат/отчисление/архив/восстановление меняют статусы, зачисление и membership_history по правилам.",
"oracle": "Статус student/account, группа зачисления, membership_history",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#academic leave creates target cohort group and records history()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#return from academic leave moves to requested active group()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#expel blocks login and keeps current enrollment for history()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#archive blocks login and restore returns student to active group()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#restore returns an expelled student to active group()"
],
"limitations": "Проверены основные переходы; массовые операции не автоматизированы."
},
{
"id": "LDD-LIFE-002",
"title": "Вход закрыт для отчисленных, архивных и закрытых групп",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §1.3",
"claim": "EXPELLED/GRADUATED/ARCHIVED студенты и архивные аккаунты не проходят login; активный студент проходит.",
"oracle": "Unauthorized/403 при login и active=false в /auth/me",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#expelled student cannot login()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#student in archived group cannot login()",
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#student in active group can login()",
"ru.ysu.lms.identity.AuthServiceMeActiveTest#me is inactive for archived account()"
],
"limitations": "JWT с прежним сроком жизни остаётся остаточным риском."
},
{
"id": "LDD-PROFILE-001",
"title": "Профиль студента курса различает staff/student и публикацию",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.1",
"claim": "Преподаватель своего курса видит все лабы и неопубликованные данные; студент — только опубликованное; чужой преподаватель получает 403.",
"oracle": "Состав DTO профиля и HTTP-статус",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher sees unpublished grade, authors and all submission attempts()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#student sees only published grade and comments of own profile()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher of another course gets forbidden()"
],
"limitations": "Проверен API; UI-шаблон проверяется отдельно."
},
{
"id": "LDD-DOWNLOAD-001",
"title": "Скачивание сдачи ограничено курсом и студентом",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.1",
"claim": "Преподаватель скачивает только сдачи своего курса/своих студентов; сдача лабы другого курса отклоняется.",
"oracle": "ZIP для разрешённой сдачи; 403 для чужой лабы/студента",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#download rejects submission from another course lab and allows current course lab()",
"ru.ysu.lms.progress.TeacherLabPageAccessIntegrationTest#teacher downloads submission of own lab()",
"ru.ysu.lms.progress.TeacherLabPageAccessIntegrationTest#teacher is forbidden for foreign lab()"
],
"limitations": "Аудит скачивания проверяется отдельным claim."
},
{
"id": "LDD-CONTENT-001",
"title": "Курсовые материалы не утекают в публичное дерево",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Курсовая глава недоступна через публичный article endpoint; студент видит только published; преподаватель видит hidden.",
"oracle": "404/отсутствие курсовой главы в public tree; состав courseTree",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#course chapter is not exposed through public article endpoint()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#student of course sees only published materials()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#teacher of course sees published and hidden materials()"
],
"limitations": "Импорт и авторство проверяются отдельными тестами."
},
{
"id": "LDD-ASSET-001",
"title": "Видимость активов наследуется от статьи",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Скрытый курсовой актив не отдаётся студенту, но доступен преподавателю; чужой курс получает 403; публичный глобальный ALL-актив доступен без входа.",
"oracle": "HTTP-статус и содержимое файла",
"evidence": [
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#hidden course asset is not served to student but is served to teacher()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#foreign teacher and student are forbidden for course asset()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()"
],
"limitations": "Legacy-файлы без article_assets теперь закрыты отдельным claim LDD-ASSET-002."
},
{
"id": "LDD-MIG-001",
"title": "Миграция V13 идемпотентна",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.2",
"claim": "Повторное применение V13 не меняет схему и не падает.",
"oracle": "Повторный Flyway migrate и проверка колонок/индекса",
"evidence": [
"ru.ysu.lms.progress.migration.ProfileAuthorsMigrationTest#V13 profile authors migration is idempotent()"
],
"limitations": "Полный upgrade с master-схемы проверяется runtime-скриптом."
},
{
"id": "LDD-AUDIT-001",
"title": "Аудит защищён и доступен только администратору",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Внутренняя запись требует X-Internal-Token; чтение /api/v1/audit требует ADMIN.",
"oracle": "403 без токена/для не-админа; 200 для админа",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#admin list requires admin role()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#invalid or missing internal token is forbidden and writes nothing()"
],
"limitations": "Проверены API; UI-страница проверяется отдельным шаблонным тестом."
},
{
"id": "LDD-AUDIT-002",
"title": "Аудит переживает удаление сущности и сохраняет поля",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.2",
"claim": "Событие аудита сохраняется после удаления студента/преподавателя и корректно отдаётся в списке.",
"oracle": "Наличие строки после delete; поля id/action/entity/occurredAt",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#identity hooks record teacher create and student delete that survives deletion()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#internal write is listed newest first with all fields()"
],
"limitations": "Проверены identity-хуки; cross-service хуки проверяются отдельными тестами."
},
{
"id": "LDD-ACCESS-007",
"title": "Преподаватель управляет группами своего курса",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "TEACHER привязывает/отвязывает активные группы только своего курса; чужой преподаватель и студент получают 403.",
"oracle": "200 и history-запись для своего курса; 403 для чужого",
"evidence": [
"ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher sees available groups, attaches and detaches on own course()",
"ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#foreign teacher cannot list attach or detach groups()"
],
"limitations": "Проверено API; UI-форма — шаблонным тестом."
},
{
"id": "LDD-ACCESS-008",
"title": "Преподаватель авторствует материалы только своего курса",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "TEACHER создаёт/меняет/публикует главы, разделы и статьи своего курса; глобальные материалы — только ADMIN; чужой курс — 403.",
"oracle": "200 для своего курса; 403 для чужого; global routes только ADMIN",
"evidence": [
"ru.ysu.lms.academic.content.CourseContentAuthoringIntegrationTest#teacher creates chapter section and article in own course()",
"ru.ysu.lms.academic.content.CourseContentAuthoringIntegrationTest#foreign teacher cannot author course content()",
"ru.ysu.lms.academic.content.CourseContentAuthoringIntegrationTest#global chapter authoring stays admin only()"
],
"limitations": "Импорт ZIP остаётся админским; это осознанное ограничение."
},
{
"id": "LDD-ACCESS-009",
"title": "Ведомость преподавателя не показывает админские операции",
"criticality": "P1",
"status": "active",
"requirement": "docs/13-stage3-report.md, §4",
"claim": "В teacher-view скрыты пароли, аттестации и жизненный цикл; админский вид не меняется; админские URL закрыты интерсептором.",
"oracle": "Состав HTML и redirect для teacher",
"evidence": [
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#teacher gradebook has no admin-only controls()",
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#admin view is used when teacherView attribute is missing()",
"ru.ysu.lms.web.session.AdminInterceptorTest#teacher is redirected from admin-only group operations()"
],
"limitations": "Это UI-защита; серверные проверки — в LDD-ACCESS-001."
},
{
"id": "LDD-LIFE-003",
"title": "Удаление требует усиленного подтверждения",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §11",
"claim": "DELETE студента без совпадения confirm с login/ФИО отклоняется; корректный confirm удаляет.",
"oracle": "400 при неверном confirm; успешное удаление при верном",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#delete requires confirm equal to login or full name()"
],
"limitations": "Проверен сервис; UI-диалог — шаблонным тестом."
},
{
"id": "LDD-PROM-005",
"title": "Отмена устаревшего dry-run разрешает перестроить план",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Cancel переводит PLANNED dry-run в FAILED/CANCELLED; после этого можно построить новый план за год.",
"oracle": "Статус run и создание нового dry-run",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionServiceIntegrationTest#cancel marks planned dry run and allows rebuild()"
],
"limitations": "Конкурентный cancel/apply защищён advisory lock, отдельного теста нет."
},
{
"id": "LDD-IMPORT-001",
"title": "Импорт групп валидирует файл и не применяется частично",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Ошибочные строки блокируют импорт целиком; корректный шаблон и дубли № распознаются.",
"oracle": "applied=false и отсутствие изменений при ошибках; отчёт парсера",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#does not apply import when there are row errors()",
"ru.ysu.lms.identity.roster.StudentImportParserTest#parses new template and splits full name()",
"ru.ysu.lms.identity.roster.StudentImportParserTest#reports duplicate student number()"
],
"limitations": "Старый формат импорта не поддерживается; номер группы числовой, отдельного требования к ведущим нулям нет."
},
{
"id": "LDD-LAB-002",
"title": "Копирование лабораторной не переносит прогресс и требует другой курс",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §10",
"claim": "Копия создаёт новые уровни/варианты в целевом курсе, не копирует оценки/сдачи; тот же/архивный курс отклоняется.",
"oracle": "Состав копии и отсутствие progress-строк; 400 для same/archived",
"evidence": [
"ru.ysu.lms.academic.lab.LabCopyIntegrationTest#copies lab with levels and variants into another course()",
"ru.ysu.lms.academic.lab.LabCopyIntegrationTest#copy rejects the source course as target()",
"ru.ysu.lms.academic.lab.LabCopyIntegrationTest#copy rejects an archived target course()"
],
"limitations": "Проверено на Testcontainers; UI-копирование — шаблонным тестом."
},
{
"id": "LDD-CONTRACT-001",
"title": "DTO-контракты web и сервисов совпадают",
"criticality": "P1",
"status": "active",
"requirement": "docs/09-teacher-student-profile.md",
"claim": "Ключевые JSON-контракты профиля, состава группы, аудита и повышения десериализуются web-слоем без потери полей.",
"oracle": "JsonIgnoreProperties + значения полей после десериализации",
"evidence": [
"ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()",
"ru.ysu.lms.web.api.CourseGroupRosterDtoTest#deserializes course group roster contract()",
"ru.ysu.lms.web.api.AuditDtoTest#deserializes audit event list with nulls()",
"ru.ysu.lms.web.api.PromotionDtoTest#deserializes promotion detail with item states and result()",
"ru.ysu.lms.identity.roster.TeacherIntegrationTest#student creation accepts omitted optional json fields()"
],
"limitations": "Это smoke-контракты, не consumer-driven contracts."
},
{
"id": "LDD-UI-001",
"title": "Ключевые страницы содержат нужные действия и не раскрывают лишнего",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §3.3",
"claim": "Шаблоны ведомости, материалов и аудита рендерят ожидаемые формы/ссылки и экранируют данные.",
"oracle": "Содержимое HTML и отсутствие запрещённых элементов",
"evidence": [
"ru.ysu.lms.web.teacher.TeacherCourseContentTemplateTest#content page renders create forms and full tree()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()",
"ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()"
],
"limitations": "Thymeleaf-тесты не заменяют браузерный E2E."
},
{
"id": "LDD-AUDIT-003",
"title": "Критичные действия progress пишут аудит",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5.2",
"claim": "Оценка и публикация оценки создают audit-событие с actor из JWT и корректными идентификаторами.",
"oracle": "Вызов AuditClient с ожидаемыми action/entity/student/course",
"evidence": [
"ru.ysu.lms.progress.ProgressControllerAuditTest#grade write records audit entry for actor from jwt()",
"ru.ysu.lms.progress.ProgressControllerAuditTest#publication records student count()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#action filter and limit are applied()"
],
"limitations": "Проверены representative-хуки; остальные actions проверяются кодом и smoke."
},
{
"id": "LDD-MIG-002",
"title": "Upgrade с master-схемы применяет миграции ветки",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.2",
"claim": "Схема master + синтетические данные → миграции identity V4–V14, academic V6–V12, progress V13; V10 audit, V11 refresh, V12 absolute-expiry, V13 keyset-индексы, V14 NOT NULL/unique когорты; V13/V14 идемпотентны.",
"oracle": "postflight checks = required; повторные V13/V14 не падают",
"runtime": [
"migration.master_upgrade"
],
"limitations": "V14 намеренно блокирует legacy-группы с неразобранной когортой."
},
{
"id": "LDD-RUNTIME-001",
"title": "Полный стек поднимается и проходит E2E smoke",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §3.3",
"claim": "На чистой БД стартуют все сервисы, health=UP, admin setup/login работает, создаются группа/курс/преподаватель/лаба, чужой преподаватель получает 403, аудит доступен.",
"oracle": "HTTP health/status/JSON на реальном стеке",
"runtime": [
"runtime.stack_health",
"runtime.admin_setup_login",
"runtime.course_flow",
"runtime.foreign_teacher_denied",
"runtime.audit_visible",
"runtime.web_login_page"
],
"limitations": "Smoke не заменяет полный UI E2E и нагрузочные тесты."
},
{
"id": "LDD-MUTATION-001",
"title": "Мутационная прочность матрицы доступа",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "PIT по CourseAccessService убивает не менее 70% покрытых мутаций.",
"oracle": "PIT mutations.xml: killed / covered",
"runtime": [
"mutation.course_access"
],
"limitations": "PIT 1.20 + JUnit4 harness; Kotlin/Spring-специфичные мутации и эквивалентные мутанты не входят в цель."
},
{
"id": "LDD-MUTATION-002",
"title": "Мутационная прочность планировщика повышения",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "PIT по PromotionPlanner убивает не менее 60% покрытых мутаций.",
"oracle": "PIT mutations.xml: killed / covered",
"runtime": [
"mutation.promotion_planner"
],
"limitations": "Проверяется планировщик, не транзакционный apply/rollback (они покрыты интеграционными тестами)."
},
{
"id": "LDD-BROWSER-001",
"title": "Браузерный E2E в Chromium, Firefox и WebKit",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "6 сценариев (админ, преподаватель, студент/аноним, сдача+mobile, cursor-аудит, инвентаризация активов) проходят в Chromium, Firefox и WebKit: 18/18 без unexpected.",
"oracle": "Playwright assertions на DOM/URL",
"runtime": [
"browser.e2e"
],
"limitations": "Нет визуальной регрессии и полного набора мобильных страниц; WebKit на Fedora запускается через Docker-образ Playwright."
},
{
"id": "LDD-RUNTIME-002",
"title": "Нагрузочный smoke чтения курса, лаб, профиля и аудита",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "100 параллельных чтений (10 workers) через gateway дают 0 ошибок и p95 < 2 c.",
"oracle": "HTTP status + latency percentiles",
"runtime": [
"runtime.load_gradebook_profile"
],
"limitations": "Smoke, не benchmark; одна машина и синтетические данные, без SLA production."
},
{
"id": "LDD-PROM-006",
"title": "Конкурентные apply/rollback одного запуска повышения",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Два одновременных apply не дублируют историю; два одновременных rollback дают ровно один успех и один конфликт, состояние восстанавливается один раз.",
"oracle": "Результаты двух потоков, количество APPLY/ROLLBACK и history",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionConcurrencyIntegrationTest#concurrent apply of one run is idempotent and writes history once()",
"ru.ysu.lms.identity.promotion.PromotionConcurrencyIntegrationTest#concurrent rollback allows exactly one rollback and restores once()"
],
"limitations": "Два потока в одном JVM; распределённая нагрузка и несколько инстансов проверяются advisory lock-ом, но не chaos-тестом."
},
{
"id": "LDD-PROM-007",
"title": "Транзакционный apply/rollback на сгенерированных когортах",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §7",
"claim": "Для 10 сгенерированных когорт apply приводит группу к плану, а rollback возвращает точный before-state и активный статус студента.",
"oracle": "Сравнение состояния группы/студента до, после apply и после rollback",
"evidence": [
"ru.ysu.lms.identity.promotion.PromotionApplyRollbackPropertyTest#generated cohorts apply to plan and rollback to before-state()"
],
"limitations": "10 детерминированных сценариев в одной транзакции; не заменяет property-генератор по всем полям."
},
{
"id": "LDD-RUNTIME-003",
"title": "Масштабный load: 100 групп и 1000 студентов",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "После seed 100 групп/1000 студентов 125 параллельных чтений курса, лаб, профиля, групп и аудита дают 0 ошибок и p95 < 2 c.",
"oracle": "runtime.scale_seed + runtime.load_gradebook_profile",
"runtime": [
"runtime.scale_seed",
"runtime.load_gradebook_profile"
],
"limitations": "Синтетические данные в одноразовой БД; это smoke, не SLA production."
},
{
"id": "LDD-ASSET-002",
"title": "Файлы без привязки к статье не публичны",
"criticality": "P0",
"status": "active",
"requirement": "docs/15-stage5-course-materials.md",
"claim": "Аноним, студент и преподаватель получают 404 для legacy-актива без article_assets; админ получает файл; endpoint legacy-assets перечисляет такие файлы до привязки.",
"oracle": "HTTP 404/200 и список unlinkedAssetNames",
"evidence": [
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#legacy asset without article association is not public()",
"ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#unlinkedAssetNames lists stored file until it is linked()"
],
"limitations": "Старые внешние ссылки на бесхозные файлы перестают работать у обычных пользователей — осознанный security-компромисс."
},
{
"id": "LDD-AUTH-001",
"title": "Короткий access-токен и ротация refresh",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Login выдаёт access ≤15 минут и refresh; refresh ротируется, старый refresh отклоняется, повторное использование отзывает активные сессии.",
"oracle": "TTL JWT, ответы refresh, отзыв сессий",
"evidence": [
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#login returns short lived access token and refresh token()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#refresh rotates the token and old token is rejected()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#reuse of a rotated token revokes every active session()"
],
"limitations": "Refresh-токен хранится как SHA-256 отпечаток; очистка истёкших записей выполняется позже."
},
{
"id": "LDD-AUTH-002",
"title": "Отзыв refresh при logout/смене пароля и закрытом статусе",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Logout и смена пароля отзывают refresh; отчисленный/архивный студент, архивный аккаунт и закрытая группа не могут обновить сессию.",
"oracle": "401 на refresh после отзыва/закрытия",
"evidence": [
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#logout revokes the refresh token idempotently()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#password change revokes refresh tokens()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#expelled student cannot refresh()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#archived account cannot refresh()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#student in archived group cannot refresh()"
],
"limitations": "Access-токен живёт до 15 минут после отзыва refresh — это принятый компромисс."
},
{
"id": "LDD-AUTH-003",
"title": "Web-сессия 7 дней sliding и автообновление",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "Web хранит refresh и expiry, обновляет access за 2 минуты до истечения, продлевает cookie на каждом ответе; logout отзывает refresh; identity outage не разлогинивает всех.",
"oracle": "SessionTokenService/AuthPageController/фильтр cookie",
"evidence": [
"ru.ysu.lms.web.session.SessionTokenServiceTest#token expiring soon is refreshed and session is updated()",
"ru.ysu.lms.web.session.SessionTokenServiceTest#identity outage fails open()",
"ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#refreshes cookie max age on every existing session()",
"ru.ysu.lms.web.auth.AuthPageControllerTest#logout revokes refresh token and invalidates session()"
],
"limitations": "Cookie и серверный timeout 7 дней; поверх них действует абсолютный потолок 30 дней (LDD-AUTH-006)."
},
{
"id": "LDD-AUDIT-004",
"title": "Пагинация журнала аудита",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "Audit API поддерживает offset-страницы `page`/`limit` для совместимости; UI по умолчанию продолжает журнал keyset-курсором.",
"oracle": "Страницы AuditIntegrationTest и ссылки шаблона",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#pagination returns disjoint pages newest first()",
"ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()"
],
"limitations": "Offset-режим остаётся только для старых ссылок; основной путь — курсор (LDD-AUDIT-005)."
},
{
"id": "LDD-PERF-001",
"title": "Bulk-группы курса без N+1",
"criticality": "P1",
"status": "active",
"requirement": "docs/evidence/README.md",
"claim": "Страница преподавателя получает названия всех привязанных групп одним запросом; чужой преподаватель получает 403.",
"oracle": "Один вызов courseGroups и отсутствие courseGroupStudents на страницу",
"evidence": [
"ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher gets attached groups with names in one call()",
"ru.ysu.lms.web.teacher.TeacherControllerCourseGroupsTest#loads group names with a single call instead of per-group requests()"
],
"limitations": "Детали лабораторных теперь пакетные (LDD-PERF-002); отдельные запросы на лабораторную в ведомости ещё остаются."
},
{
"id": "LDD-AUTH-004",
"title": "Gateway пропускает refresh/logout без access-токена",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "POST /api/v1/auth/refresh и /api/v1/auth/logout проходят через gateway без access-JWT, иначе автообновление и отзыв сессии невозможны.",
"oracle": "GatewayFilterChain вызван для public path; protected path без токена — 401",
"evidence": [
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#refresh and logout are public()",
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#protected route without bearer token is rejected()"
],
"limitations": "Проверена маршрутизация gateway; интеграционный E2E с истёкшим access-токеном добавлен через web-тесты."
},
{
"id": "LDD-AUTH-005",
"title": "Gateway пропускает refresh/logout без access-JWT",
"criticality": "P0",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Gateway не требует access-JWT для /api/v1/auth/refresh и /api/v1/auth/logout; защищённый маршрут без токена получает 401. Полный цикл refresh/logout через реальный gateway проверяется отдельным P1 runtime-claim.",
"oracle": "JwtAuthenticationFilterTest: public paths пропущены, protected path без токена — 401",
"runtime": [],
"limitations": "Unit-тест проверяет маршрутизацию gateway; полный обмен пары и отзыв refresh — LDD-AUTH-008.",
"evidence": [
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#refresh and logout are public()",
"ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#protected route without bearer token is rejected()"
]
},
{
"id": "LDD-AUTH-006",
"title": "Абсолютный потолок сессии 30 дней",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Ротация refresh не продлевает absolute_expires_at; после 30 дней refresh отклоняется, а web-сессия принудительно завершается и cookie не живёт дольше остатка.",
"oracle": "absolute_expires_at в цепочке ротаций, 401 на refresh, redirect /login?expired=absolute, Max-Age cookie",
"evidence": [
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#refresh past the absolute session expiry is rejected with unauthorized()",
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#rotation keeps the absolute session expiry unchanged()",
"ru.ysu.lms.web.session.AuthInterceptorTest#absolute expired session redirects to absolute login without identity call()",
"ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#cookie max age is capped by remaining absolute lifetime()",
"ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#absolute expired session receives zero max age cookie()",
"ru.ysu.lms.web.auth.AuthPageControllerTest#login stores authenticated at timestamp()"
],
"limitations": "Срок настраивается LMS_SESSION_ABSOLUTE_TIMEOUT; абсолютный отсчёт начинается с логина, а не с создания web-сессии."
},
{
"id": "LDD-AUTH-007",
"title": "Очистка истёкших refresh-токенов",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §4",
"claim": "Ночная очистка удаляет токены, у которых sliding- или absolute-срок истёк раньше retention (30 дней), и сохраняет свежие/недавно отозванные токены для detection reuse.",
"oracle": "cleanupExpired(now) и количество удалённых записей",
"evidence": [
"ru.ysu.lms.identity.RefreshTokenIntegrationTest#cleanup deletes only tokens expired beyond retention()"
],
"limitations": "Расписание задаётся LMS_REFRESH_TOKEN_CLEANUP_CRON; в тесте вызывается метод напрямую, без ожидания cron."
},
{
"id": "LDD-AUDIT-005",
"title": "Keyset-пагинация аудита",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.7",
"claim": "Audit API принимает непрозрачный cursor (occurredAt|id), отдаёт строго следующие записи без пересечений и сохраняет фильтр по action; битый курсор — 400; UI ведёт «Показать ещё» по курсору.",
"oracle": "Страницы AuditIntegrationTest и cursor-ссылка шаблона",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor pagination walks all events newest first()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor pagination respects the action filter()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#malformed audit cursor is rejected with bad request()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor from the newest event returns an empty page()",
"ru.ysu.lms.web.admin.AdminAuditTemplateTest#cursor mode links next page with cursor and resets to start()"
],
"limitations": "Offset page остаётся для обратной совместимости; keyset-курсор не подписан, но не содержит секретов."
},
{
"id": "LDD-PERF-002",
"title": "Пакетные детали лабораторных без N+1",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.7",
"claim": "Academic отдаёт все лабораторные курса с уровнями и вариантами одним запросом /api/v1/labs/details; progress делает ровно один HTTP-вызов вместо запроса на каждую лабораторную.",
"oracle": "LabDetailsIntegrationTest и счётчик запросов AcademicClientBatchLabsTest",
"evidence": [
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#teacher gets all course labs with levels and variants in one call()",
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student of the course receives lab details()",
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#foreign teacher is forbidden for lab details()",
"ru.ysu.lms.progress.AcademicClientBatchLabsTest#getCourseLabs loads all labs with levels and variants in one request()"
],
"limitations": "Ведомость всё ещё запрашивает сдачи по выбранной лабораторной; это не N+1 по студентам."
},
{
"id": "LDD-ASSET-003",
"title": "Админская инвентаризация legacy-активов",
"criticality": "P1",
"status": "active",
"requirement": "docs/15-stage5-course-materials.md",
"claim": "Админ открывает /admin/legacy-assets, видит список файлов без статьи, ссылку на файл и пустое состояние; страница не показывает ошибку при пустом списке.",
"oracle": "LegacyAssetsTemplateTest и HTTP-страница",
"evidence": [
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders orphan assets with links and admin-only explanation()",
"ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()"
],
"limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует."
},
{
"id": "LDD-AUTH-008",
"title": "Refresh/logout через реальный gateway",
"criticality": "P1",
"status": "active",
"requirement": "docs/16-stage6-report.md, §2.3",
"claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.",
"oracle": "runtime.auth_refresh",
"runtime": [
"runtime.auth_refresh"
],
"limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke."
},
{
"id": "LDD-ACCESS-010",
"title": "Черновики лабораторных скрыты от студентов",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §5",
"claim": "Студент не видит черновик лабораторной ни в списке, ни в пакетных деталях, ни по прямому UUID; преподаватель и админ видят черновик.",
"oracle": "HTTP 200/404 и фильтрация списков",
"evidence": [
"ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student sees published labs but not drafts in list details or by id()",
"ru.ysu.lms.academic.course.CourseAccessServiceTest#student cannot access a draft lab of own course()"
],
"limitations": "Проверены academic API и список; отдельный тест прогресса на сдачу черновика не добавлялся, так как academic отдаёт 404."
},
{
"id": "LDD-LIFE-004",
"title": "Академический отпуск: вход только для чтения",
"criticality": "P0",
"status": "active",
"requirement": "docs/07-groups-courses.md, §0.3",
"claim": "Студент в ACADEMIC_LEAVE может войти и читать, но не может сдавать лабораторные и начинать/завершать защиту.",
"oracle": "readOnly в login/me, ForbiddenException на submit/start/finish",
"evidence": [
"ru.ysu.lms.identity.roster.StudentLifecycleIntegrationTest#academic leave login is read only()",
"ru.ysu.lms.progress.SubmissionServiceTest#academic leave student cannot submit()",
"ru.ysu.lms.progress.DefenseServiceAcademicLeaveTest#academic leave student cannot start or finish defense()"
],
"limitations": "Запрет проверяется на progress-service; UI скрывает кнопки, но не является единственной защитой."
},
{
"id": "LDD-LIFE-005",
"title": "Действие «Убрать из группы» удалено",
"criticality": "P1",
"status": "active",
"requirement": "docs/14-stage4-report.md, §3",
"claim": "В админском UI и API больше нет отдельного действия unenroll; доступны только перевод, академ, отчисление, архив и восстановление.",
"oracle": "Отсутствие маршрута/кнопки/метода и отрицательные проверки шаблона",
"evidence": [
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders group lifecycle buttons by student status()",
"ru.ysu.lms.web.admin.TeacherGradebookTemplateTest#teacher gradebook has no admin-only controls()"
],
"limitations": "Удалённый API-маршрут не проверяется отдельным HTTP-тестом; отсутствие метода компилятор проверяет по коду."
},
{
"id": "LDD-IMPORT-002",
"title": "Импорт не воскрешает неактивных студентов без флага",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Повторный импорт сохраняет EXPELLED/GRADUATED/ARCHIVED/ACADEMIC_LEAVE без явного флага restoreInactiveStatuses и сообщает строку в skipped; с флагом активирует и зачисляет.",
"oracle": "Статус/зачисление/ImportResult.skipped",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import does not resurrect expelled student without explicit flag()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import restores expelled student with explicit flag and records import history()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import preserves academic leave without explicit flag()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import still updates an active student()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import form requires explicit restoration flag()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#group import result shows skipped inactive rows()"
],
"limitations": "Флаг восстанавливает статус студента; архивный аккаунт может потребовать отдельного lifecycle-восстановления."
},
{
"id": "LDD-CONTENT-003",
"title": "Глобальные материалы STUDENTS/TEACHERS",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §6",
"claim": "Аноним видит только ALL; студент — ALL+STUDENTS; преподаватель — ALL+TEACHERS; прямой доступ к чужой аудитории даёт 404; админ может создать/сменить аудиторию.",
"oracle": "Фильтрация дерева/статей по JWT-роли и authoring audience",
"evidence": [
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global tree is filtered by role audience()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#global article access follows audience and role()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#authoring creates requested audience and rejects invalid()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#changing audience immediately changes access()",
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()"
],
"limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории."
},
{
"id": "LDD-IMPORT-003",
"title": "Ошибки импорта報告 по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Занятый логин и неактивная целевая группа возвращают applied=false с номером строки и не создают частичных студентов/зачислений.",
"oracle": "ImportResult.errors[].row и отсутствие записей после ошибки",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports occupied login with row and writes nothing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()"
],
"limitations": "Неожиданные ошибки БД вне известных preflight-проверок всё ещё откатывают транзакцию без построчного отчёта."
},
{
"id": "LDD-UI-002",
"title": "Браузерные URL курсов используют courseNo",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §0.2; docs/09 §8",
"claim": "Страницы преподавателя, админа, материалы и профиль строят URL по courseNo, а REST API вызывают с UUID курса.",
"oracle": "Ссылки/redirects шаблонов и контроллеров содержат courseNo, API получает UUID",
"evidence": [
"ru.ysu.lms.web.teacher.TeacherCourseTemplateTest#course links use human course number instead of uuid()",
"ru.ysu.lms.web.teacher.TeacherCourseContentControllerTest#content page resolves course by human number and keeps uuid for api()",
"ru.ysu.lms.web.admin.AdminCourseControllerTest#detail page resolves course by number and calls api with uuid()",
"ru.ysu.lms.web.admin.AdminCourseTemplateTest#course page forms and actions use human number()",
"ru.ysu.lms.web.admin.AdminGroupControllerTest#teacher gradebook resolves course number and scopes api calls by uuid()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()"
],
"limitations": "Внутренние REST-контракты остаются UUID; form payload создания/копирования лабораторной тоже использует UUID."
},
{
"id": "LDD-PROFILE-002",
"title": "Профиль соответствует контракту docs/09 и ограничивает попытки",
"criticality": "P1",
"status": "active",
"requirement": "docs/09-teacher-student-profile.md, §3, §7",
"claim": "Профиль отдаёт variant.id, level.id/code, grade.updatedBy и author.name; число попыток отражается полностью, а список ограничивается параметром submissionLimit; профиль не делает запрос на каждую лабу.",
"oracle": "JSON-поля контракта; attemptsCount/submissionsTotal; число SQL-запросов не растёт с числом лабораторных",
"evidence": [
"ru.ysu.lms.progress.StudentProfileIntegrationTest#teacher sees unpublished grade, authors and all submission attempts()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#profile limits submission attempts but reports total()",
"ru.ysu.lms.progress.StudentProfileIntegrationTest#profile uses one query per data kind instead of per lab()",
"ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()",
"ru.ysu.lms.web.profile.StudentProfileTemplateTest#renders student profile with labs, comments and downloads()",
"ru.ysu.lms.identity.internal.InternalControllerIntegrationTest#lookup includes student full name and leaves staff name empty()"
],
"limitations": "По умолчанию возвращаются последние 50 попыток; UI показывает, сколько скрыто."
},
{
"id": "LDD-GROUP-001",
"title": "Группа возвращается из архива",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8.3",
"claim": "Архивную группу можно вернуть в ACTIVE; повторный возврат активной группы отклоняется, состав и история не меняются.",
"oracle": "status меняется ARCHIVED→ACTIVE; второй restore — 400; число студентов и история сохраняются",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#restores archived group and rejects repeated restore()",
"ru.ysu.lms.identity.audit.AuditIntegrationTest#roster operations record group and student audit events()"
],
"limitations": "Возврат выполняется со страницы группы; отдельного массового восстановления нет."
},
{
"id": "LDD-GROUP-002",
"title": "Финальные ограничения когорты",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §8.2; docs/08-groups-courses-plan.md, этап 1",
"claim": "Все поля когорты NOT NULL, уникальность полная (без partial WHERE), legacy-строки блокируют миграцию с явной ошибкой.",
"oracle": "information_schema.is_nullable=NO для 5 полей; groups_cohort_unique без WHERE; master→branch migration PASS",
"evidence": [],
"runtime": [
"migration.master_upgrade"
],
"limitations": "Перед деплоем на реальных данных нужен ручной разбор legacy-групп; запрос для проверки есть в V14."
},
{
"id": "LDD-IMPORT-004",
"title": "XLSX-импорт отчитывается по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/08-groups-courses-plan.md, этап 1; docs/18 §3",
"claim": "Для XLSX занятый логин и неактивная целевая группа возвращают applied=false, номер строки и не создают частичных записей.",
"oracle": "ImportResult.errors.row=2, applied=false; студент не создан",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#xlsx import reports occupied login before writing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#xlsx import reports inactive target group before writing()"
],
"limitations": "Гонка с параллельной вставкой логина вне preflight всё ещё может дать общий 409."
},
{
"id": "LDD-AUDIT-006",
"title": "Операции с группами и студентами пишут аудит",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9; docs/08-groups-courses-plan.md, этап 1",
"claim": "GROUP_CREATE, STUDENT_CREATE, GROUP_ENROLL, GROUP_IMPORT, GROUP_ARCHIVE и GROUP_RESTORE попадают в журнал с актором и деталями.",
"oracle": "audit_events содержит все шесть action; actorAccountId/actorLogin заполнены",
"evidence": [
"ru.ysu.lms.identity.audit.AuditIntegrationTest#roster operations record group and student audit events()"
],
"limitations": "Проверяются успешные операции; отказы валидации аудит не пишут."
},
{
"id": "LDD-PERF-003",
"title": "Пакетные сдачи и пагинация списков",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §10; docs/18 §3",
"claim": "Ведомость получает последние сдачи по всем выбранным лабам одним запросом; группы и плоский список материалов поддерживают limit/offset.",
"oracle": "одна выборка submissions на набор labId; страницы групп/материалов не пересекаются и не теряют записи",
"evidence": [
"ru.ysu.lms.progress.SubmissionBatchIntegrationTest#latest submissions are grouped by lab in one call()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#lists groups page by page and returns all without limit()",
"ru.ysu.lms.academic.content.CourseContentIntegrationTest#admin article list supports offset and limit()"
],
"limitations": "Дерево курса по-прежнему загружается целиком для навигации; пагинируются плоские списки."
},
{
"id": "LDD-GROUP-003",
"title": "История членства видна на странице группы",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §3; docs/18 §3",
"claim": "Админ видит последние события membership_history по группе: студент, действие, откуда/куда, причина и время МСК.",
"oracle": "GET /api/v1/groups/{id}/history отдаёт studentName/fromGroupName/toGroupName; шаблон показывает блок истории",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#imports students and records membership history()",
"ru.ysu.lms.web.admin.AdminStage4TemplateTest#renders membership history and restore action for archived group()"
],
"limitations": "Показаны последние 200 событий; фильтра/пагинации по истории нет."
}
]
}

6
e2e/fixtures/main.c Normal file
View File

@@ -0,0 +1,6 @@
#include <stdio.h>
int main(void) {
puts("LDD e2e");
return 0;
}

245
e2e/lms.spec.ts Normal file
View File

@@ -0,0 +1,245 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const apiBase = process.env.LDD_API_URL ?? "http://localhost:8080";
type Seeded = {
courseId: string;
courseNo: number;
courseTitle: string;
groupId: string;
labId: string;
groupName: string;
studentFullName: string;
studentLogin: string;
studentPassword: string;
teacherLogin: string;
teacherPassword: string;
adminLogin: string;
adminPassword: string;
};
let seeded: Seeded;
let api: APIRequestContext;
async function expectOk(response: Awaited<ReturnType<APIRequestContext["post"]>>): Promise<unknown> {
if (!response.ok()) {
throw new Error(`${response.url()} -> ${response.status()}: ${await response.text()}`);
}
return response.json();
}
async function setupAdmin(context: APIRequestContext): Promise<string> {
const health = await context.get("/api/v1/auth/health");
const healthBody = (await health.json()) as { adminExists: boolean };
if (healthBody.adminExists) {
const login = await context.post("/api/v1/auth/login", {
data: { login: "e2e-admin", password: "e2e-admin-123" },
});
return ((await expectOk(login)) as { token: string }).token;
}
const setup = await context.post("/api/v1/auth/setup", {
data: { login: "e2e-admin", password: "e2e-admin-123", password2: "e2e-admin-123" },
});
return ((await expectOk(setup)) as { token: string }).token;
}
async function createWithToken(
context: APIRequestContext,
token: string,
method: "post",
path: string,
data: unknown,
): Promise<Record<string, unknown>> {
const response = await context[method](path, {
data,
headers: { Authorization: `Bearer ${token}` },
});
return (await expectOk(response)) as Record<string, unknown>;
}
async function changePassword(context: APIRequestContext, login: string, oldPassword: string, newPassword: string) {
const loginResponse = await context.post("/api/v1/auth/login", {
data: { login, password: oldPassword },
});
const token = ((await expectOk(loginResponse)) as { token: string }).token;
await expectOk(
await context.post("/api/v1/auth/change-password", {
headers: { Authorization: `Bearer ${token}` },
data: { old: oldPassword, new: newPassword, new2: newPassword },
}),
);
}
async function login(page: Page, login: string, password: string, expectedPath: RegExp | string) {
await page.goto("/login");
await page.fill("#login", login);
await page.fill("#password", password);
await Promise.all([
page.waitForURL(expectedPath, { waitUntil: "domcontentloaded", timeout: 60_000 }),
page.click('button[type="submit"]'),
]);
}
test.beforeAll(async ({ playwright }) => {
api = await playwright.request.newContext({ baseURL: apiBase });
const suffix = Math.random().toString(36).slice(2, 8);
const adminToken = await setupAdmin(api);
const group = await createWithToken(api, adminToken, "post", "/api/v1/groups", {
stream: "E2E" + suffix,
course: 1,
number: 1,
degree: "БО",
enrollmentYear: 2026,
});
const course = await createWithToken(api, adminToken, "post", "/api/v1/courses", {
title: "E2E Course " + suffix,
});
const teacher = await createWithToken(api, adminToken, "post", "/api/v1/teachers", {
login: "e2e-teacher-" + suffix,
password: "e2e-teacher-123",
});
const teacherLogin = String(teacher.login);
await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/teachers`, {
accountId: teacher.id,
});
await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/groups`, {
groupId: group.id,
});
const lab = await createWithToken(api, adminToken, "post", "/api/v1/labs", {
number: 1,
title: "E2E Lab",
status: "published",
courseId: course.id,
levels: [{ code: "basic", title: "Базовый", position: 1 }],
});
const studentNo = "E2E-S-" + suffix;
const student = await createWithToken(api, adminToken, "post", `/api/v1/groups/${group.id}/students`, {
studentNo,
lastName: "Тестов",
firstName: "Студент",
basis: "бюджет",
});
const studentPassword = String(student.password);
await changePassword(api, teacherLogin, "e2e-teacher-123", "e2e-teacher-456");
await changePassword(api, studentNo, studentPassword, "e2e-student-456");
seeded = {
courseId: String(course.id),
courseNo: Number(course.courseNo),
courseTitle: String(course.title),
groupId: String(group.id),
groupName: String(group.name),
labId: String(lab.id),
studentFullName: String(student.fullName),
studentLogin: studentNo,
studentPassword: "e2e-student-456",
teacherLogin,
teacherPassword: "e2e-teacher-456",
adminLogin: "e2e-admin",
adminPassword: "e2e-admin-123",
};
});
test.afterAll(async () => {
await api.dispose();
});
test("admin opens courses and audit", async ({ page }) => {
await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin");
await page.goto("/admin/courses");
await expect(page.locator("body")).toContainText(seeded.courseTitle);
await page.goto("/admin/audit");
await expect(page.locator("body")).toContainText("Создан курс");
});
test("teacher opens course, gradebook, student profile and authors a chapter", async ({ page }) => {
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
await expect(page.locator("body")).toContainText(seeded.courseTitle);
await page.goto(`/teacher/courses/${seeded.courseNo}`);
await expect(page.locator("h1")).toContainText(seeded.courseTitle);
await expect(page.locator("body")).toContainText(seeded.groupName);
await expect(page.locator("body")).toContainText("E2E Lab");
await page.getByRole("link", { name: "Ведомость" }).click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${seeded.groupId}\\?courseNo=`));
await expect(page.locator("body")).toContainText(seeded.groupName);
await expect(page.locator("body")).toContainText(seeded.studentFullName);
await expect(page.getByRole("button", { name: "Отчислить" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Сбросить" })).toHaveCount(0);
await page.getByRole("link", { name: seeded.studentFullName }).first().click();
await expect(page).toHaveURL(new RegExp(`/teacher/courses/${seeded.courseNo}/students/`));
await expect(page.locator("body")).toContainText(seeded.studentFullName);
await page.goto(`/teacher/courses/${seeded.courseNo}/content`);
await page.fill("#chapterTitle", "E2E Chapter");
await page.getByRole("button", { name: "Создать главу" }).click();
await expect(page.locator("body")).toContainText("E2E Chapter");
});
test("anonymous and student cannot reach admin pages", async ({ page }) => {
await page.goto("/admin/audit");
await expect(page).toHaveURL(/\/login/);
await login(page, seeded.studentLogin, seeded.studentPassword, "**/student");
await page.goto("/admin/audit");
await expect(page).toHaveURL(/\/student/);
await page.goto(`/learn/course/${seeded.courseNo}`);
await expect(page.locator("body")).toContainText(seeded.courseTitle);
});
test("student submits a solution and mobile teacher page renders", async ({ page }) => {
await login(page, seeded.studentLogin, seeded.studentPassword, "**/student");
await page.goto(`/student/labs/${seeded.labId}`);
await page.setInputFiles('input[name="files"]', "fixtures/main.c");
await page.getByRole("button", { name: "Отправить решение" }).click();
await expect(page.locator("body")).toContainText("Решение отправлено");
await expect(page.locator("body")).toContainText("main.c");
await page.context().clearCookies();
await page.setViewportSize({ width: 390, height: 844 });
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
await page.goto(`/teacher/courses/${seeded.courseNo}`);
await expect(page.locator("h1")).toContainText(seeded.courseTitle);
await expect(page.locator("body")).toContainText(seeded.groupName);
});
test("admin paginates audit with cursor and returns to start", async ({ page }) => {
await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin");
await page.goto("/admin/audit?limit=1");
await expect(page.locator("body")).toContainText("Страница 1");
const nextLink = page.getByRole("link", { name: "Показать ещё" });
await expect(nextLink).toBeVisible();
await expect(nextLink).toHaveAttribute("href", /cursor=/);
await nextLink.click();
await expect(page).toHaveURL(/cursor=/);
await expect(page.locator("body")).toContainText("Продолжение");
await expect(page.getByRole("link", { name: "В начало" })).toBeVisible();
await page.getByRole("link", { name: "Показать ещё" }).click();
await expect(page).toHaveURL(/cursor=/);
await expect(page.locator("body")).toContainText("Продолжение");
await expect(page.getByRole("link", { name: "В начало" })).toBeVisible();
await page.getByRole("link", { name: "В начало" }).click();
await expect(page).not.toHaveURL(/cursor=/);
await expect(page.locator("body")).toContainText("Страница 1");
});
test("admin opens legacy assets inventory", async ({ page }) => {
await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin");
await page.goto("/admin/legacy-assets");
await expect(page.locator("h1")).toContainText("Активы");
await expect(page.locator("body")).toContainText("только администраторам");
const assetRow = page.locator("table tbody tr td.font-mono").first();
const empty = page.getByText("Бесхозных файлов нет.");
await expect(assetRow.or(empty)).toBeVisible();
await expect(page.locator(".toast-error")).toHaveCount(0);
});

58
e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "lms-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lms-e2e",
"devDependencies": {
"@playwright/test": "1.64.1"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

7
e2e/package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "lms-e2e",
"private": true,
"devDependencies": {
"@playwright/test": "1.64.1"
}
}

35
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: ".",
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
forbidOnly: true,
reporter: [
["list"],
["json", { outputFile: "../build/evidence/playwright.json" }],
],
use: {
baseURL: process.env.LDD_WEB_URL ?? "http://localhost:8090",
headless: true,
ignoreHTTPSErrors: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
});

View File

@@ -13,6 +13,9 @@ apachePoi = "5.5.1"
ktlint = "14.2.0" ktlint = "14.2.0"
detekt = "1.23.8" detekt = "1.23.8"
mockk = "1.14.11" mockk = "1.14.11"
jqwik = "1.9.3"
pitest = "1.15.0"
pitestJunit5 = "1.2.1"
[libraries] [libraries]
# --- Gradle plugins used from buildSrc --- # --- Gradle plugins used from buildSrc ---
@@ -58,6 +61,7 @@ flywayPostgresql = { module = "org.flywaydb:flyway-database-postgresql" }
postgresql = { module = "org.postgresql:postgresql" } postgresql = { module = "org.postgresql:postgresql" }
jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" } jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" }
jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" } jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
jackson3ModuleKotlin = { module = "tools.jackson.module:jackson-module-kotlin", version = "3.1.5" }
# --- API docs --- # --- API docs ---
springdocStarterWebmvcUi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } springdocStarterWebmvcUi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" }
@@ -71,6 +75,9 @@ apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi
# --- Tests / Testcontainers (versions managed by the Boot BOM) --- # --- Tests / Testcontainers (versions managed by the Boot BOM) ---
mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
jqwik = { module = "net.jqwik:jqwik", version.ref = "jqwik" }
pitestCommandLine = { module = "org.pitest:pitest-command-line", version.ref = "pitest" }
pitestJunit5Plugin = { module = "org.pitest:pitest-junit5-plugin", version.ref = "pitestJunit5" }
junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" } junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" }
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" } testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.security
enum class LmsRole { enum class LmsRole {
ADMIN, ADMIN,
TEACHER,
STUDENT, STUDENT,
; ;

View File

@@ -11,6 +11,7 @@ dependencies {
api(libs.testcontainersJunitJupiter) api(libs.testcontainersJunitJupiter)
api(libs.springBootStarterTest) api(libs.springBootStarterTest)
api(libs.mockk) api(libs.mockk)
api(libs.jqwik)
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно. // Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
api(libs.junitPlatformLauncher) api(libs.junitPlatformLauncher)
} }

9
scripts/ci-build-image.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Собирает предварительно прогретый CI-образ на CI VM.
# Образ immutable: исходники пайплайна берутся из checkout, кэш Gradle — из образа.
set -euo pipefail
cd "$(dirname "$0")/.."
tag="${1:-lms-ci:1}"
docker build -f ci/Dockerfile -t "$tag" .
echo "Готово: $tag"
echo "Образ используется в .woodpecker/*.yaml как image: $tag"

47
scripts/ci-dind.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/sh
# Подготовка изолированного Docker-in-Docker шага Woodpecker.
# Запускается внутри privileged-контейнера docker:*-dind: поднимает локальный
# dockerd и доустанавливает Java/Python/bash, если их нет в образе.
# Хост-Docker сокет не используется.
set -eu
if ! command -v dockerd >/dev/null 2>&1; then
echo "ci-dind.sh: нужен образ docker:<version>-dind" >&2
exit 1
fi
export DOCKER_HOST="${DOCKER_HOST:-unix:///var/run/docker.sock}"
if ! docker info >/dev/null 2>&1; then
echo ">> Запускаю изолированный dockerd..."
dockerd-entrypoint.sh >/tmp/ci-dockerd.log 2>&1 &
i=0
while [ "$i" -lt 90 ]; do
docker info >/dev/null 2>&1 && break
sleep 1
i=$((i + 1))
done
if ! docker info >/dev/null 2>&1; then
echo "dockerd не поднялся:" >&2
tail -50 /tmp/ci-dockerd.log >&2 || true
exit 1
fi
fi
missing=""
command -v bash >/dev/null 2>&1 || missing="$missing bash"
command -v java >/dev/null 2>&1 || missing="$missing openjdk21-jdk"
command -v python3 >/dev/null 2>&1 || missing="$missing python3"
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
command -v npm >/dev/null 2>&1 || missing="$missing npm"
command -v git >/dev/null 2>&1 || missing="$missing git"
command -v curl >/dev/null 2>&1 || missing="$missing curl"
command -v ss >/dev/null 2>&1 || missing="$missing iproute2"
if [ -n "$missing" ]; then
echo ">> Устанавливаю в изолированный шаг:$missing"
# shellcheck disable=SC2086
apk add --no-cache $missing
fi
java -version 2>&1 | head -n 1
docker info --format 'Docker server {{.ServerVersion}} (изолированный dind)'

124
scripts/ci-preflight.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Проверка окружения изолированного dind-шага Woodpecker перед smoke/release.
# Ничего не устанавливает.
set -euo pipefail
cd "$(dirname "$0")/.."
release=0
case "${1:-}" in
"") ;;
--release) release=1 ;;
-h | --help)
echo "Usage: $0 [--release]"
exit 0
;;
*)
echo "Usage: $0 [--release]" >&2
exit 2
;;
esac
failures=()
ok() { printf 'OK %s\n' "$*"; }
fail() {
printf 'FAIL %s\n' "$*"
failures+=("$*")
}
if [[ -n "${BASH_VERSION:-}" ]]; then
ok "bash ${BASH_VERSION}"
else
fail "bash: не найден"
fi
if command -v java >/dev/null 2>&1; then
java_line=$(java -version 2>&1 | head -n 1 || true)
if [[ "$java_line" =~ version\ \"21([.\"-]|$) ]]; then
ok "java 21: ${java_line:-java}"
else
fail "java: нужна версия 21, найдено: ${java_line:-неизвестно}"
fi
else
fail "java: не найден в PATH"
fi
if command -v docker >/dev/null 2>&1; then
docker_line=$(docker --version 2>/dev/null | head -n 1 || true)
ok "docker: ${docker_line:-команда доступна}"
else
fail "docker: не найден в PATH"
fi
if command -v docker >/dev/null 2>&1; then
compose_short=$(docker compose version --short 2>/dev/null | head -n 1 || true)
if [[ -z "$compose_short" ]]; then
compose_short=$(docker compose version 2>/dev/null | head -n 1 || true)
compose_short=${compose_short#Docker Compose version }
compose_short=${compose_short#v}
fi
compose_short=${compose_short#v}
compose_major=${compose_short%%.*}
if [[ "$compose_major" =~ ^[0-9]+$ ]] && ((compose_major >= 2)); then
ok "docker compose v2+: ${compose_short:-неизвестно}"
else
fail "docker compose: нужен v2+, найдено: ${compose_short:-версия не определена}"
fi
else
fail "docker compose: docker не найден"
fi
if command -v python3 >/dev/null 2>&1; then
ok "python3: $(python3 --version 2>&1 | head -n 1)"
else
fail "python3: не найден в PATH"
fi
if command -v git >/dev/null 2>&1; then
ok "git: $(git --version 2>&1 | head -n 1)"
else
fail "git: не найден в PATH"
fi
if command -v ss >/dev/null 2>&1; then
ok "ss: $(ss -V 2>&1 | head -n 1)"
else
fail "ss: не найден в PATH (установите iproute2)"
fi
if ((release)); then
if command -v node >/dev/null 2>&1; then
ok "node: $(node --version 2>&1 | head -n 1)"
else
fail "node: не найден в PATH"
fi
if command -v npx >/dev/null 2>&1; then
ok "npx: $(npx --version 2>&1 | head -n 1)"
else
fail "npx: не найден в PATH"
fi
if [[ -d e2e/node_modules ]]; then
ok "e2e/node_modules: найден"
playwright_line=$(cd e2e && npx playwright --version 2>/dev/null | head -n 1 || true)
if [[ -n "$playwright_line" ]]; then
ok "playwright: $playwright_line"
else
fail "npx playwright --version: не удалось получить версию"
fi
else
fail "e2e/node_modules: не найден (выполните: cd e2e && npm ci)"
fail "npx playwright --version: пропущено без e2e/node_modules"
fi
fi
echo
if ((${#failures[@]} > 0)); then
echo "Preflight не пройден, ошибок: ${#failures[@]}."
printf ' - %s\n' "${failures[@]}"
exit 1
fi
echo "Preflight пройден."

173
scripts/ldd-browser.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# LDD browser E2E: полный стек + Playwright (Chromium/Firefox/WebKit) на одноразовой БД.
set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_e2e_$(date -u +%H%M%S)_$$"
POSTGRES_WAS_RUNNING=0
STACK_STARTED=0
E2E_STATUS=1
log() { echo ">> $*"; }
playwright_version() {
(cd e2e && node -p "require('@playwright/test/package.json').version" 2>/dev/null || true)
}
host_playwright_usable() {
# WebKit на Fedora часто не запускается из-за Ubuntu-специфичных libicu/libjpeg;
# проверяем все три браузера, чтобы fallback сработал и при нехватке lib для Firefox.
(cd e2e && node -e '
const { chromium, firefox, webkit } = require("playwright");
(async () => {
for (const type of [chromium, firefox, webkit]) {
const browser = await type.launch();
await browser.close();
}
})().catch(() => process.exit(1));
') >/dev/null 2>&1
}
ensure_playwright_browsers() {
local browser detected missing=()
# Проверяем точные пути браузеров текущей версии Playwright, а не кэш целиком.
if ! detected=$(cd e2e && node -e '
const fs = require("fs");
const { chromium, firefox, webkit } = require("playwright");
for (const [name, type] of [["chromium", chromium], ["firefox", firefox], ["webkit", webkit]]) {
if (!fs.existsSync(type.executablePath())) console.log(name);
}
' 2>/dev/null); then
detected=$'chromium\nfirefox\nwebkit'
fi
while IFS= read -r browser; do
[[ -n "$browser" ]] && missing+=("$browser")
done <<<"$detected"
if ((${#missing[@]} == 0)); then
log "Браузеры Playwright уже установлены (Chromium/Firefox/WebKit)."
return
fi
log "Устанавливаю браузеры Playwright: ${missing[*]}; системные зависимости ставит CI."
(cd e2e && npx playwright install --no-progress "${missing[@]}")
}
cleanup() {
local code=$?
if ((STACK_STARTED)); then
log "Останавливаю тестовый стек..."
./run.sh stop >/dev/null 2>&1 || true
fi
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true
if ((POSTGRES_WAS_RUNNING == 0)); then
docker compose stop postgres >/dev/null 2>&1 || true
fi
exit $code
}
trap cleanup EXIT INT TERM
for port in 8080 8081 8082 8083 8090; do
if ss -ltn 2>/dev/null | grep -q ":$port "; then
echo "Порт $port занят. Остановите текущий стек (./run.sh stop) и повторите." >&2
exit 1
fi
done
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
command -v npx >/dev/null 2>&1 || { echo "Нужен Node.js/npx" >&2; exit 1; }
JAVA_BIN="${JAVA_BIN:-$(command -v java || true)}"
[[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; }
[[ -d e2e/node_modules ]] || { echo "Выполните: cd e2e && npm install" >&2; exit 1; }
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
POSTGRES_WAS_RUNNING=1
fi
log "Запускаю PostgreSQL..."
docker compose up -d postgres >/dev/null
for _ in $(seq 1 40); do
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break
sleep 1
done
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE $DB;" >/dev/null
log "Собираю bootJar..."
./gradlew bootJar --console=plain >/tmp/lms-ldd-e2e-bootjar.log
log "Запускаю стек..."
export LMS_DB_URL="jdbc:postgresql://localhost:5432/$DB"
export LMS_DB_NAME="$DB"
export LMS_DB_USER="lms"
export LMS_DB_PASSWORD="lms"
export LMS_INTERNAL_TOKEN="ldd-internal-token"
export LMS_SESSION_COOKIE_SECURE="false"
export JAVA_BIN
STACK_STARTED=1
./run.sh start
mkdir -p build/evidence
ensure_playwright_browsers
set +e
if host_playwright_usable; then
log "Запускаю Playwright в текущем окружении (Chromium/Firefox/WebKit)..."
(
cd e2e
LDD_WEB_URL="http://localhost:8090" \
LDD_API_URL="http://localhost:8080" \
npx playwright test --config=playwright.config.ts
)
E2E_STATUS=$?
else
version=$(playwright_version)
if [[ -z "$version" ]]; then
echo "WebKit не запускается в текущем окружении, а версию Playwright определить не удалось." >&2
E2E_STATUS=1
else
log "WebKit не запускается в текущем окружении; запускаю Chromium/Firefox/WebKit в Docker-образе Playwright ${version}."
docker run --rm --network host \
-v "$PWD:/work" \
-w /work/e2e \
-e LDD_WEB_URL="http://localhost:8090" \
-e LDD_API_URL="http://localhost:8080" \
"mcr.microsoft.com/playwright:v${version}-noble" \
npx playwright test --config=playwright.config.ts
E2E_STATUS=$?
fi
fi
set -e
python3 - "$E2E_STATUS" <<'PY'
import json
import os
import sys
status_code = int(sys.argv[1])
playwright_path = "build/evidence/playwright.json"
detail = "Playwright exit=" + str(status_code)
if os.path.isfile(playwright_path):
try:
root = json.load(open(playwright_path, encoding="utf-8"))
stats = root.get("stats", {})
detail += (
f"; expected={stats.get('expected', 0)}"
f", unexpected={stats.get('unexpected', 0)}"
f", flaky={stats.get('flaky', 0)}"
f", skipped={stats.get('skipped', 0)}"
)
except Exception as exc:
detail += "; не удалось разобрать playwright.json: " + str(exc)
status = "passed" if status_code == 0 else "failed"
with open("build/evidence/browser.json", "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()
json.dump(
{"generatedAt": now, "checks": [{"id": "browser.e2e", "status": status, "detail": detail}]},
fh,
ensure_ascii=False,
indent=2,
)
print(("PASS " if status == "passed" else "FAIL ") + "browser.e2e: " + detail)
sys.exit(0 if status == "passed" else 1)
PY

226
scripts/ldd-migration-upgrade.sh Executable file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env bash
# LDD migration evidence: master-схема + данные -> миграции ветки.
# Одноразовая БД, рабочие данные не затрагиваются.
set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_ldd_mig_$(date -u +%H%M%S)_$$"
EVIDENCE="build/evidence/migration.json"
POSTGRES_WAS_RUNNING=0
FAILED=0
log() { echo ">> $*"; }
cleanup() {
local code=$?
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true
if ((POSTGRES_WAS_RUNNING == 0)); then
docker compose stop postgres >/dev/null 2>&1 || true
fi
exit $code
}
trap cleanup EXIT INT TERM
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
git rev-parse --verify master >/dev/null 2>&1 || { echo "Нужна ветка master в git" >&2; exit 1; }
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
POSTGRES_WAS_RUNNING=1
fi
docker compose up -d postgres >/dev/null
for _ in $(seq 1 40); do
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break
sleep 1
done
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE $DB;" >/dev/null
mkdir -p build/evidence
apply() {
docker exec -i lms-postgres psql -U lms -d "$DB" -v ON_ERROR_STOP=1 >/tmp/lms-ldd-migration.log
}
log "Разворачиваю master-схему и синтетические данные..."
{
git show master:services/identity-service/src/main/resources/db/migration/V1__init.sql
echo 'SET search_path TO identity;'
git show master:services/identity-service/src/main/resources/db/migration/V2__accounts.sql
git show master:services/identity-service/src/main/resources/db/migration/V3__login_attempts.sql
cat <<'SQL'
INSERT INTO accounts (id, login, password_hash, role, must_change_password) VALUES
('00000000-0000-7000-8000-000000001001','admin','x','ADMIN',false),
('00000000-0000-7000-8000-000000001002','s1','x','STUDENT',false);
INSERT INTO students (account_id, student_no, last_name, first_name) VALUES
('00000000-0000-7000-8000-000000001002','S1','Иванов','Иван');
INSERT INTO groups (id, name) VALUES ('00000000-0000-7000-8000-000000002001','ПИ-11БО');
INSERT INTO enrollments (group_id, student_id) VALUES
('00000000-0000-7000-8000-000000002001','00000000-0000-7000-8000-000000001002');
SQL
echo 'SET search_path TO academic;'
git show master:services/academic-service/src/main/resources/db/migration/V1__init.sql
git show master:services/academic-service/src/main/resources/db/migration/V2__schema.sql
git show master:services/academic-service/src/main/resources/db/migration/V3__seed.sql
git show master:services/academic-service/src/main/resources/db/migration/V4__content.sql
git show master:services/academic-service/src/main/resources/db/migration/V5__content_published.sql
cat <<'SQL'
INSERT INTO labs (id, number, title, semester_id) VALUES
('00000000-0000-7000-8000-000000003001',1,'ЛР 1','00000000-0000-7000-8000-000000000001');
INSERT INTO chapters (id, title, slug) VALUES
('00000000-0000-7000-8000-000000003002','Глава','osnovy-c');
SQL
echo 'SET search_path TO progress;'
git show master:services/progress-service/src/main/resources/db/migration/V1__init.sql
git show master:services/progress-service/src/main/resources/db/migration/V2__schema.sql
git show master:services/progress-service/src/main/resources/db/migration/V3__grades.sql
git show master:services/progress-service/src/main/resources/db/migration/V4__submissions.sql
git show master:services/progress-service/src/main/resources/db/migration/V5__defense_sessions.sql
git show master:services/progress-service/src/main/resources/db/migration/V6__grade_comments.sql
git show master:services/progress-service/src/main/resources/db/migration/V7__grade_publications.sql
git show master:services/progress-service/src/main/resources/db/migration/V8__grade_publication_students.sql
git show master:services/progress-service/src/main/resources/db/migration/V9__grade_general_comment.sql
git show master:services/progress-service/src/main/resources/db/migration/V11__attestations.sql
git show master:services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql
cat <<'SQL'
INSERT INTO lab_grades (student_id, lab_id, score) VALUES
('00000000-0000-7000-8000-000000001002','00000000-0000-7000-8000-000000003001',4.5);
INSERT INTO grade_comments (student_id, lab_id, text) VALUES
('00000000-0000-7000-8000-000000001002','00000000-0000-7000-8000-000000003001','ok');
SQL
} | apply
log "Применяю миграции ветки..."
{
echo 'SET search_path TO identity;'
cat services/identity-service/src/main/resources/db/migration/V4__roles.sql
cat services/identity-service/src/main/resources/db/migration/V5__groups_cohorts.sql
cat services/identity-service/src/main/resources/db/migration/V6__student_statuses.sql
cat services/identity-service/src/main/resources/db/migration/V7__promotion.sql
cat services/identity-service/src/main/resources/db/migration/V8__membership_history.sql
cat services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql
cat services/identity-service/src/main/resources/db/migration/V10__audit_events.sql
cat services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql
cat services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql
cat services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql
cat services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql
echo 'SET search_path TO academic;'
cat services/academic-service/src/main/resources/db/migration/V6__courses.sql
cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql
cat services/academic-service/src/main/resources/db/migration/V8__chapters_course.sql
cat services/academic-service/src/main/resources/db/migration/V9__article_assets_indexes.sql
cat services/academic-service/src/main/resources/db/migration/V10__course_title_unique.sql
cat services/academic-service/src/main/resources/db/migration/V11__global_audience_all.sql
cat services/academic-service/src/main/resources/db/migration/V12__asset_links_backfill.sql
echo 'SET search_path TO progress;'
cat services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql
} | apply
log "Проверяю postflight..."
CHECK_SQL=$(cat <<'SQL'
SET search_path TO identity;
SELECT 'audit_events=' || count(*) FROM information_schema.tables
WHERE table_schema='identity' AND table_name='audit_events';
SELECT 'refresh_tokens=' || count(*) FROM information_schema.tables
WHERE table_schema='identity' AND table_name='refresh_tokens';
SELECT 'refresh_absolute=' || count(*) FROM information_schema.columns
WHERE table_schema='identity' AND table_name='refresh_tokens' AND column_name='absolute_expires_at';
SELECT 'audit_keyset_idx=' || count(*) FROM pg_indexes
WHERE schemaname='identity' AND indexname='audit_events_occurred_id_idx';
SELECT 'students=' || count(*) FROM students;
SELECT 'groups=' || count(*) FROM groups;
SELECT 'cohort_not_null=' || count(*) FROM information_schema.columns
WHERE table_schema='identity' AND table_name='groups'
AND column_name IN ('stream','degree','course','number','enrollment_year')
AND is_nullable='NO';
SELECT 'cohort_unique_full=' || count(*) FROM pg_indexes
WHERE schemaname='identity' AND indexname='groups_cohort_unique'
AND indexdef NOT LIKE '%WHERE%';
SELECT 'history=' || count(*) FROM membership_history;
SET search_path TO academic;
SELECT 'labs_course_id=' || count(*) FROM information_schema.columns
WHERE table_schema='academic' AND table_name='labs' AND column_name='course_id';
SELECT 'chapters_course_id=' || count(*) FROM information_schema.columns
WHERE table_schema='academic' AND table_name='chapters' AND column_name='course_id';
SELECT 'article_assets=' || count(*) FROM information_schema.tables
WHERE table_schema='academic' AND table_name='article_assets';
SELECT 'labs=' || count(*) FROM labs;
SELECT 'chapters=' || count(*) FROM chapters;
SET search_path TO progress;
SELECT 'grade_author=' || count(*) FROM information_schema.columns
WHERE table_schema='progress' AND table_name='grade_comments' AND column_name='author_account_id';
SELECT 'grade_updated_by=' || count(*) FROM information_schema.columns
WHERE table_schema='progress' AND table_name='lab_grades' AND column_name='updated_by_account_id';
SELECT 'grades=' || count(*) FROM lab_grades;
SELECT 'comments=' || count(*) FROM grade_comments;
SQL
)
RESULT=$(docker exec -i lms-postgres psql -U lms -d "$DB" -At -v ON_ERROR_STOP=1 -c "$CHECK_SQL")
# V13/V14 повторно: идемпотентность.
if { echo 'SET search_path TO progress;'; cat services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql; } | apply; then
V13_TWICE="passed"
else
V13_TWICE="failed"
FAILED=1
fi
if { echo 'SET search_path TO identity;'; cat services/identity-service/src/main/resources/db/migration/V14__cohort_constraints.sql; } | apply; then
V14_TWICE="passed"
else
V14_TWICE="failed"
FAILED=1
fi
printf '%s\n' "$RESULT" | sed 's/^/ /'
python3 - "$EVIDENCE" "$RESULT" "$V13_TWICE" "$V14_TWICE" <<'PY'
import json
import sys
out, result, v13, v14 = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
required = {
"audit_events=1",
"refresh_tokens=1",
"refresh_absolute=1",
"audit_keyset_idx=1",
"students=1",
"groups=1",
"cohort_not_null=5",
"cohort_unique_full=1",
"history=1",
"labs_course_id=1",
"chapters_course_id=1",
"article_assets=1",
"labs=1",
"chapters=1",
"grade_author=1",
"grade_updated_by=1",
"grades=1",
"comments=1",
}
lines = {line.strip() for line in result.splitlines() if line.strip()}
missing = sorted(required - lines)
failed = bool(missing) or v13 != "passed" or v14 != "passed"
if missing:
status = "failed"
detail = "postflight не подтвердил: " + ", ".join(missing)
elif v13 != "passed":
status = "failed"
detail = "V13 не применилась повторно"
elif v14 != "passed":
status = "failed"
detail = "V14 не применилась повторно"
else:
status = "passed"
detail = "master->branch: postflight, повторные V13 и V14 успешны"
checks = [{"id": "migration.master_upgrade", "status": status, "detail": detail}]
with open(out, "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()
json.dump({"generatedAt": now, "checks": checks}, fh, ensure_ascii=False, indent=2)
print(("PASS " if not failed else "FAIL ") + "migration.master_upgrade: " + detail)
sys.exit(1 if failed else 0)
PY
log "Migration evidence: $EVIDENCE"

68
scripts/ldd-mutation.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# LDD mutation evidence: PIT для критичных P0-компонентов.
# JUnit 4 harness используется потому, что JUnit5-плагин PIT несовместим с JUnit Platform 6.
set -euo pipefail
cd "$(dirname "$0")/.."
./gradlew \
:services:academic-service:pitestCourseAccess \
:services:identity-service:pitestPromotionPlanner \
--console=plain
mkdir -p build/evidence
python3 - <<'PY'
import json
import os
import xml.etree.ElementTree as ET
checks = []
def parse(path):
root = ET.parse(path).getroot()
total = killed = no_coverage = 0
for mutation in root.findall("mutation"):
total += 1
status = mutation.attrib.get("status")
if status == "KILLED":
killed += 1
elif status == "NO_COVERAGE":
no_coverage += 1
covered = total - no_coverage
strength = (killed / covered * 100) if covered else 0.0
return total, killed, no_coverage, strength
def check(check_id, path, threshold):
if not os.path.isfile(path):
checks.append({"id": check_id, "status": "failed", "detail": "нет отчёта " + path})
return
total, killed, no_coverage, strength = parse(path)
status = "passed" if strength >= threshold else "failed"
detail = (
f"mutations={total}, killed={killed}, no_coverage={no_coverage}, "
f"test_strength={strength:.1f}% (threshold {threshold:.0f}%)"
)
checks.append({"id": check_id, "status": status, "detail": detail})
print(("PASS " if status == "passed" else "FAIL ") + check_id + ": " + detail)
check(
"mutation.course_access",
"services/academic-service/build/reports/pitest-course-access/mutations.xml",
70.0,
)
check(
"mutation.promotion_planner",
"services/identity-service/build/reports/pitest-promotion-planner/mutations.xml",
60.0,
)
with open("build/evidence/mutation.json", "w", encoding="utf-8") as fh:
json.dump({"generatedAt": __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat(), "checks": checks}, fh, ensure_ascii=False, indent=2)
if any(c["status"] != "passed" for c in checks):
raise SystemExit(1)
PY

31
scripts/ldd-release.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Полный LDD release gate: тесты, линтеры, upgrade, mutation, runtime и browser E2E.
# Требует Docker, Java 21, Node.js/npm и Chromium/Firefox/WebKit Playwright.
set -euo pipefail
cd "$(dirname "$0")/.."
log() { echo; echo ">> $*"; }
log "1/6 Тесты, линтеры и evidence gate"
./gradlew test ktlintCheck detekt evidenceCheck --console=plain
log "2/6 Upgrade с master-схемы"
./scripts/ldd-migration-upgrade.sh
log "3/6 Mutation testing"
./scripts/ldd-mutation.sh
log "4/6 Runtime smoke + scale load"
LDD_SCALE=1 ./scripts/ldd-runtime.sh
log "5/6 Browser E2E"
./scripts/ldd-browser.sh
log "6/6 Финальный evidence gate"
./gradlew evidenceCheck --console=plain
echo
echo "LDD release evidence готов:"
echo " build/evidence/"
echo " build/reports/evidence/index.md"

448
scripts/ldd-runtime.sh Executable file
View File

@@ -0,0 +1,448 @@
#!/usr/bin/env bash
# LDD runtime smoke: поднимает полный стек на одноразовой БД и проверяет
# ключевые сценарии через реальные HTTP-вызовы. Не трогает рабочую БД lms.
#
# ./scripts/ldd-runtime.sh
#
# Требования: docker compose v2, Java 21, curl, python3. Скрипт отказывается
# работать, если порты 8080/8081/8082/8083/8090 уже заняты.
set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_ldd_$(date -u +%H%M%S)_$$"
EVIDENCE="${LDD_EVIDENCE_FILE:-build/evidence/runtime.json}"
POSTGRES_WAS_RUNNING=0
STACK_STARTED=0
log() { echo ">> $*"; }
cleanup() {
local code=$?
if ((STACK_STARTED)); then
log "Останавливаю тестовый стек..."
./run.sh stop >/dev/null 2>&1 || true
fi
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true
if ((POSTGRES_WAS_RUNNING == 0)); then
docker compose stop postgres >/dev/null 2>&1 || true
fi
exit $code
}
trap cleanup EXIT INT TERM
for port in 8080 8081 8082 8083 8090; do
if ss -ltn 2>/dev/null | grep -q ":$port "; then
echo "Порт $port занят. Остановите текущий стек (./run.sh stop) и повторите." >&2
exit 1
fi
done
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "Нужен python3" >&2; exit 1; }
JAVA_BIN="${JAVA_BIN:-$(command -v java || true)}"
[[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; }
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
POSTGRES_WAS_RUNNING=1
fi
log "Запускаю PostgreSQL..."
docker compose up -d postgres >/dev/null
for _ in $(seq 1 40); do
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break
sleep 1
done
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null
log "Создаю одноразовую БД $DB..."
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE $DB;" >/dev/null
log "Собираю bootJar..."
./gradlew bootJar --console=plain >/tmp/lms-ldd-bootjar.log
log "Запускаю стек..."
export LMS_DB_URL="jdbc:postgresql://localhost:5432/$DB"
export LMS_DB_NAME="$DB"
export LMS_DB_USER="lms"
export LMS_DB_PASSWORD="lms"
export LMS_INTERNAL_TOKEN="ldd-internal-token"
export LMS_SESSION_COOKIE_SECURE="false"
export JAVA_BIN
STACK_STARTED=1
./run.sh start
mkdir -p "$(dirname "$EVIDENCE")"
log "Проверяю HTTP-сценарии..."
python3 - "$EVIDENCE" <<'PY'
import concurrent.futures
import json
import os
import statistics
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
out_path = sys.argv[1]
gateway = "http://localhost:8080"
web = "http://localhost:8090"
checks = []
failures = []
def record(check_id, status, detail):
checks.append({"id": check_id, "status": status, "detail": detail})
print(("PASS " if status == "passed" else "FAIL ") + check_id + ": " + detail)
if status != "passed":
failures.append(check_id)
def req(method, url, data=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
body = json.dumps(data).encode() if data is not None else None
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode()
return response.status, json.loads(raw) if raw.strip().startswith(("{", "[")) else raw
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw)
except Exception:
parsed = raw
return exc.code, parsed
def expect(check_id, method, url, expected, data=None, token=None, detail=""):
status, body = req(method, url, data=data, token=token)
if status != expected:
record(check_id, "failed", f"{detail or url}: ожидался {expected}, получен {status}: {body}")
raise AssertionError(check_id)
record(check_id, "passed", detail or f"{method} {url} -> {status}")
return body
try:
# 1. Health всех сервисов.
for name, port in [("identity", 8081), ("academic", 8082), ("progress", 8083), ("gateway", 8080), ("web", 8090)]:
status, body = req("GET", f"http://localhost:{port}/actuator/health")
if status != 200 or not isinstance(body, dict) or body.get("status") != "UP":
record("runtime.stack_health", "failed", f"{name}: {status} {body}")
raise AssertionError("stack_health")
record("runtime.stack_health", "passed", "identity/academic/progress/gateway/web health=UP")
# 2. Admin setup + login через gateway.
health = expect("runtime.admin_setup_login", "GET", gateway + "/api/v1/auth/health", 200)
if health.get("adminExists") is not False:
record("runtime.admin_setup_login", "failed", f"adminExists до setup: {health}")
raise AssertionError("adminExists")
admin = expect(
"runtime.admin_setup_login",
"POST",
gateway + "/api/v1/auth/setup",
200,
data={"login": "ldd-admin", "password": "ldd-password-123", "password2": "ldd-password-123"},
detail="setup admin",
)
admin_token = admin["token"]
me = expect("runtime.admin_setup_login", "GET", gateway + "/api/v1/auth/me", 200, token=admin_token)
if me.get("role") != "ADMIN" or not me.get("active", False):
record("runtime.admin_setup_login", "failed", f"/auth/me: {me}")
raise AssertionError("me")
# 3. Реальный доменный поток: группа -> курс -> преподаватель -> группа курса -> лаба.
suffix = uuid.uuid4().hex[:6]
group = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/groups",
200,
data={"stream": "LDD" + suffix, "course": 1, "number": 1, "degree": "БО", "enrollmentYear": 2026},
token=admin_token,
detail="create group",
)
course = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/courses",
200,
data={"title": "LDD runtime course " + suffix},
token=admin_token,
detail="create course",
)
teacher = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/teachers",
200,
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
token=admin_token,
detail="create teacher",
)
expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/courses/" + course["id"] + "/teachers",
200,
data={"accountId": teacher["id"]},
token=admin_token,
detail="assign teacher",
)
expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/courses/" + course["id"] + "/groups",
200,
data={"groupId": group["id"]},
token=admin_token,
detail="attach group",
)
lab = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/labs",
200,
data={
"number": 1,
"title": "LDD lab",
"status": "published",
"courseId": course["id"],
"levels": [{"code": "basic", "title": "Базовый", "position": 1}],
},
token=admin_token,
detail="create lab",
)
student = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/groups/" + group["id"] + "/students",
200,
data={
"studentNo": "ldd-student-" + suffix,
"lastName": "Тестов",
"firstName": "Студент",
"middleName": "",
"basis": "бюджет",
"note": "",
},
token=admin_token,
detail="create student",
)
teacher_auth = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/auth/login",
200,
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
detail="teacher login",
)
teacher_token = teacher_auth["token"]
teacher_refresh = teacher_auth["refreshToken"]
mine = expect("runtime.course_flow", "GET", gateway + "/api/v1/courses/mine", 200, token=teacher_token)
if not any(item.get("id") == course["id"] for item in mine):
record("runtime.course_flow", "failed", "курс не найден в /courses/mine преподавателя")
raise AssertionError("mine")
labs = expect(
"runtime.course_flow",
"GET",
gateway + "/api/v1/labs?courseId=" + course["id"],
200,
token=teacher_token,
)
if not any(item.get("id") == lab["id"] for item in labs):
record("runtime.course_flow", "failed", "лаба не найдена у преподавателя")
raise AssertionError("lab list")
roster = expect(
"runtime.course_flow",
"GET",
gateway + "/api/v1/courses/" + course["id"] + "/groups/" + group["id"] + "/students",
200,
token=teacher_token,
)
if roster.get("groupId") != group["id"]:
record("runtime.course_flow", "failed", f"roster: {roster}")
raise AssertionError("roster")
record("runtime.course_flow", "passed", "группа/курс/преподаватель/лаба/roster доступны преподавателю")
# 3a. Refresh/logout через gateway: без access-токена, с ротацией.
refreshed = expect(
"runtime.auth_refresh",
"POST",
gateway + "/api/v1/auth/refresh",
200,
data={"refreshToken": teacher_refresh},
detail="refresh via gateway",
)
expect(
"runtime.auth_refresh",
"GET",
gateway + "/api/v1/courses/mine",
200,
token=refreshed["token"],
detail="new access works",
)
expect(
"runtime.auth_refresh",
"POST",
gateway + "/api/v1/auth/logout",
204,
data={"refreshToken": refreshed["refreshToken"]},
detail="logout via gateway",
)
expect(
"runtime.auth_refresh",
"POST",
gateway + "/api/v1/auth/refresh",
401,
data={"refreshToken": refreshed["refreshToken"]},
detail="revoked refresh rejected",
)
teacher_token = refreshed["token"]
record("runtime.auth_refresh", "passed", "refresh ротируется, новый access работает, logout отзывает refresh")
# 3b. Масштабный seed для release-нагрузки: 100 групп / 1000 студентов.
if os.environ.get("LDD_SCALE") == "1":
scale_sql = f"""
WITH nums AS (SELECT g FROM generate_series(1, 1000) g),
acc AS (
INSERT INTO identity.accounts (id, login, password_hash, role, must_change_password)
SELECT uuidv7(), 'scale-s-' || g, 'x', 'STUDENT', false FROM nums
RETURNING id, login
)
INSERT INTO identity.students (account_id, student_no, last_name, first_name)
SELECT id, 'SCALE-' || lpad(split_part(login, '-', 3), 4, '0'), 'Студентов', 'Студент' FROM acc;
INSERT INTO identity.groups (id, name, stream, course, number, degree, enrollment_year, status)
SELECT uuidv7(), 'SCALE-' || g || 'БО', 'SCALE', 1, g, 'БО', 2026, 'ACTIVE'
FROM generate_series(1, 100) g;
INSERT INTO academic.course_groups (id, course_id, group_id, added_at)
SELECT uuidv7(), '{course["id"]}', id, now() FROM identity.groups WHERE stream = 'SCALE';
WITH g AS (
SELECT id, row_number() OVER (ORDER BY name) rn FROM identity.groups WHERE stream = 'SCALE'
), s AS (
SELECT account_id, row_number() OVER (ORDER BY student_no) rn FROM identity.students WHERE student_no LIKE 'SCALE-%'
)
INSERT INTO identity.enrollments (id, group_id, student_id)
SELECT uuidv7(), g.id, s.account_id FROM g JOIN s ON ((s.rn - 1) / 10) + 1 = g.rn;
"""
subprocess.run(
["docker", "exec", "-i", "lms-postgres", "psql", "-U", "lms", "-d", os.environ["LMS_DB_NAME"], "-v", "ON_ERROR_STOP=1"],
input=scale_sql,
text=True,
check=True,
stdout=subprocess.DEVNULL,
)
record("runtime.scale_seed", "passed", "100 групп / 1000 студентов")
# 4. Чужой преподаватель не получает доступ к курсу/лабе/аудиту.
outsider = expect(
"runtime.foreign_teacher_denied",
"POST",
gateway + "/api/v1/teachers",
200,
data={"login": "ldd-outsider-" + suffix, "password": "ldd-outsider-123"},
token=admin_token,
detail="create outsider",
)
outsider_token = expect(
"runtime.foreign_teacher_denied",
"POST",
gateway + "/api/v1/auth/login",
200,
data={"login": "ldd-outsider-" + suffix, "password": "ldd-outsider-123"},
detail="outsider login",
)["token"]
expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/courses/" + course["id"], 403, token=outsider_token, detail="foreign course 403")
expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/labs/" + lab["id"], 403, token=outsider_token, detail="foreign lab 403")
expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/audit", 403, token=outsider_token, detail="audit 403")
record("runtime.foreign_teacher_denied", "passed", "чужой преподаватель получает 403 на курс/лабу/аудит")
# 5. Аудит доступен админу и содержит ключевые события.
events = expect("runtime.audit_visible", "GET", gateway + "/api/v1/audit?limit=200", 200, token=admin_token)
actions = {event.get("action") for event in events}
required = {"COURSE_CREATE", "TEACHER_CREATE", "COURSE_TEACHER_ASSIGN", "COURSE_GROUP_ATTACH", "LAB_CREATE"}
absent = required - actions
if absent:
record("runtime.audit_visible", "failed", f"в аудите нет: {sorted(absent)}; есть: {sorted(actions)}")
raise AssertionError("audit actions")
record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}")
# 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит.
smoke_only = os.environ.get("LDD_SMOKE") == "1"
load_paths = [
(gateway + "/api/v1/courses/" + course["id"], teacher_token),
(gateway + "/api/v1/labs?courseId=" + course["id"], teacher_token),
(gateway + "/api/v1/courses/" + course["id"] + "/students/" + student["id"] + "/profile", teacher_token),
(gateway + "/api/v1/courses/" + course["id"] + "/groups", teacher_token),
(gateway + "/api/v1/audit?limit=100", admin_token),
]
def timed_get(path, token):
started = time.perf_counter()
status, _ = req("GET", path, token=token)
return status, time.perf_counter() - started
if not smoke_only:
latencies = []
errors = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
futures = [
pool.submit(timed_get, path, token)
for _ in range(25)
for path, token in load_paths
]
for future in concurrent.futures.as_completed(futures):
status, latency = future.result()
latencies.append(latency)
if status != 200:
errors += 1
latencies.sort()
p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))]
average = statistics.mean(latencies)
load_ok = errors == 0 and p95 < 2.0
record(
"runtime.load_gradebook_profile",
"passed" if load_ok else "failed",
f"requests={len(latencies)}, errors={errors}, avg={average:.3f}s, p95={p95:.3f}s",
)
if not load_ok:
raise AssertionError("load smoke")
# 6. Web-страница входа и публичное дерево.
status, _ = req("GET", web + "/login")
if status != 200:
record("runtime.web_login_page", "failed", f"/login -> {status}")
raise AssertionError("web login")
record("runtime.web_login_page", "passed", "web /login -> 200")
expect("runtime.web_login_page", "GET", gateway + "/api/v1/content/tree", 200, detail="public content tree")
expect("runtime.web_login_page", "GET", gateway + "/api/v1/courses/" + course["id"] + "/content/tree", 401, detail="anonymous course content 401")
except AssertionError:
pass
except Exception as exc:
record("runtime.unexpected", "failed", f"{type(exc).__name__}: {exc}")
with open(out_path, "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()
json.dump({"generatedAt": now, "checks": checks}, fh, ensure_ascii=False, indent=2)
if failures:
print("Неуспешные runtime-проверки: " + ", ".join(failures), file=sys.stderr)
sys.exit(1)
print("Все runtime-проверки пройдены.")
PY
if [[ -f "$EVIDENCE" ]]; then
log "Runtime evidence: $EVIDENCE"
fi

6
scripts/ldd-smoke.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Быстрый дымовой тест для каждого коммита: стек + health + вход + один курс.
# Полный release gate: scripts/ldd-release.sh.
set -euo pipefail
cd "$(dirname "$0")/.."
LDD_SMOKE=1 LDD_EVIDENCE_FILE=build/evidence/smoke.json exec ./scripts/ldd-runtime.sh

81
scripts/legacy-assets.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Инвентаризация legacy-активов (файлов без связи со статьёй) на работающем стенде.
# Пример:
# LMS_BASE_URL=https://lms.example.ru LMS_ADMIN_LOGIN=admin LMS_ADMIN_PASSWORD=... \
# ./scripts/legacy-assets.sh
set -euo pipefail
base_url="${LMS_BASE_URL:-http://localhost:8080}"
admin_login="${LMS_ADMIN_LOGIN:-}"
admin_password="${LMS_ADMIN_PASSWORD:-}"
if [[ -z "$admin_login" || -z "$admin_password" ]]; then
echo "Задайте LMS_ADMIN_LOGIN и LMS_ADMIN_PASSWORD." >&2
echo "Пример: LMS_BASE_URL=https://lms.example.ru LMS_ADMIN_LOGIN=admin LMS_ADMIN_PASSWORD=... $0" >&2
exit 2
fi
LMS_BASE_URL="${base_url%/}" \
LMS_ADMIN_LOGIN="$admin_login" \
LMS_ADMIN_PASSWORD="$admin_password" \
python3 - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
base_url = os.environ["LMS_BASE_URL"]
admin_login = os.environ["LMS_ADMIN_LOGIN"]
admin_password = os.environ["LMS_ADMIN_PASSWORD"]
def request(method, path, payload=None, token=None):
body = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(base_url + path, data=body, headers=headers, method=method)
try:
return urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace").strip()
message = f"{method} {path}: HTTP {exc.code}"
if detail:
message += f": {detail}"
print(message, file=sys.stderr)
raise SystemExit(1)
except urllib.error.URLError as exc:
print(f"Не удалось подключиться к {base_url}: {exc.reason}", file=sys.stderr)
raise SystemExit(1)
with request("POST", "/api/v1/auth/login", {"login": admin_login, "password": admin_password}) as response:
login_body = response.read().decode("utf-8")
try:
token = json.loads(login_body)["token"]
except (ValueError, KeyError, TypeError):
print("Ответ /api/v1/auth/login не содержит token.", file=sys.stderr)
raise SystemExit(1)
with request("GET", "/api/v1/content/legacy-assets", token=token) as response:
try:
assets = json.load(response)
except ValueError:
print("Ответ /api/v1/content/legacy-assets не является JSON.", file=sys.stderr)
raise SystemExit(1)
if not isinstance(assets, list):
print("Неожиданный формат /api/v1/content/legacy-assets: ожидался список.", file=sys.stderr)
raise SystemExit(1)
for asset in assets:
if not isinstance(asset, str):
print(f"Пропущен нестроковый элемент: {asset!r}", file=sys.stderr)
continue
print(asset)
print(f"Всего legacy-активов: {len(assets)}", file=sys.stderr)
PY

View File

@@ -2,6 +2,58 @@ plugins {
id("lms.spring-boot") id("lms.spring-boot")
} }
/** PIT запускается отдельной задачей: Gradle-плагин 1.15 несовместим с Gradle 9.6. */
val pitest by configurations.creating
dependencies {
add("pitest", libs.pitestCommandLine)
}
tasks.register<JavaExec>("pitestCourseAccess") {
group = "verification"
description = "Mutation testing CourseAccessService (P0 access matrix)"
dependsOn(tasks.testClasses)
classpath = pitest + sourceSets.getByName("test").runtimeClasspath
mainClass.set("org.pitest.mutationtest.commandline.MutationCoverageReport")
val reportDir =
layout.buildDirectory
.dir("reports/pitest-course-access")
.get()
.asFile
.absolutePath
val sourceDirs =
sourceSets.main
.get()
.kotlin.srcDirs
.joinToString(",") { it.absolutePath }
val testClasspath = sourceSets.getByName("test").runtimeClasspath.asPath
args(
"--reportDir",
reportDir,
"--targetClasses",
"ru.ysu.lms.academic.course.CourseAccessService",
"--targetTests",
"ru.ysu.lms.academic.course.CourseAccessServiceMutationTest",
"--sourceDirs",
sourceDirs,
"--classPath",
testClasspath,
"--mutators",
"STRONGER",
"--threads",
"1",
"--verbose",
"--outputFormats",
"XML,HTML",
"--timestampedReports",
"false",
"--timeoutConst",
"15000",
"--failWhenNoMutations",
"true",
)
}
dependencies { dependencies {
implementation(platform(libs.springBootBom)) implementation(platform(libs.springBootBom))
@@ -16,6 +68,7 @@ dependencies {
runtimeOnly(libs.postgresql) runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml) implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin) implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(libs.springdocStarterWebmvcUi) implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http")) implementation(project(":libs:common-http"))
@@ -23,6 +76,10 @@ dependencies {
implementation(project(":libs:security-jwt")) implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support")) testImplementation(project(":libs:test-support"))
testImplementation(libs.springSecurityTest)
// PIT 1.15/1.20 JUnit5-плагин несовместим с JUnit Platform 6 (Spring Boot 4):
// mutation harness запускается через встроенный JUnit 4 plugin.
testImplementation("junit:junit:4.13.2")
} }
springBoot { springBoot {

View File

@@ -0,0 +1,104 @@
package ru.ysu.lms.academic.audit
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import java.net.http.HttpClient
import java.time.Duration
/**
* Клиент внутреннего endpoint аудита identity-service.
*
* Запись аудита — best-effort: недоступность identity не должна ломать бизнес-операцию,
* поэтому любые ошибки только логируются. Логин актора здесь не разрешается — identity
* уже знает пользователя по `actorAccountId`.
*/
@Service
class AuditClient internal constructor(
private val client: RestClient,
private val internalToken: String,
) {
@Autowired
constructor(
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
@Value("\${lms.internal.token:dev-internal-token}") internalToken: String,
) : this(restClient(baseUrl), internalToken)
@Suppress("TooGenericExceptionCaught")
fun record(
actorAccountId: String?,
action: String,
entityType: String,
entityId: String?,
courseId: String? = null,
studentId: String? = null,
details: String = "",
) {
try {
client
.post()
.uri(AUDIT_PATH)
.header(INTERNAL_TOKEN_HEADER, internalToken)
.contentType(MediaType.APPLICATION_JSON)
.body(
AuditRequest(
actorAccountId = actorAccountId,
action = action,
entityType = entityType,
entityId = entityId,
courseId = courseId,
studentId = studentId,
details = details,
),
).retrieve()
.toBodilessEntity()
} catch (e: Exception) {
log.warn(
"Не удалось записать аудит: action={}, entityType={}, entityId={}",
action,
entityType,
entityId,
e,
)
}
}
private companion object {
private val log = LoggerFactory.getLogger(AuditClient::class.java)
private const val AUDIT_PATH = "/api/v1/internal/audit"
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
}
}
/** Тело `POST /api/v1/internal/audit`; поля null допустимы по контракту. */
internal data class AuditRequest(
val actorAccountId: String?,
val actorLogin: String? = null,
val action: String,
val entityType: String,
val entityId: String?,
val courseId: String?,
val studentId: String?,
val details: String,
)
/** Таймауты обязательны: зависший audit не должен надолго задерживать запрос. */
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 5L
private fun restClient(baseUrl: String): RestClient =
RestClient
.builder()
.baseUrl(baseUrl)
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build(),
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
).build()

View File

@@ -0,0 +1,17 @@
package ru.ysu.lms.academic.config
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import tools.jackson.module.kotlin.KotlinModule
/** Jackson 3: без Kotlin-модуля DTO с default-значениями требуют все поля JSON. */
@Configuration
class JacksonConfig {
@Bean
fun kotlinModule(): KotlinModule = KotlinModule.Builder().build()
@Bean
fun kotlinJsonCustomizer(): JsonMapperBuilderCustomizer =
JsonMapperBuilderCustomizer { builder -> builder.addModule(KotlinModule.Builder().build()) }
}

View File

@@ -65,6 +65,16 @@ class AssetStorage(
} }
} }
/** Имена обычных файлов непосредственно в корне хранилища (без подкаталогов). */
fun listNames(): List<String> =
Files.list(rootDir).use { stream ->
stream
.filter { Files.isRegularFile(it) }
.map { it.fileName.toString() }
.sorted()
.toList()
}
fun read(publicName: String): Pair<Path, String>? { fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/') val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize() val target = rootDir.resolve(clean).normalize()

View File

@@ -0,0 +1,35 @@
package ru.ysu.lms.academic.content
import ru.ysu.lms.common.BadRequestException
/** Аудитории глобальных глав; ALL доступна без авторизации. */
const val AUDIENCE_ALL = "ALL"
const val AUDIENCE_STUDENTS = "STUDENTS"
const val AUDIENCE_TEACHERS = "TEACHERS"
/** Допустимые аудитории глобальной главы. */
val CONTENT_AUDIENCES: Set<String> = setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS, AUDIENCE_TEACHERS)
/**
* Аудитории глобальных глав, доступные роли. Аноним видит только ALL,
* студент — ALL и STUDENTS, преподаватель — ALL и TEACHERS, администратор — всё.
*/
fun audiencesForRole(role: String?): Set<String> =
when (role) {
ROLE_ADMIN -> CONTENT_AUDIENCES
ROLE_STUDENT -> setOf(AUDIENCE_ALL, AUDIENCE_STUDENTS)
ROLE_TEACHER -> setOf(AUDIENCE_ALL, AUDIENCE_TEACHERS)
else -> setOf(AUDIENCE_ALL)
}
/** Нормализует и проверяет аудиторию; `null` остаётся `null`. */
fun normalizeAudience(audience: String?): String? {
if (audience == null) return null
val normalized = audience.trim().uppercase()
if (normalized !in CONTENT_AUDIENCES) throw BadRequestException("Недопустимая аудитория: $audience")
return normalized
}
private const val ROLE_ADMIN = "ADMIN"
private const val ROLE_STUDENT = "STUDENT"
private const val ROLE_TEACHER = "TEACHER"

View File

@@ -9,6 +9,7 @@ import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import java.util.UUID import java.util.UUID
@@ -32,8 +33,14 @@ class ContentAuthoringService(
articleId: UUID, articleId: UUID,
title: String, title: String,
bodyMd: String, bodyMd: String,
courseId: UUID? = null,
) { ) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } val a =
if (courseId == null) {
articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
} else {
requireArticleInCourse(articleId, courseId)
}
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
a.title = title.trim() a.title = title.trim()
a.bodyMd = bodyMd a.bodyMd = bodyMd
@@ -41,46 +48,97 @@ class ContentAuthoringService(
} }
@Transactional @Transactional
fun deleteArticle(articleId: UUID) { fun deleteArticle(
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена") articleId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireArticleInCourse(articleId, courseId)
} else if (!articles.existsById(articleId)) {
throw NotFoundException("Статья не найдена")
}
articles.deleteById(articleId) articles.deleteById(articleId)
} }
/**
* Глобальная глава (`courseId == null`) получает запрошенную аудиторию
* (по умолчанию ALL) и уникальный slug среди глобальных глав. Курсовая глава
* получает `courseId`, пустую аудиторию и уникальный slug внутри курса.
*/
@Transactional @Transactional
fun createChapter( fun createChapter(
title: String, title: String,
slug: String?, slug: String?,
courseId: UUID? = null,
audience: String? = null,
): ChapterTreeNode { ): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val requestedAudience = normalizeAudience(audience)
val scope =
if (courseId == null) {
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
} else {
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId)
}
val cleanSlug = val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) } slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()) ?: Slugs.uniqueSlug(title, scope.map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0 val maxPos = scope.maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1)) val ch =
chapters.save(
ContentChapter(
title = title.trim(),
slug = cleanSlug,
position = maxPos + 1,
audience = if (courseId == null) requestedAudience ?: AUDIENCE_ALL else null,
courseId = courseId,
),
)
return ChapterTreeNode( return ChapterTreeNode(
id = ch.id.toString(), id = ch.id.toString(),
title = ch.title, title = ch.title,
slug = ch.slug, slug = ch.slug,
position = ch.position, position = ch.position,
published = ch.published, published = ch.published,
audience = ch.audience,
sections = emptyList(), sections = emptyList(),
) )
} }
/** Меняет аудиторию только у глобальной главы; у курсовой её нет. */
@Transactional
fun updateChapterAudience(
chapterId: UUID,
audience: String,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (ch.courseId != null) throw BadRequestException("Аудитория задаётся только для глобальных глав")
ch.audience = normalizeAudience(audience)
chapters.save(ch)
}
@Transactional @Transactional
fun renameChapter( fun renameChapter(
chapterId: UUID, chapterId: UUID,
title: String, title: String,
courseId: UUID? = null,
) { ) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = findChapter(chapterId, courseId)
if (title.isBlank()) throw BadRequestException("Название не может быть пустым") if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim() ch.title = title.trim()
chapters.save(ch) chapters.save(ch)
} }
@Transactional @Transactional
fun deleteChapter(chapterId: UUID) { fun deleteChapter(
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена") chapterId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireChapterInCourse(chapterId, courseId)
} else if (!chapters.existsById(chapterId)) {
throw NotFoundException("Глава не найдена")
}
chapters.deleteById(chapterId) chapters.deleteById(chapterId)
} }
@@ -89,8 +147,9 @@ class ContentAuthoringService(
chapterId: UUID, chapterId: UUID,
title: String, title: String,
slug: String?, slug: String?,
courseId: UUID? = null,
): SectionTreeNode { ): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = findChapter(chapterId, courseId)
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug = val cleanSlug =
@@ -115,16 +174,24 @@ class ContentAuthoringService(
fun renameSection( fun renameSection(
sectionId: UUID, sectionId: UUID,
title: String, title: String,
courseId: UUID? = null,
) { ) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val sec = findSection(sectionId, courseId, "Раздел не найден")
if (title.isBlank()) throw BadRequestException("Название не может быть пустым") if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim() sec.title = title.trim()
sections.save(sec) sections.save(sec)
} }
@Transactional @Transactional
fun deleteSection(sectionId: UUID) { fun deleteSection(
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден") sectionId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireSectionInCourse(sectionId, courseId)
} else if (!sections.existsById(sectionId)) {
throw NotFoundException("Раздел не найден")
}
sections.deleteById(sectionId) sections.deleteById(sectionId)
} }
@@ -133,9 +200,10 @@ class ContentAuthoringService(
fun moveArticlesAndDeleteSection( fun moveArticlesAndDeleteSection(
sectionId: UUID, sectionId: UUID,
targetSectionId: UUID, targetSectionId: UUID,
courseId: UUID? = null,
) { ) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val src = findSection(sectionId, courseId, "Раздел не найден")
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") } val dst = findSection(targetSectionId, courseId, "Раздел назначения не найден")
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным") if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id) val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0 var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
@@ -148,41 +216,39 @@ class ContentAuthoringService(
sections.delete(src) sections.delete(src)
} }
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */ /**
* Создаёт статью; если раздела нет — создаёт главу/раздел по названиям.
* При `courseId != null` все вложенные сущности обязаны принадлежать этому курсу,
* а новые главы и разделы создаются в нём.
*/
@Transactional @Transactional
fun createArticlePlaced(placement: ArticlePlacement): ContentArticleView { fun createArticlePlaced(
placement: ArticlePlacement,
courseId: UUID? = null,
): ContentArticleView {
val targetSection: ContentSection = val targetSection: ContentSection =
when { when {
placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> { placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> {
val anchor = val anchor = findSection(placement.sectionId, courseId, "Раздел не найден")
sections val node = createSection(anchor.chapterId, placement.newSectionTitle, null, courseId)
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, placement.newSectionTitle, null)
sections sections
.findById(UUID.fromString(node.id)) .findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") } .orElseThrow { NotFoundException("Раздел не найден") }
} }
placement.sectionId != null -> placement.sectionId != null -> findSection(placement.sectionId, courseId, "Раздел не найден")
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
!placement.newSectionTitle.isNullOrBlank() -> { !placement.newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter = val chapter: ContentChapter =
when { when {
placement.chapterId != null -> placement.chapterId != null -> findChapter(placement.chapterId, courseId)
chapters
.findById(placement.chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
!placement.newChapterTitle.isNullOrBlank() -> { !placement.newChapterTitle.isNullOrBlank() -> {
val node = createChapter(placement.newChapterTitle, null) val node = createChapter(placement.newChapterTitle, null, courseId)
chapters chapters
.findById(UUID.fromString(node.id)) .findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") } .orElseThrow { NotFoundException("Глава не найдена") }
} }
else -> throw BadRequestException("Укажите главу") else -> throw BadRequestException("Укажите главу")
} }
val node = createSection(chapter.id, placement.newSectionTitle, null) val node = createSection(chapter.id, placement.newSectionTitle, null, courseId)
sections sections
.findById(UUID.fromString(node.id)) .findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") } .orElseThrow { NotFoundException("Раздел не найден") }
@@ -243,4 +309,55 @@ class ContentAuthoringService(
bodyMd = a.bodyMd, bodyMd = a.bodyMd,
) )
} }
/**
* Проверяет, что глава существует и принадлежит курсу. Чужой курс — 403,
* отсутствующая глава — 404, чтобы не путать ошибку доступа с опечаткой в id.
*/
fun requireChapterInCourse(
chapterId: UUID,
courseId: UUID,
): ContentChapter {
val chapter = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (chapter.courseId != courseId) throw ForbiddenException("Глава принадлежит другому курсу")
return chapter
}
fun requireSectionInCourse(
sectionId: UUID,
courseId: UUID,
): ContentSection {
val section = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
requireChapterInCourse(section.chapterId, courseId)
return section
}
fun requireArticleInCourse(
articleId: UUID,
courseId: UUID,
): ContentArticle {
val article = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
requireSectionInCourse(article.sectionId, courseId)
return article
}
private fun findChapter(
chapterId: UUID,
courseId: UUID?,
): ContentChapter =
if (courseId == null) {
chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
} else {
requireChapterInCourse(chapterId, courseId)
}
private fun findSection(
sectionId: UUID,
courseId: UUID?,
notFoundMessage: String,
): ContentSection {
val section = sections.findById(sectionId).orElseThrow { NotFoundException(notFoundMessage) }
if (courseId != null) requireChapterInCourse(section.chapterId, courseId)
return section
}
} }

View File

@@ -2,9 +2,11 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ArticleAsset
import ru.ysu.lms.academic.domain.ContentArticle import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ArticleAssetRepository
import ru.ysu.lms.academic.repo.ContentArticleRepository import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -27,6 +29,7 @@ class ContentImporter(
private val chapters: ContentChapterRepository, private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository, private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val assetLinks: ArticleAssetRepository,
private val assets: AssetStorage, private val assets: AssetStorage,
) { ) {
@Transactional @Transactional
@@ -43,9 +46,9 @@ class ContentImporter(
if (contentFiles.isEmpty()) { if (contentFiles.isEmpty()) {
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами") throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
} }
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath)) val storedAssets = storeAssets(files.filterKeys(::isAssetsPath))
val parsed = parseArticles(contentFiles) val parsed = parseArticles(contentFiles)
return mergeIntoDb(parsed, linkRewrite, sync) return mergeIntoDb(parsed, storedAssets, sync)
} }
private fun requireZip(filename: String) { private fun requireZip(filename: String) {
@@ -54,22 +57,22 @@ class ContentImporter(
private fun mergeIntoDb( private fun mergeIntoDb(
parsed: List<ParsedArticle>, parsed: List<ParsedArticle>,
linkRewrite: Map<String, String>, storedAssets: StoredAssets,
sync: Boolean, sync: Boolean,
): ContentImportResult { ): ContentImportResult {
val state = ImportState() val state = ImportState()
for (p in parsed) { for (p in parsed) {
val chapter = chapterFor(p, state) val chapter = chapterFor(p, state)
val section = sectionFor(chapter, p, state) val section = sectionFor(chapter, p, state)
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) } val body = storedAssets.rewrite(p.body)
upsertArticle(section, p, body, state) upsertArticle(section, p, body, storedAssets, state)
} }
val removed = if (sync) removeMissing(state) else 0 val removed = if (sync) removeMissing(state) else 0
return ContentImportResult( return ContentImportResult(
chapters = state.chapters, chapters = state.chapters,
sections = state.sections, sections = state.sections,
articles = state.articles, articles = state.articles,
assets = linkRewrite.size, assets = storedAssets.refs.size,
removed = removed, removed = removed,
) )
} }
@@ -85,10 +88,13 @@ class ContentImporter(
existing?.apply { existing?.apply {
title = p.chapterDir title = p.chapterDir
position = p.chapterPos position = p.chapterPos
// Импорт создаёт/обновляет глобальные главы; курсовые не трогаем.
if (courseId == null && audience == null) audience = AUDIENCE_ALL
} ?: ContentChapter( } ?: ContentChapter(
title = p.chapterDir, title = p.chapterDir,
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()), slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos, position = p.chapterPos,
audience = AUDIENCE_ALL,
) )
state.chapter = chapters.save(entity) state.chapter = chapters.save(entity)
state.touchedChapters.add(state.chapter!!.id) state.touchedChapters.add(state.chapter!!.id)
@@ -133,6 +139,7 @@ class ContentImporter(
section: ContentSection, section: ContentSection,
p: ParsedArticle, p: ParsedArticle,
body: String, body: String,
storedAssets: StoredAssets,
state: ImportState, state: ImportState,
) { ) {
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}" val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
@@ -145,11 +152,33 @@ class ContentImporter(
bodyMd = body bodyMd = body
position = p.articlePos position = p.articlePos
} }
articles.save(article) // saveAndFlush гарантирует строку articles до вставки article_assets (FK).
articles.saveAndFlush(article)
replaceAssetLinks(article, body, storedAssets)
state.seenArticles.add(article.id) state.seenArticles.add(article.id)
state.articles += 1 state.articles += 1
} }
/** Связи статьи с активами полностью заменяются при повторном импорте. */
private fun replaceAssetLinks(
article: ContentArticle,
body: String,
storedAssets: StoredAssets,
) {
assetLinks.deleteByArticleId(article.id)
for (ref in storedAssets.referencedIn(body)) {
assetLinks.save(
ArticleAsset(
articleId = article.id,
filename = ref.originalName,
storedPath = ref.storedPath,
mime = ref.mime,
size = ref.size,
),
)
}
}
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */ /** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
private fun removeMissing(state: ImportState): Int = private fun removeMissing(state: ImportState): Int =
state.touchedChapters.sumOf { chapterId -> state.touchedChapters.sumOf { chapterId ->
@@ -177,15 +206,22 @@ class ContentImporter(
return removed return removed
} }
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> { private fun storeAssets(assetFiles: Map<String, ByteArray>): StoredAssets {
val linkRewrite = mutableMapOf<String, String>() val refs = mutableListOf<StoredAssetRef>()
for ((path, data) in assetFiles) { for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/') val name = path.substringAfterLast('/')
if (name.isBlank()) continue if (name.isBlank()) continue
val stored = assets.store(name, data) val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}" refs +=
StoredAssetRef(
originalName = name,
storedPath = stored.storedPath.toString(),
mime = stored.mime,
size = data.size.toLong(),
url = "/api/v1/content/assets/${stored.publicName}",
)
} }
return linkRewrite return StoredAssets(refs)
} }
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/") private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
@@ -294,6 +330,22 @@ class ContentImporter(
value value
} }
private data class StoredAssetRef(
val originalName: String,
val storedPath: String,
val mime: String,
val size: Long,
val url: String,
)
private data class StoredAssets(
val refs: List<StoredAssetRef>,
) {
fun rewrite(body: String): String = refs.fold(body) { acc, ref -> acc.replace("assets/${ref.originalName}", ref.url) }
fun referencedIn(body: String): List<StoredAssetRef> = refs.filter { body.contains(it.url) }
}
private data class ParsedArticle( private data class ParsedArticle(
val chapterPos: Int, val chapterPos: Int,
val chapterDir: String, val chapterDir: String,

View File

@@ -2,6 +2,9 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ArticleAsset
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.repo.ArticleAssetRepository
import ru.ysu.lms.academic.repo.ContentArticleRepository import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -31,6 +34,7 @@ data class ChapterTreeNode(
val slug: String, val slug: String,
val position: Int, val position: Int,
val published: Boolean = true, val published: Boolean = true,
val audience: String? = null,
val sections: List<SectionTreeNode>, val sections: List<SectionTreeNode>,
) )
@@ -59,6 +63,17 @@ data class ContentImportResult(
val removed: Int = 0, val removed: Int = 0,
) )
/**
* Наследуемая видимость актива: статья → раздел → глава.
* `published` — опубликованы все три уровня.
*/
data class ArticleAssetAccess(
val articleId: UUID,
val courseId: UUID?,
val audience: String?,
val published: Boolean,
)
/** /**
* Новое расположение материалов: порядок глав, порядок разделов внутри глав * Новое расположение материалов: порядок глав, порядок разделов внутри глав
* (и перенос разделов между главами), порядок статей внутри разделов * (и перенос разделов между главами), порядок статей внутри разделов
@@ -97,20 +112,181 @@ class ContentService(
private val chapters: ContentChapterRepository, private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository, private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val articleAssets: ArticleAssetRepository,
private val importer: ContentImporter, private val importer: ContentImporter,
private val assetStorage: AssetStorage,
) { ) {
/**
* Публичное дерево: только глобальные главы, аудитория которых доступна роли.
* Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
* администратор — все аудитории. Курсовые главы здесь не показываются.
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree { fun tree(
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published } includeHidden: Boolean = false,
role: String? = null,
): ContentTree {
val allowedAudiences = audiencesForRole(role)
return buildTree(
chapters
.findAllByOrderByPositionAscIdAsc()
.filter {
it.courseId == null &&
it.audience in allowedAudiences &&
(includeHidden || it.published)
},
includeHidden = includeHidden,
)
}
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
@Transactional(readOnly = true)
fun treeAll(): ContentTree = buildTree(chapters.findAllByOrderByPositionAscIdAsc(), includeHidden = true)
/** Дерево материалов конкретного курса. */
@Transactional(readOnly = true)
fun courseTree(
courseId: UUID,
includeHidden: Boolean = false,
): ContentTree =
buildTree(
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId).filter { includeHidden || it.published },
includeHidden = includeHidden,
)
@Transactional(readOnly = true)
fun findArticle(
chapterSlug: String,
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean = false,
role: String? = null,
): ContentArticleView {
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
requirePublicChapter(ch, includeHidden, role)
return articleView(ch, sectionSlug, articleSlug, includeHidden)
}
/** Просмотр статьи админом: глобальные и курсовые главы без учёта публикации. */
@Transactional(readOnly = true)
fun findArticleForAdmin(
chapterSlug: String,
sectionSlug: String,
articleSlug: String,
): ContentArticleView {
// Сначала глобальная глава, затем первая курсовая по id: slug может повторяться
// в разных курсах, а findBySlug на дублях бросает IncorrectResultSizeDataAccessException.
val ch =
requireFound(
chapters.findBySlugAndCourseIdIsNull(chapterSlug)
?: chapters.findFirstBySlugOrderByIdAsc(chapterSlug),
"Глава не найдена",
)
return articleView(ch, sectionSlug, articleSlug, includeHidden = true)
}
@Transactional(readOnly = true)
fun findCourseArticle(
courseId: UUID,
chapterSlug: String,
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean = false,
): ContentArticleView {
val ch = requireFound(chapters.findByCourseIdAndSlug(courseId, chapterSlug), "Глава не найдена")
requireVisible(ch.published, includeHidden, "Глава скрыта")
return articleView(ch, sectionSlug, articleSlug, includeHidden)
}
/**
* Все статьи, к которым привязан актив, с наследуемой видимостью.
* Один файл может использоваться несколькими статьями — решение принимает
* вызывающая сторона по правилу «доступен, если видима хотя бы одна».
*/
@Transactional(readOnly = true)
fun assetAccesses(name: String): List<ArticleAssetAccess> =
(articleAssets.findByStoredPathEndingWith(name) + articleAssets.findByFilename(name))
.distinctBy { it.id }
.filter { it.matchesAssetName(name) }
.mapNotNull { assetAccess(it) }
/**
* Файлы в хранилище, на которые не ссылается ни одна запись `article_assets`.
* Нужны админу для очистки legacy-файлов: по умолчанию они не публичны.
*/
@Transactional(readOnly = true)
fun unlinkedAssetNames(): List<String> {
val assets = articleAssets.findAll()
return assetStorage
.listNames()
.filter { name -> assets.none { it.matchesAssetName(name) } }
.sorted()
}
private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? {
val articleId = asset.articleId ?: return null
val article = articles.findById(articleId).orElse(null)
val section = article?.let { sections.findById(it.sectionId).orElse(null) }
val chapter = section?.let { chapters.findById(it.chapterId).orElse(null) }
return if (article != null && section != null && chapter != null) {
ArticleAssetAccess(
articleId = article.id,
courseId = chapter.courseId,
audience = chapter.audience,
published = chapter.published && section.published && article.published,
)
} else {
null
}
}
private fun articleView(
ch: ContentChapter,
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean,
): ContentArticleView {
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
requireVisible(sec.published, includeHidden, "Раздел скрыт")
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
requireVisible(a.published, includeHidden, "Статья скрыта")
return ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
private fun requirePublicChapter(
ch: ContentChapter,
includeHidden: Boolean,
role: String?,
) {
requireVisible(ch.published, includeHidden, "Глава скрыта")
if (ch.audience !in audiencesForRole(role)) throw NotFoundException("Глава недоступна")
}
private fun buildTree(
chapterList: List<ContentChapter>,
includeHidden: Boolean,
): ContentTree {
val chapterIds = chapterList.mapTo(HashSet()) { it.id }
val sectionList = val sectionList =
sections sections
.findAllByOrderByPositionAscIdAsc() .findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published } .filter { it.chapterId in chapterIds && (includeHidden || it.published) }
.groupBy { it.chapterId } .groupBy { it.chapterId }
val sectionIds = sectionList.values.flatten().mapTo(HashSet()) { it.id }
val articleList = val articleList =
articles articles
.findAllByOrderByPositionAscIdAsc() .findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published } .filter { it.sectionId in sectionIds && (includeHidden || it.published) }
.groupBy { it.sectionId } .groupBy { it.sectionId }
return ContentTree( return ContentTree(
chapterList.map { ch -> chapterList.map { ch ->
@@ -120,6 +296,7 @@ class ContentService(
slug = ch.slug, slug = ch.slug,
position = ch.position, position = ch.position,
published = ch.published, published = ch.published,
audience = ch.audience,
sections = sections =
(sectionList[ch.id] ?: emptyList()).map { sec -> (sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode( SectionTreeNode(
@@ -139,36 +316,6 @@ class ContentService(
) )
} }
/** Полное дерево (включая скрытые) — только для админки. */
@Transactional(readOnly = true)
fun treeAll(): ContentTree = tree(includeHidden = true)
@Transactional(readOnly = true)
fun findArticle(
chapterSlug: String,
sectionSlug: String,
articleSlug: String,
includeHidden: Boolean = false,
): ContentArticleView {
val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
requireVisible(ch.published, includeHidden, "Глава скрыта")
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
requireVisible(sec.published, includeHidden, "Раздел скрыт")
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
requireVisible(a.published, includeHidden, "Статья скрыта")
return ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
private fun <T> requireFound( private fun <T> requireFound(
value: T?, value: T?,
message: String, message: String,
@@ -212,24 +359,31 @@ class ContentService(
articles.save(a) articles.save(a)
} }
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun listAll(): List<ContentArticleView> { fun listAll(
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleView> {
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id } val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id } val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a -> val all =
val sec = secById[a.sectionId] ?: return@mapNotNull null articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val ch = chById[sec.chapterId] ?: return@mapNotNull null val sec = secById[a.sectionId] ?: return@mapNotNull null
ContentArticleView( val ch = chById[sec.chapterId] ?: return@mapNotNull null
id = a.id.toString(), ContentArticleView(
chapterSlug = ch.slug, id = a.id.toString(),
chapterTitle = ch.title, chapterSlug = ch.slug,
sectionSlug = sec.slug, chapterTitle = ch.title,
sectionTitle = sec.title, sectionSlug = sec.slug,
slug = a.slug, sectionTitle = sec.title,
title = a.title, slug = a.slug,
bodyMd = a.bodyMd, title = a.title,
) bodyMd = a.bodyMd,
} )
}
if (limit <= 0) return all
return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE))
} }
@Transactional @Transactional
@@ -238,4 +392,15 @@ class ContentService(
bytes: ByteArray, bytes: ByteArray,
sync: Boolean = true, sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync) ): ContentImportResult = importer.importZip(filename, bytes, sync)
private companion object {
const val MAX_ARTICLE_PAGE = 500
}
} }
/**
* Единое правило связи файла с записью `article_assets`: совпадение имени файла,
* полного сохранённого пути или его basename.
*/
internal fun ArticleAsset.matchesAssetName(name: String): Boolean =
filename == name || storedPath == name || storedPath.substringAfterLast('/') == name

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@@ -12,7 +14,9 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.content.ArticlePlacement import ru.ysu.lms.academic.content.ArticlePlacement
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentAuthoringService import ru.ysu.lms.academic.content.ContentAuthoringService
import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentImportResult
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
@@ -25,6 +29,7 @@ import java.util.UUID
class ContentAdminController( class ContentAdminController(
private val contentService: ContentService, private val contentService: ContentService,
private val authoring: ContentAuthoringService, private val authoring: ContentAuthoringService,
private val audit: AuditClient,
) { ) {
data class ArticleUpdatePayload( data class ArticleUpdatePayload(
val title: String, val title: String,
@@ -55,6 +60,11 @@ class ContentAdminController(
data class ChapterCreatePayload( data class ChapterCreatePayload(
val title: String, val title: String,
val slug: String? = null, val slug: String? = null,
val audience: String? = null,
)
data class ChapterAudiencePayload(
val audience: String,
) )
data class SectionCreatePayload( data class SectionCreatePayload(
@@ -66,79 +76,140 @@ class ContentAdminController(
@GetMapping("/tree/all") @GetMapping("/tree/all")
fun treeAll() = contentService.treeAll() fun treeAll() = contentService.treeAll()
/** Файлы хранилища без связи со статьями — для очистки legacy-активов. */
@GetMapping("/legacy-assets")
fun legacyAssets(): List<String> = contentService.unlinkedAssetNames()
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */ /** Просмотр статьи без учёта публикации — для предпросмотра админом. */
@GetMapping("/all/{chapter}/{section}/{article}") @GetMapping("/all/{chapter}/{section}/{article}")
fun articleAll( fun articleAll(
@PathVariable chapter: String, @PathVariable chapter: String,
@PathVariable section: String, @PathVariable section: String,
@PathVariable article: String, @PathVariable article: String,
): ContentPublicController.ContentArticleViewDto { ): ContentPublicController.ContentArticleViewDto =
val a = contentService.findArticle(chapter, section, article, includeHidden = true) contentService
return ContentPublicController.ContentArticleViewDto( .findArticleForAdmin(chapter, section, article)
id = a.id, .toDto()
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@PutMapping("/chapters/{id}/published") @PutMapping("/chapters/{id}/published")
fun chapterPublished( fun chapterPublished(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: PublishedPayload, @RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
contentService.setChapterPublished(id, body.published) contentService.setChapterPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_PUBLISH",
ENTITY_CHAPTER,
id.toString(),
details = "published=${body.published}",
)
} }
@PutMapping("/sections/{id}/published") @PutMapping("/sections/{id}/published")
fun sectionPublished( fun sectionPublished(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: PublishedPayload, @RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
contentService.setSectionPublished(id, body.published) contentService.setSectionPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_SECTION_PUBLISH",
ENTITY_SECTION,
id.toString(),
details = "published=${body.published}",
)
} }
@PutMapping("/articles/{id}/published") @PutMapping("/articles/{id}/published")
fun articlePublished( fun articlePublished(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: PublishedPayload, @RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
contentService.setArticlePublished(id, body.published) contentService.setArticlePublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_PUBLISH",
ENTITY_ARTICLE,
id.toString(),
details = "published=${body.published}",
)
} }
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(value = "sync", defaultValue = "true") sync: Boolean, @RequestParam(value = "sync", defaultValue = "true") sync: Boolean,
@AuthenticationPrincipal jwt: Jwt,
): ContentImportResult { ): ContentImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") if (file.isEmpty) throw BadRequestException("Выберите файл")
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync) val result = contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync)
audit.record(
jwt.subject,
"CONTENT_IMPORT",
ENTITY_CONTENT,
null,
details = "chapters=${result.chapters}, sections=${result.sections}, articles=${result.articles}",
)
return result
} }
@GetMapping("/articles") @GetMapping("/articles")
fun list() = contentService.listAll() fun list(
@RequestParam(defaultValue = "0") offset: Int,
@RequestParam(defaultValue = "0") limit: Int,
) = contentService.listAll(offset, limit)
@PostMapping("/chapters") @PostMapping("/chapters")
fun createChapter( fun createChapter(
@RequestBody body: ChapterCreatePayload, @RequestBody body: ChapterCreatePayload,
) = authoring.createChapter(body.title, body.slug) @AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug, audience = body.audience).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
it.id,
details = "title=${body.title}, audience=${it.audience}",
)
}
@PutMapping("/chapters/{id}/audience")
fun chapterAudience(
@PathVariable id: UUID,
@RequestBody body: ChapterAudiencePayload,
@AuthenticationPrincipal jwt: Jwt,
) {
authoring.updateChapterAudience(id, body.audience)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_AUDIENCE",
ENTITY_CHAPTER,
id.toString(),
details = "audience=${body.audience}",
)
}
@PutMapping("/chapters/{id}") @PutMapping("/chapters/{id}")
fun renameChapter( fun renameChapter(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: TitlePayload, @RequestBody body: TitlePayload,
) = authoring.renameChapter(id, body.title) @AuthenticationPrincipal jwt: Jwt,
) {
authoring.renameChapter(id, body.title)
audit.record(jwt.subject, "CONTENT_CHAPTER_RENAME", ENTITY_CHAPTER, id.toString(), details = "title=${body.title}")
}
@DeleteMapping("/chapters/{id}") @DeleteMapping("/chapters/{id}")
fun deleteChapter( fun deleteChapter(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
authoring.deleteChapter(id) authoring.deleteChapter(id)
audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString())
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@@ -146,60 +217,107 @@ class ContentAdminController(
fun createSection( fun createSection(
@RequestParam chapterId: UUID, @RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload, @RequestBody body: SectionCreatePayload,
) = authoring.createSection(chapterId, body.title, body.slug) @AuthenticationPrincipal jwt: Jwt,
) = authoring.createSection(chapterId, body.title, body.slug).also {
audit.record(
jwt.subject,
"CONTENT_SECTION_CREATE",
ENTITY_SECTION,
it.id,
details = "title=${body.title}",
)
}
@PutMapping("/sections/{id}") @PutMapping("/sections/{id}")
fun renameSection( fun renameSection(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: TitlePayload, @RequestBody body: TitlePayload,
) = authoring.renameSection(id, body.title) @AuthenticationPrincipal jwt: Jwt,
) {
authoring.renameSection(id, body.title)
audit.record(jwt.subject, "CONTENT_SECTION_RENAME", ENTITY_SECTION, id.toString(), details = "title=${body.title}")
}
@DeleteMapping("/sections/{id}") @DeleteMapping("/sections/{id}")
fun deleteSection( fun deleteSection(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestParam(required = false) moveTo: UUID?, @RequestParam(required = false) moveTo: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
if (moveTo != null) { if (moveTo != null) {
authoring.moveArticlesAndDeleteSection(id, moveTo) authoring.moveArticlesAndDeleteSection(id, moveTo)
} else { } else {
authoring.deleteSection(id) authoring.deleteSection(id)
} }
audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString())
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@PostMapping("/articles") @PostMapping("/articles")
fun createArticle( fun createArticle(
@RequestBody body: ArticleCreatePayload, @RequestBody body: ArticleCreatePayload,
) = authoring.createArticlePlaced( @AuthenticationPrincipal jwt: Jwt,
ArticlePlacement( ): ContentArticleView {
title = body.title, val article =
bodyMd = body.bodyMd, authoring.createArticlePlaced(
chapterId = body.chapterId?.let { UUID.fromString(it) }, ArticlePlacement(
newChapterTitle = body.newChapterTitle, title = body.title,
sectionId = body.sectionId?.let { UUID.fromString(it) }, bodyMd = body.bodyMd,
newSectionTitle = body.newSectionTitle, chapterId = body.chapterId?.let { UUID.fromString(it) },
), newChapterTitle = body.newChapterTitle,
) sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
),
)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_CREATE",
ENTITY_ARTICLE,
article.id,
details = "title=${body.title}",
)
return article
}
@PutMapping("/articles/{id}") @PutMapping("/articles/{id}")
fun update( fun update(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload, @RequestBody body: ArticleUpdatePayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
authoring.updateArticle(id, body.title, body.bodyMd) authoring.updateArticle(id, body.title, body.bodyMd)
audit.record(jwt.subject, "CONTENT_ARTICLE_UPDATE", ENTITY_ARTICLE, id.toString(), details = "title=${body.title}")
} }
@PostMapping("/articles/{id}/move") @PostMapping("/articles/{id}/move")
fun move( fun move(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: ArticleMovePayload, @RequestBody body: ArticleMovePayload,
) = authoring.moveArticle(id, UUID.fromString(body.sectionId)) @AuthenticationPrincipal jwt: Jwt,
) = authoring.moveArticle(id, UUID.fromString(body.sectionId)).also {
audit.record(
jwt.subject,
"CONTENT_ARTICLE_MOVE",
ENTITY_ARTICLE,
it.id,
details = "sectionId=${body.sectionId}",
)
}
@DeleteMapping("/articles/{id}") @DeleteMapping("/articles/{id}")
fun delete( fun delete(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
authoring.deleteArticle(id) authoring.deleteArticle(id)
audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString())
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
private companion object {
private const val ENTITY_CONTENT = "CONTENT"
private const val ENTITY_CHAPTER = "CONTENT_CHAPTER"
private const val ENTITY_SECTION = "CONTENT_SECTION"
private const val ENTITY_ARTICLE = "CONTENT_ARTICLE"
}
} }

View File

@@ -3,42 +3,56 @@ package ru.ysu.lms.academic.controller
import org.springframework.core.io.FileSystemResource import org.springframework.core.io.FileSystemResource
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.AUDIENCE_ALL
import ru.ysu.lms.academic.content.AUDIENCE_STUDENTS
import ru.ysu.lms.academic.content.AUDIENCE_TEACHERS
import ru.ysu.lms.academic.content.ArticleAssetAccess
import ru.ysu.lms.academic.content.AssetStorage import ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/** Публичное чтение учебных материалов — доступно без аутентификации. */ /**
* Публичное чтение учебных материалов.
*
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
* С JWT дополнительно открываются STUDENTS для студента и TEACHERS для преподавателя;
* администратор видит все аудитории. Курсовые главы отдаются только курсовыми эндпоинтами.
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
* их может скачать только админ для очистки. Привязанные активы наследуют
* видимость статьи, раздела и главы.
*/
@RestController @RestController
@RequestMapping("/api/v1/content") @RequestMapping("/api/v1/content")
class ContentPublicController( class ContentPublicController(
private val contentService: ContentService, private val contentService: ContentService,
private val assetStorage: AssetStorage, private val assetStorage: AssetStorage,
private val accessService: CourseAccessService,
) { ) {
@GetMapping("/tree") @GetMapping("/tree")
fun tree() = contentService.tree() fun tree(
@AuthenticationPrincipal jwt: Jwt?,
) = contentService.tree(role = jwt?.getClaimAsString("role"))
@GetMapping("/{chapter}/{section}/{article}") @GetMapping("/{chapter}/{section}/{article}")
fun article( fun article(
@PathVariable chapter: String, @PathVariable chapter: String,
@PathVariable section: String, @PathVariable section: String,
@PathVariable article: String, @PathVariable article: String,
): ContentArticleViewDto { @AuthenticationPrincipal jwt: Jwt?,
val a = contentService.findArticle(chapter, section, article) ): ContentArticleViewDto =
return ContentArticleViewDto( contentService
id = a.id, .findArticle(chapter, section, article, role = jwt?.getClaimAsString("role"))
chapterSlug = a.chapterSlug, .toDto()
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
data class ContentArticleViewDto( data class ContentArticleViewDto(
val id: String, val id: String,
@@ -55,7 +69,14 @@ class ContentPublicController(
@GetMapping("/assets/{name}") @GetMapping("/assets/{name}")
fun asset( fun asset(
@PathVariable name: String, @PathVariable name: String,
@AuthenticationPrincipal jwt: Jwt?,
): ResponseEntity<FileSystemResource> { ): ResponseEntity<FileSystemResource> {
val accesses = contentService.assetAccesses(name)
if (accesses.isEmpty()) {
requireLegacyAssetAccess(jwt)
} else {
requireAssetAccess(accesses, jwt)
}
val (path, mime) = val (path, mime) =
assetStorage.read(name) assetStorage.read(name)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
@@ -75,4 +96,120 @@ class ContentPublicController(
builder.header("X-Content-Type-Options", "nosniff") builder.header("X-Content-Type-Options", "nosniff")
return builder.body(FileSystemResource(path)) return builder.body(FileSystemResource(path))
} }
/**
* Legacy-файл без связи со статьёй не публичен: аноним и обычный пользователь
* получают 404, чтобы не раскрывать существование файла. Админ может скачать
* его для очистки хранилища.
*/
private fun requireLegacyAssetAccess(jwt: Jwt?) {
if (jwt?.getClaimAsString("role") != CourseAccessService.ROLE_ADMIN) {
throw NotFoundException("Файл не найден")
}
}
/**
* Аноним видит только глобальный опубликованный актив аудитории ALL.
* Аутентифицированный — если видима хотя бы одна связанная статья.
* Скрытые материалы отдают 404, чтобы не раскрывать их существование.
*/
private fun requireAssetAccess(
accesses: List<ArticleAssetAccess>,
jwt: Jwt?,
) {
if (jwt == null) {
requireAnonymousAssetAccess(accesses)
} else {
requireAuthenticatedAssetAccess(accesses, jwt)
}
}
private fun requireAnonymousAssetAccess(accesses: List<ArticleAssetAccess>) {
if (accesses.any { it.isPublic }) return
throw NotFoundException("Файл не найден")
}
private fun requireAuthenticatedAssetAccess(
accesses: List<ArticleAssetAccess>,
jwt: Jwt,
) {
val role = jwt.getClaimAsString("role")
if (role == CourseAccessService.ROLE_ADMIN) return
var hidden = false
var denied = false
for (access in accesses) {
when (decision(access, role, jwt)) {
AssetDecision.ALLOW -> return
AssetDecision.HIDDEN -> hidden = true
AssetDecision.DENY -> denied = true
}
}
if (hidden && !denied) throw NotFoundException("Файл не найден")
throw ForbiddenException("Файл недоступен")
}
private fun decision(
access: ArticleAssetAccess,
role: String?,
jwt: Jwt,
): AssetDecision {
val courseId = access.courseId
return if (courseId != null) courseDecision(courseId, access, role, jwt) else globalDecision(access, role)
}
private fun courseDecision(
courseId: UUID,
access: ArticleAssetAccess,
role: String?,
jwt: Jwt,
): AssetDecision {
try {
accessService.requireCourseAccess(courseId, jwt)
} catch (_: ForbiddenException) {
return AssetDecision.DENY
}
return when {
role !in COURSE_ROLES -> AssetDecision.DENY
role == CourseAccessService.ROLE_STUDENT && !access.published -> AssetDecision.HIDDEN
else -> AssetDecision.ALLOW
}
}
private fun globalDecision(
access: ArticleAssetAccess,
role: String?,
): AssetDecision =
when {
!access.published -> AssetDecision.HIDDEN
access.audience == AUDIENCE_ALL && role in COURSE_ROLES -> AssetDecision.ALLOW
access.audience == AUDIENCE_STUDENTS && role == CourseAccessService.ROLE_STUDENT -> AssetDecision.ALLOW
access.audience == AUDIENCE_TEACHERS && role == CourseAccessService.ROLE_TEACHER -> AssetDecision.ALLOW
else -> AssetDecision.DENY
}
private val ArticleAssetAccess.isPublic: Boolean
get() = courseId == null && audience == AUDIENCE_ALL && published
private enum class AssetDecision {
ALLOW,
HIDDEN,
DENY,
}
private companion object {
val COURSE_ROLES = setOf(CourseAccessService.ROLE_STUDENT, CourseAccessService.ROLE_TEACHER)
}
} }
internal fun ContentArticleView.toDto(): ContentPublicController.ContentArticleViewDto =
ContentPublicController.ContentArticleViewDto(
id = id,
chapterSlug = chapterSlug,
chapterTitle = chapterTitle,
sectionSlug = sectionSlug,
sectionTitle = sectionTitle,
slug = slug,
title = title,
bodyMd = bodyMd,
published = published,
)

View File

@@ -0,0 +1,316 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.content.ArticlePlacement
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentAuthoringService
import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Авторский контент курса: администратор и преподаватель, назначенный на курс.
* Любая изменяемая сущность обязана принадлежать курсу из пути — чужие id
* отклоняются, чтобы нельзя было править материалы другого курса.
*/
@RestController
@RequestMapping("/api/v1/courses/{courseId}/content")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
class CourseContentAdminController(
private val contentService: ContentService,
private val authoring: ContentAuthoringService,
private val accessService: CourseAccessService,
private val courses: CourseRepository,
private val audit: AuditClient,
) {
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
data class TitlePayload(
val title: String,
)
data class ArticleCreatePayload(
val chapterId: String? = null,
val sectionId: String? = null,
val newChapterTitle: String? = null,
val newSectionTitle: String? = null,
val title: String,
val bodyMd: String? = null,
)
data class ArticleUpdatePayload(
val title: String,
val bodyMd: String,
)
data class PublishedPayload(
val published: Boolean,
)
@PostMapping("/chapters")
fun createChapter(
@PathVariable courseId: UUID,
@RequestBody body: ChapterCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug, requireAccess(courseId, jwt)).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
it.id,
courseId = courseId.toString(),
details = "title=${body.title}",
)
}
@PutMapping("/chapters/{id}")
fun renameChapter(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.renameChapter(id, body.title, courseId)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_RENAME",
ENTITY_CHAPTER,
id.toString(),
courseId = courseId.toString(),
details = "title=${body.title}",
)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/chapters/{id}")
fun deleteChapter(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.deleteChapter(id, courseId)
audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString(), courseId = courseId.toString())
return ResponseEntity.noContent().build()
}
@PostMapping("/sections")
fun createSection(
@PathVariable courseId: UUID,
@RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
) = authoring.createSection(chapterId, body.title, body.slug, requireAccess(courseId, jwt)).also {
audit.record(
jwt.subject,
"CONTENT_SECTION_CREATE",
ENTITY_SECTION,
it.id,
courseId = courseId.toString(),
details = "title=${body.title}",
)
}
@PutMapping("/sections/{id}")
fun renameSection(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.renameSection(id, body.title, courseId)
audit.record(
jwt.subject,
"CONTENT_SECTION_RENAME",
ENTITY_SECTION,
id.toString(),
courseId = courseId.toString(),
details = "title=${body.title}",
)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/sections/{id}")
fun deleteSection(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestParam(required = false) moveTo: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
if (moveTo != null) {
authoring.moveArticlesAndDeleteSection(id, moveTo, courseId)
} else {
authoring.deleteSection(id, courseId)
}
audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString(), courseId = courseId.toString())
return ResponseEntity.noContent().build()
}
@PostMapping("/articles")
fun createArticle(
@PathVariable courseId: UUID,
@RequestBody body: ArticleCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
): ContentArticleView {
val article =
authoring.createArticlePlaced(
ArticlePlacement(
title = body.title,
bodyMd = body.bodyMd ?: "",
chapterId = body.chapterId?.let { UUID.fromString(it) },
newChapterTitle = body.newChapterTitle,
sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
),
courseId = requireAccess(courseId, jwt),
)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_CREATE",
ENTITY_ARTICLE,
article.id,
courseId = courseId.toString(),
details = "title=${body.title}",
)
return article
}
@PutMapping("/articles/{id}")
fun updateArticle(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.updateArticle(id, body.title, body.bodyMd, courseId)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_UPDATE",
ENTITY_ARTICLE,
id.toString(),
courseId = courseId.toString(),
details = "title=${body.title}",
)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/articles/{id}")
fun deleteArticle(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.deleteArticle(id, courseId)
audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString(), courseId = courseId.toString())
return ResponseEntity.noContent().build()
}
@PutMapping("/chapters/{id}/published")
fun chapterPublished(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.requireChapterInCourse(id, courseId)
contentService.setChapterPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_PUBLISH",
ENTITY_CHAPTER,
id.toString(),
courseId = courseId.toString(),
details = "published=${body.published}",
)
return ResponseEntity.noContent().build()
}
@PutMapping("/sections/{id}/published")
fun sectionPublished(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.requireSectionInCourse(id, courseId)
contentService.setSectionPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_SECTION_PUBLISH",
ENTITY_SECTION,
id.toString(),
courseId = courseId.toString(),
details = "published=${body.published}",
)
return ResponseEntity.noContent().build()
}
@PutMapping("/articles/{id}/published")
fun articlePublished(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.requireArticleInCourse(id, courseId)
contentService.setArticlePublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_PUBLISH",
ENTITY_ARTICLE,
id.toString(),
courseId = courseId.toString(),
details = "published=${body.published}",
)
return ResponseEntity.noContent().build()
}
/**
* Проверяет курсовую область видимости и существование курса (для администратора,
* который проходит проверку доступа без запроса к БД). Возвращает id курса.
*/
private fun requireAccess(
courseId: UUID,
jwt: Jwt,
): UUID {
accessService.requireCourseAccess(courseId, jwt)
if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден")
return courseId
}
private companion object {
private const val ENTITY_CHAPTER = "CONTENT_CHAPTER"
private const val ENTITY_SECTION = "CONTENT_SECTION"
private const val ENTITY_ARTICLE = "CONTENT_ARTICLE"
}
}

View File

@@ -0,0 +1,52 @@
package ru.ysu.lms.academic.controller
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.content.ContentTree
import ru.ysu.lms.academic.course.CourseAccessService
import java.util.UUID
/**
* Материалы курса. Доступ проверяется по курсовой области видимости:
* администратор — любой курс, преподаватель — назначенный, студент — курс своей группы.
* Скрытые главы/разделы/статьи видят только администратор и преподаватель курса.
*/
@RestController
@RequestMapping("/api/v1/courses/{courseId}/content")
@PreAuthorize("isAuthenticated()")
class CourseContentController(
private val contentService: ContentService,
private val accessService: CourseAccessService,
) {
@GetMapping("/tree")
fun tree(
@PathVariable courseId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ContentTree {
accessService.requireCourseAccess(courseId, jwt)
return contentService.courseTree(courseId, includeHidden = canSeeHidden(jwt))
}
@GetMapping("/{chapter}/{section}/{article}")
fun article(
@PathVariable courseId: UUID,
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
@AuthenticationPrincipal jwt: Jwt,
): ContentPublicController.ContentArticleViewDto {
accessService.requireCourseAccess(courseId, jwt)
return contentService
.findCourseArticle(courseId, chapter, section, article, includeHidden = canSeeHidden(jwt))
.toDto()
}
private fun canSeeHidden(jwt: Jwt): Boolean =
jwt.getClaimAsString("role") in setOf(CourseAccessService.ROLE_ADMIN, CourseAccessService.ROLE_TEACHER)
}

View File

@@ -0,0 +1,237 @@
package ru.ysu.lms.academic.controller
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.academic.course.CourseBootstrapRequest
import ru.ysu.lms.academic.course.CourseBootstrapResult
import ru.ysu.lms.academic.course.CourseBootstrapService
import ru.ysu.lms.academic.course.CourseCreateRequest
import ru.ysu.lms.academic.course.CourseDetail
import ru.ysu.lms.academic.course.CourseGroupOption
import ru.ysu.lms.academic.course.CourseGroupRequest
import ru.ysu.lms.academic.course.CourseGroupRoster
import ru.ysu.lms.academic.course.CourseGroupService
import ru.ysu.lms.academic.course.CourseRosterService
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.course.CourseSummary
import ru.ysu.lms.academic.course.CourseTeacherRequest
import ru.ysu.lms.academic.course.CourseUpdateRequest
import ru.ysu.lms.academic.course.parseUuidOrBadRequest
import java.util.UUID
@RestController
@RequestMapping("/api/v1/courses")
@PreAuthorize("isAuthenticated()")
class CourseController(
private val courseService: CourseService,
private val bootstrapService: CourseBootstrapService,
private val accessService: CourseAccessService,
private val rosterService: CourseRosterService,
private val courseGroupService: CourseGroupService,
private val audit: AuditClient,
) {
@GetMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun list(): List<CourseSummary> = courseService.list()
/** Курсы текущего пользователя: все для admin, назначенные/по группам для остальных. */
@GetMapping("/mine")
fun mine(
@AuthenticationPrincipal jwt: Jwt,
): List<CourseSummary> = accessService.myCourses(jwt)
@PostMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun create(
@RequestBody body: CourseCreateRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.create(body, ownerAccountId(jwt))
audit.record(jwt.subject, "COURSE_CREATE", ENTITY_COURSE, course.id, courseId = course.id)
return course
}
@GetMapping("/{id}")
fun detail(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
accessService.requireCourseAccess(id, jwt)
return courseService.detail(id)
}
/** Курс по человекочитаемому номеру из URL (`course_no`). */
@GetMapping("/by-no/{courseNo}")
fun detailByNo(
@PathVariable courseNo: Long,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.findByCourseNo(courseNo)
accessService.requireCourseAccess(UUID.fromString(course.id), jwt)
return course
}
/** Состав группы, активно привязанной к курсу. */
@GetMapping("/{courseId}/groups/{groupId}/students")
@PreAuthorize("isAuthenticated()")
fun groupStudents(
@PathVariable courseId: UUID,
@PathVariable groupId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): CourseGroupRoster = rosterService.groupRoster(courseId, groupId, jwt)
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun update(
@PathVariable id: UUID,
@RequestBody body: CourseUpdateRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.update(id, body)
val details =
listOfNotNull(
body.title?.let { "title=$it" },
body.status?.let { "status=$it" },
).joinToString(", ")
audit.record(jwt.subject, "COURSE_UPDATE", ENTITY_COURSE, course.id, courseId = course.id, details = details)
return course
}
@PostMapping("/{id}/teachers")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun assignTeacher(
@PathVariable id: UUID,
@RequestBody body: CourseTeacherRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.assignTeacher(id, body)
audit.record(
jwt.subject,
"COURSE_TEACHER_ASSIGN",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "accountId=${body.accountId}",
)
return course
}
@DeleteMapping("/{id}/teachers/{accountId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun removeTeacher(
@PathVariable id: UUID,
@PathVariable accountId: String,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.removeTeacher(id, accountId)
audit.record(
jwt.subject,
"COURSE_TEACHER_REMOVE",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "accountId=$accountId",
)
return course
}
@PostMapping("/{id}/groups")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun attachGroup(
@PathVariable id: UUID,
@RequestBody body: CourseGroupRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
accessService.requireCourseAccess(id, jwt)
courseGroupService.requireAttachable(body.groupId)
val course = courseService.attachGroup(id, body)
audit.record(
jwt.subject,
"COURSE_GROUP_ATTACH",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "groupId=${body.groupId}",
)
return course
}
@DeleteMapping("/{id}/groups/{groupId}")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun detachGroup(
@PathVariable id: UUID,
@PathVariable groupId: String,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
accessService.requireCourseAccess(id, jwt)
val course = courseService.detachGroup(id, groupId)
audit.record(
jwt.subject,
"COURSE_GROUP_DETACH",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "groupId=$groupId",
)
return course
}
/** Активные группы курса с именами из identity — одним запросом. */
@GetMapping("/{id}/groups")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun attachedGroups(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<CourseGroupOption> {
accessService.requireCourseAccess(id, jwt)
return courseGroupService.attachedGroups(id)
}
/** Активные группы identity, ещё не привязанные к курсу. */
@GetMapping("/{id}/available-groups")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun availableGroups(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<CourseGroupOption> {
accessService.requireCourseAccess(id, jwt)
return courseGroupService.availableGroups(id)
}
@PostMapping("/bootstrap")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun bootstrap(
@RequestBody body: CourseBootstrapRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseBootstrapResult {
val result = bootstrapService.bootstrap(body, ownerAccountId(jwt))
audit.record(
jwt.subject,
"COURSE_BOOTSTRAP",
ENTITY_COURSE,
result.courseId,
courseId = result.courseId,
details =
"groups=${result.attachedGroups}, labs=${result.assignedLabs}, " +
"chapters=${result.assignedChapters}",
)
return result
}
private fun ownerAccountId(jwt: Jwt): UUID = parseUuidOrBadRequest(jwt.subject ?: "", "идентификатор пользователя в токене")
private companion object {
private const val ENTITY_COURSE = "COURSE"
}
}

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@@ -12,6 +14,9 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.academic.lab.LabCopyRequest
import ru.ysu.lms.academic.lab.LabCreateRequest import ru.ysu.lms.academic.lab.LabCreateRequest
import ru.ysu.lms.academic.lab.LabDetail import ru.ysu.lms.academic.lab.LabDetail
import ru.ysu.lms.academic.lab.LabImportResult import ru.ysu.lms.academic.lab.LabImportResult
@@ -26,99 +31,257 @@ import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/labs") @RequestMapping("/api/v1/labs")
@PreAuthorize("isAuthenticated()")
class LabController( class LabController(
private val labService: LabService, private val labService: LabService,
private val accessService: CourseAccessService,
private val audit: AuditClient,
) { ) {
/**
* Без `courseId` список ограничен доступными курсами (admin видит все).
* С `courseId` доступ к курсу проверяется явно.
*/
@GetMapping @GetMapping
fun list(): List<LabSummary> = labService.list() fun list(
@RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): List<LabSummary> {
val student = accessService.isStudent(jwt)
if (courseId != null) {
accessService.requireCourseAccess(courseId, jwt)
return labService.list(courseId).filter { !student || it.status == "published" }
}
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
return labService.list(accessible).filter { !student || it.status == "published" }
}
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
@GetMapping("/details")
fun details(
@RequestParam courseId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<LabDetail> {
accessService.requireCourseAccess(courseId, jwt)
val details = labService.details(courseId)
return if (accessService.isStudent(jwt)) details.filter { it.status == "published" } else details
}
@GetMapping("/{id}") @GetMapping("/{id}")
fun detail( fun detail(
@PathVariable id: UUID, @PathVariable id: UUID,
): LabDetail = labService.detail(id) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
return labService.detail(id)
}
/** Копирование: нужен доступ и к исходной лабораторной, и к целевому курсу. */
@PostMapping("/{id}/copy")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun copy(
@PathVariable id: UUID,
@Valid @RequestBody body: LabCopyRequest,
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
accessService.requireCourseAccess(body.courseId, jwt)
val copy = labService.copy(id, body)
audit.record(
jwt.subject,
"LAB_COPY",
ENTITY_LAB,
copy.id,
courseId = copy.courseId,
details = "sourceLabId=$id",
)
return copy
}
@PostMapping("/import") @PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun importLab( fun importLab(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): LabImportResult { ): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes) val targetCourseId = requireWritableCourse(courseId, jwt)
val result = labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId)
audit.record(
jwt.subject,
"LAB_IMPORT",
ENTITY_LAB,
result.labId,
courseId = targetCourseId?.toString(),
details = "levels=${result.levelCount}, variants=${result.variantCount}",
)
return result
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun delete( fun delete(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
accessService.requireLabAccess(id, jwt)
labService.delete(id) labService.delete(id)
audit.record(jwt.subject, "LAB_DELETE", ENTITY_LAB, id.toString())
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun update( fun update(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: LabUpdateRequest, @Valid @RequestBody body: LabUpdateRequest,
): LabDetail = labService.update(id, body) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
val lab = labService.update(id, body)
audit.record(
jwt.subject,
"LAB_UPDATE",
ENTITY_LAB,
lab.id,
courseId = lab.courseId,
details = "status=${lab.status}",
)
return lab
}
@PostMapping("/{id}/variants") @PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun addVariant( fun addVariant(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: VariantSaveRequest, @Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.addVariant(id, body) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
val lab = labService.addVariant(id, body)
audit.record(jwt.subject, "LAB_VARIANT_ADD", ENTITY_LAB, id.toString(), details = "number=${body.number}")
return lab
}
@PutMapping("/{id}/variants/{variantId}") @PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun updateVariant( fun updateVariant(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable variantId: UUID, @PathVariable variantId: UUID,
@Valid @RequestBody body: VariantSaveRequest, @Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.updateVariant(id, variantId, body) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
val lab = labService.updateVariant(id, variantId, body)
audit.record(
jwt.subject,
"LAB_VARIANT_UPDATE",
ENTITY_LAB,
id.toString(),
details = "variantId=$variantId, number=${body.number}",
)
return lab
}
@DeleteMapping("/{id}/variants/{variantId}") @DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun deleteVariant( fun deleteVariant(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable variantId: UUID, @PathVariable variantId: UUID,
): LabDetail = labService.deleteVariant(id, variantId) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
val lab = labService.deleteVariant(id, variantId)
audit.record(jwt.subject, "LAB_VARIANT_DELETE", ENTITY_LAB, id.toString(), details = "variantId=$variantId")
return lab
}
@PostMapping("/{id}/toggle-status") @PostMapping("/{id}/toggle-status")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun toggleStatus( fun toggleStatus(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
accessService.requireLabAccess(id, jwt)
labService.toggleStatus(id) labService.toggleStatus(id)
audit.record(jwt.subject, "LAB_PUBLISH", ENTITY_LAB, id.toString())
} }
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun create( fun create(
@Valid @RequestBody body: LabCreateRequest, @Valid @RequestBody body: LabCreateRequest,
): LabDetail = labService.create(body) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
requireWritableCourse(body.courseId, jwt)
val lab = labService.create(body)
audit.record(
jwt.subject,
"LAB_CREATE",
ENTITY_LAB,
lab.id,
courseId = lab.courseId,
details = "title=${lab.title}",
)
return lab
}
@PostMapping("/{id}/levels") @PostMapping("/{id}/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun addLevel( fun addLevel(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: LevelSaveRequest, @Valid @RequestBody body: LevelSaveRequest,
@AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt)
labService.addLevel(id, body) labService.addLevel(id, body)
audit.record(jwt.subject, "LAB_LEVEL_ADD", ENTITY_LAB, id.toString(), details = "code=${body.code}")
return labService.detail(id) return labService.detail(id)
} }
@PutMapping("/{id}/levels/{levelId}") @PutMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun renameLevel( fun renameLevel(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable levelId: UUID, @PathVariable levelId: UUID,
@Valid @RequestBody body: LevelRenameRequest, @Valid @RequestBody body: LevelRenameRequest,
): LabDetail = labService.renameLevel(id, levelId, body) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
val lab = labService.renameLevel(id, levelId, body)
audit.record(jwt.subject, "LAB_LEVEL_UPDATE", ENTITY_LAB, id.toString(), details = "levelId=$levelId")
return lab
}
@DeleteMapping("/{id}/levels/{levelId}") @DeleteMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun deleteLevel( fun deleteLevel(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable levelId: UUID, @PathVariable levelId: UUID,
): LabDetail = labService.deleteLevel(id, levelId) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
accessService.requireLabAccess(id, jwt)
val lab = labService.deleteLevel(id, levelId)
audit.record(jwt.subject, "LAB_LEVEL_DELETE", ENTITY_LAB, id.toString(), details = "levelId=$levelId")
return lab
}
/**
* Для записи не-администратор обязан явно указать курс: legacy-выбор единственного курса
* остаётся только для администратора. Возвращает проверенный курс (или null для admin).
*/
private fun requireWritableCourse(
courseId: UUID?,
jwt: Jwt,
): UUID? {
if (courseId != null) {
accessService.requireCourseAccess(courseId, jwt)
return courseId
}
if (!accessService.isAdmin(jwt)) throw BadRequestException("Укажите курс")
return null
}
private companion object {
private const val ENTITY_LAB = "LAB"
}
} }

View File

@@ -0,0 +1,109 @@
package ru.ysu.lms.academic.course
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Service
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Курсовая область видимости для не-администраторов.
*
* Администратор видит все курсы, включая архивные. Преподаватель — только курсы с активным
* назначением `course_teachers`; студент — только активные курсы групп, в которых он состоит
* сейчас (по данным identity-service). Архивный курс для не-администратора недоступен.
*/
@Service
class CourseAccessService(
private val courses: CourseRepository,
private val teachers: CourseTeacherRepository,
private val groups: CourseGroupRepository,
private val labs: LabRepository,
private val identity: IdentityClient,
private val courseService: CourseService,
) {
/**
* `null` — администратор, ограничений нет; пустое множество — доступных курсов нет.
* Набор содержит только активные курсы.
*/
fun accessibleCourseIds(jwt: Jwt): Set<UUID>? =
when (role(jwt)) {
ROLE_ADMIN -> null
ROLE_TEACHER -> {
val assignments = teachers.findByAccountIdAndRemovedAtIsNull(requireAccountId(jwt))
activeCourseIds(assignments.mapNotNull { it.course?.id })
}
ROLE_STUDENT -> {
val groupIds = identity.currentGroupIds(jwt.tokenValue)
if (groupIds.isEmpty()) {
emptySet()
} else {
activeCourseIds(groups.findByGroupIdInAndRemovedAtIsNull(groupIds).mapNotNull { it.course?.id })
}
}
else -> emptySet()
}
fun requireCourseAccess(
courseId: UUID,
jwt: Jwt,
) {
val allowed = accessibleCourseIds(jwt) ?: return
if (courseId !in allowed) throw ForbiddenException("Курс недоступен")
}
/**
* Лаборатория без курса доступна только администратору: иначе её нельзя было бы
* сопоставить с курсовой областью видимости.
*/
fun requireLabAccess(
labId: UUID,
jwt: Jwt,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (role(jwt) == ROLE_STUDENT && lab.status != "published") {
// Черновик не существует для студента: не раскрываем его даже прямым UUID.
throw NotFoundException("Лабораторная не найдена")
}
val courseId = lab.course?.id
if (courseId == null) {
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
return
}
requireCourseAccess(courseId, jwt)
}
fun myCourses(jwt: Jwt): List<CourseSummary> {
val allowed = accessibleCourseIds(jwt) ?: return courseService.list()
if (allowed.isEmpty()) return emptyList()
val allowedIds = allowed.mapTo(HashSet()) { it.toString() }
return courseService.list().filter { it.id in allowedIds }
}
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
fun isStudent(jwt: Jwt): Boolean = role(jwt) == ROLE_STUDENT
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
if (courseIds.isEmpty()) return emptySet()
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
}
private fun requireAccountId(jwt: Jwt): UUID =
runCatching { UUID.fromString(jwt.subject) }
.getOrElse { throw ForbiddenException("Некорректный идентификатор пользователя") }
private fun role(jwt: Jwt): String? = jwt.getClaimAsString("role")
companion object {
const val ROLE_ADMIN = "ADMIN"
const val ROLE_TEACHER = "TEACHER"
const val ROLE_STUDENT = "STUDENT"
}
}

View File

@@ -0,0 +1,148 @@
package ru.ysu.lms.academic.course
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.common.ConflictException
import java.util.UUID
/**
* Единоразовый (но идемпотентный) перенос существующих лабораторных и глав
* в курс «Промышленное программирование на языке C».
*/
@Service
class CourseBootstrapService(
private val courses: CourseRepository,
private val courseGroups: CourseGroupRepository,
private val courseService: CourseService,
private val jdbc: JdbcTemplate,
) {
@Transactional
fun bootstrap(
request: CourseBootstrapRequest,
ownerAccountId: UUID,
): CourseBootstrapResult {
// Сериализуем конкурентные bootstrap-запросы в рамках транзакции.
jdbc.execute("SELECT pg_advisory_xact_lock(hashtext('academic.course.bootstrap'))")
// Сначала проверяем все идентификаторы, чтобы не создать курс при битом запросе.
val groupIds =
request.groupIds
.map { parseUuidOrBadRequest(it, "идентификатор группы") }
.distinct()
val title = request.title?.trim()?.takeIf { it.isNotEmpty() } ?: DEFAULT_TITLE
val course = findOrCreateCourse(title, ownerAccountId)
val attachedGroups = attachGroups(course, groupIds)
val duplicateNumbers = findDuplicateLabNumbers()
if (duplicateNumbers.isNotEmpty()) {
throw ConflictException(
"Нельзя выполнить bootstrap: дубли номеров лабораторных без курса: ${duplicateNumbers.joinToString(", ")}",
)
}
val assignedLabs = assignLabs(course.id)
val assignedChapters = assignChapters(course.id)
val labsWithoutCourse = countLabsWithoutCourse()
val chaptersWithoutCourse = countChaptersWithoutCourse()
if (labsWithoutCourse == 0L && isLabsCourseIdNullable()) {
jdbc.execute("ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL")
}
return CourseBootstrapResult(
courseId = course.id.toString(),
courseNo = course.courseNo ?: courseService.loadCourseNo(course.id),
title = course.title,
attachedGroups = attachedGroups,
assignedLabs = assignedLabs,
assignedChapters = assignedChapters,
labsWithoutCourse = labsWithoutCourse,
chaptersWithoutCourse = chaptersWithoutCourse,
)
}
private fun findOrCreateCourse(
title: String,
ownerAccountId: UUID,
): Course {
courses.findByTitleIgnoreCase(title)?.let { return it }
return try {
courses
.saveAndFlush(Course(title = title, ownerAccountId = ownerAccountId))
.also { it.courseNo = courseService.loadCourseNo(it.id) }
} catch (e: DataIntegrityViolationException) {
// Конкурентный bootstrap мог вставить курс между SELECT и INSERT.
courses.findByTitleIgnoreCase(title) ?: throw e
}
}
private fun attachGroups(
course: Course,
groupIds: List<UUID>,
): Int {
var attached = 0
groupIds.forEach { groupId ->
if (courseGroups.findByCourseIdAndGroupIdAndRemovedAtIsNull(course.id, groupId) == null) {
courseGroups.save(CourseGroup(course = course, groupId = groupId))
attached += 1
}
}
return attached
}
private fun assignLabs(courseId: UUID): Int =
try {
jdbc.update("UPDATE academic.labs SET course_id = ? WHERE course_id IS NULL", courseId)
} catch (e: DataIntegrityViolationException) {
throw ConflictException(
"Не удалось привязать лабораторные к курсу: есть дубли номеров. Устраните их и повторите bootstrap.",
).apply { initCause(e) }
}
private fun assignChapters(courseId: UUID): Int =
jdbc.update(
"UPDATE academic.chapters SET course_id = ?, audience = NULL WHERE course_id IS NULL",
courseId,
)
private fun countLabsWithoutCourse(): Long =
jdbc.queryForObject("SELECT count(*) FROM academic.labs WHERE course_id IS NULL", Long::class.java) ?: 0L
private fun findDuplicateLabNumbers(): List<Int> =
jdbc
.queryForList(
"""
SELECT number
FROM academic.labs
WHERE course_id IS NULL
GROUP BY number
HAVING count(*) > 1
ORDER BY number
""".trimIndent(),
Int::class.java,
).mapNotNull { it }
private fun countChaptersWithoutCourse(): Long =
jdbc.queryForObject("SELECT count(*) FROM academic.chapters WHERE course_id IS NULL", Long::class.java) ?: 0L
private fun isLabsCourseIdNullable(): Boolean {
val nullable =
jdbc.queryForObject(
"""
SELECT is_nullable
FROM information_schema.columns
WHERE table_schema = 'academic'
AND table_name = 'labs'
AND column_name = 'course_id'
""".trimIndent(),
String::class.java,
)
return nullable == "YES"
}
companion object {
const val DEFAULT_TITLE = "Промышленное программирование на языке C"
}
}

View File

@@ -0,0 +1,76 @@
package ru.ysu.lms.academic.course
data class CourseSummary(
val id: String,
val courseNo: Long,
val title: String,
val ownerAccountId: String,
val status: String,
val teacherCount: Long,
val groupCount: Long,
val labCount: Long,
)
data class CourseTeacherDto(
val accountId: String,
val assignedAt: String,
val removedAt: String? = null,
)
data class CourseGroupDto(
val groupId: String,
val addedAt: String,
val removedAt: String? = null,
)
data class CourseDetail(
val id: String,
val courseNo: Long,
val title: String,
val ownerAccountId: String,
val status: String,
val createdAt: String,
val updatedAt: String,
val teachers: List<CourseTeacherDto>,
val groups: List<CourseGroupDto>,
)
data class CourseCreateRequest(
val title: String? = null,
)
data class CourseUpdateRequest(
val title: String? = null,
val status: String? = null,
)
data class CourseTeacherRequest(
val accountId: String,
)
data class CourseGroupRequest(
val groupId: String,
)
/** Группа курса с именем и статусом из identity: привязанная или доступная для привязки. */
data class CourseGroupOption(
val id: String,
val name: String,
val status: String,
)
data class CourseBootstrapRequest(
val groupIds: List<String> = emptyList(),
val title: String? = null,
)
data class CourseBootstrapResult(
val courseId: String,
val courseNo: Long,
val title: String,
val attachedGroups: Int,
val assignedLabs: Int,
val assignedChapters: Int,
val labsWithoutCourse: Long,
val chaptersWithoutCourse: Long,
)

View File

@@ -0,0 +1,65 @@
package ru.ysu.lms.academic.course
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.identity.IdentityGroupSummary
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Выбор групп для привязки к курсу. Источник групп — identity-service, а активные
* привязки курса хранятся в academic. Уже привязанные и неактивные группы не предлагаются.
*/
@Service
class CourseGroupService(
private val courses: CourseRepository,
private val groups: CourseGroupRepository,
private val identity: IdentityClient,
) {
@Transactional(readOnly = true)
fun availableGroups(courseId: UUID): List<CourseGroupOption> {
if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден")
val attachedIds = groups.findByCourseIdAndRemovedAtIsNull(courseId).mapTo(HashSet()) { it.groupId.toString() }
return identity
.listGroups()
.filter { it.status == STATUS_ACTIVE && it.id !in attachedIds }
.sortedWith(compareBy<IdentityGroupSummary> { it.name.lowercase() }.thenBy { it.id })
.map { CourseGroupOption(id = it.id, name = it.name, status = it.status) }
}
/**
* Активно привязанные к курсу группы с именами из identity. Один вызов вместо
* запроса имени на каждую привязку. Статус не фильтруется: если группа стала
* неактивной, она остаётся видимой в привязках курса.
*/
@Transactional(readOnly = true)
fun attachedGroups(courseId: UUID): List<CourseGroupOption> {
if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден")
val attachedIds = groups.findByCourseIdAndRemovedAtIsNull(courseId).mapTo(HashSet()) { it.groupId.toString() }
return identity
.listGroups()
.filter { it.id in attachedIds }
.sortedWith(compareBy<IdentityGroupSummary> { it.name.lowercase() }.thenBy { it.id })
.map { CourseGroupOption(id = it.id, name = it.name, status = it.status) }
}
/** Привязывать можно только существующую активную группу identity. */
@Transactional(readOnly = true)
fun requireAttachable(groupIdValue: String) {
val groupId = parseUuidOrBadRequest(groupIdValue, "идентификатор группы").toString()
val group =
identity
.listGroups()
.firstOrNull { it.id == groupId }
?: throw NotFoundException("Группа не найдена")
if (group.status != STATUS_ACTIVE) throw BadRequestException("Группа неактивна")
}
private companion object {
const val STATUS_ACTIVE = "ACTIVE"
}
}

View File

@@ -0,0 +1,15 @@
package ru.ysu.lms.academic.course
/** Состав группы в разрезе курса — данные берутся из identity, привязка проверяется в academic. */
data class CourseGroupRoster(
val groupId: String,
val groupName: String,
val students: List<CourseGroupStudent>,
)
data class CourseGroupStudent(
val id: String,
val studentNo: String,
val fullName: String,
val status: String,
)

View File

@@ -0,0 +1,44 @@
package ru.ysu.lms.academic.course
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Service
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.common.ForbiddenException
import java.util.UUID
/**
* Состав группы, привязанной к курсу. Доступ к курсу проверяется той же курсовой
* областью видимости, что и у остальных academic-эндпоинтов; сама группа должна быть
* активной привязкой курса.
*/
@Service
class CourseRosterService(
private val accessService: CourseAccessService,
private val groups: CourseGroupRepository,
private val identity: IdentityClient,
) {
fun groupRoster(
courseId: UUID,
groupId: UUID,
jwt: Jwt,
): CourseGroupRoster {
accessService.requireCourseAccess(courseId, jwt)
groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId)
?: throw ForbiddenException("Группа не привязана к курсу")
val roster = identity.getGroupRoster(groupId.toString())
return CourseGroupRoster(
groupId = roster.id,
groupName = roster.name,
students =
roster.students.map {
CourseGroupStudent(
id = it.id,
studentNo = it.studentNo,
fullName = it.fullName,
status = it.status,
)
},
)
}
}

View File

@@ -0,0 +1,193 @@
package ru.ysu.lms.academic.course
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.time.Instant
import java.util.UUID
@Service
class CourseService(
private val courses: CourseRepository,
private val teachers: CourseTeacherRepository,
private val groups: CourseGroupRepository,
private val labs: LabRepository,
private val jdbc: JdbcTemplate,
) {
@Transactional(readOnly = true)
fun list(): List<CourseSummary> =
courses.findAllByOrderByCourseNoAsc().map { course ->
CourseSummary(
id = course.id.toString(),
courseNo = requireCourseNo(course),
title = course.title,
ownerAccountId = course.ownerAccountId.toString(),
status = course.status,
teacherCount = teachers.countByCourseIdAndRemovedAtIsNull(course.id),
groupCount = groups.countByCourseIdAndRemovedAtIsNull(course.id),
labCount = labs.countByCourseId(course.id),
)
}
@Transactional
fun create(
request: CourseCreateRequest,
ownerAccountId: UUID,
): CourseDetail {
val title = normalizeTitle(request.title) ?: DEFAULT_TITLE
if (courses.findByTitleIgnoreCase(title) != null) {
throw ConflictException("Курс с названием «$title» уже существует")
}
val course =
try {
courses.saveAndFlush(Course(title = title, ownerAccountId = ownerAccountId))
} catch (e: DataIntegrityViolationException) {
// Конкурентная вставка между проверкой и saveAndFlush ловится unique-индексом.
throw ConflictException("Курс с названием «$title» уже существует").apply { initCause(e) }
}
course.courseNo = loadCourseNo(course.id)
return toDetail(course)
}
@Transactional(readOnly = true)
fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId))
@Transactional(readOnly = true)
fun findByCourseNo(courseNo: Long): CourseDetail =
toDetail(courses.findByCourseNo(courseNo) ?: throw NotFoundException("Курс не найден"))
@Transactional
fun update(
courseId: UUID,
request: CourseUpdateRequest,
): CourseDetail {
val course = findCourse(courseId)
request.title?.let {
course.title = normalizeTitle(it) ?: throw BadRequestException("Название курса не может быть пустым")
}
request.status?.let { course.status = normalizeStatus(it) }
course.updatedAt = Instant.now()
courses.save(course)
return toDetail(course)
}
@Transactional
fun assignTeacher(
courseId: UUID,
request: CourseTeacherRequest,
): CourseDetail {
val course = findActiveCourse(courseId)
val accountId = parseUuidOrBadRequest(request.accountId, "идентификатор преподавателя")
if (teachers.findByCourseIdAndAccountIdAndRemovedAtIsNull(course.id, accountId) == null) {
teachers.save(CourseTeacher(course = course, accountId = accountId))
}
return toDetail(course)
}
@Transactional
fun removeTeacher(
courseId: UUID,
accountIdValue: String,
): CourseDetail {
val course = findCourse(courseId)
val accountId = parseUuidOrBadRequest(accountIdValue, "идентификатор преподавателя")
val assignment =
teachers.findByCourseIdAndAccountIdAndRemovedAtIsNull(course.id, accountId)
?: throw NotFoundException("Преподаватель не назначен на курс")
assignment.removedAt = Instant.now()
teachers.save(assignment)
return toDetail(course)
}
@Transactional
fun attachGroup(
courseId: UUID,
request: CourseGroupRequest,
): CourseDetail {
val course = findActiveCourse(courseId)
val groupId = parseUuidOrBadRequest(request.groupId, "идентификатор группы")
if (groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(course.id, groupId) == null) {
groups.save(CourseGroup(course = course, groupId = groupId))
}
return toDetail(course)
}
@Transactional
fun detachGroup(
courseId: UUID,
groupIdValue: String,
): CourseDetail {
val course = findCourse(courseId)
val groupId = parseUuidOrBadRequest(groupIdValue, "идентификатор группы")
val assignment =
groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(course.id, groupId)
?: throw NotFoundException("Группа не привязана к курсу")
assignment.removedAt = Instant.now()
groups.save(assignment)
return toDetail(course)
}
internal fun loadCourseNo(courseId: UUID): Long =
jdbc.queryForObject("SELECT course_no FROM academic.courses WHERE id = ?", Long::class.java, courseId)
?: error("course_no не сгенерирован для курса $courseId")
private fun findCourse(courseId: UUID): Course = courses.findById(courseId).orElseThrow { NotFoundException("Курс не найден") }
private fun findActiveCourse(courseId: UUID): Course {
val course = findCourse(courseId)
if (course.status != Course.STATUS_ACTIVE) throw BadRequestException("Курс архивирован")
return course
}
private fun toDetail(course: Course): CourseDetail =
CourseDetail(
id = course.id.toString(),
courseNo = requireCourseNo(course),
title = course.title,
ownerAccountId = course.ownerAccountId.toString(),
status = course.status,
createdAt = course.createdAt.toString(),
updatedAt = course.updatedAt.toString(),
teachers =
teachers.findByCourseIdOrderByAssignedAtAsc(course.id).map {
CourseTeacherDto(
accountId = it.accountId.toString(),
assignedAt = it.assignedAt.toString(),
removedAt = it.removedAt?.toString(),
)
},
groups =
groups.findByCourseIdOrderByAddedAtAsc(course.id).map {
CourseGroupDto(
groupId = it.groupId.toString(),
addedAt = it.addedAt.toString(),
removedAt = it.removedAt?.toString(),
)
},
)
private fun requireCourseNo(course: Course): Long = checkNotNull(course.courseNo) { "course_no не загружен для курса ${course.id}" }
private fun normalizeTitle(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() }
private fun normalizeStatus(value: String): String {
val status = value.trim().uppercase()
if (status !in Course.STATUSES) throw BadRequestException("Недопустимый статус курса: $value")
return status
}
companion object {
const val DEFAULT_TITLE = "Новый курс"
}
}

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.academic.course
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
/** Разбирает UUID из строкового поля запроса, превращая ошибку формата в 400. */
internal fun parseUuidOrBadRequest(
value: String,
what: String,
): UUID =
runCatching { UUID.fromString(value.trim()) }
.getOrElse { throw BadRequestException("Некорректный $what") }

View File

@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import ru.ysu.lms.common.Ids import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID import java.util.UUID
@Entity @Entity
@@ -12,9 +13,11 @@ import java.util.UUID
class ContentChapter( class ContentChapter(
@Id var id: UUID = Ids.v7(), @Id var id: UUID = Ids.v7(),
@Column(nullable = false, length = 200) var title: String = "", @Column(nullable = false, length = 200) var title: String = "",
@Column(nullable = false, unique = true, length = 200) var slug: String = "", @Column(nullable = false, length = 200) var slug: String = "",
@Column(nullable = false) var position: Int = 0, @Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true, @Column(nullable = false) var published: Boolean = true,
@Column(name = "course_id") var courseId: UUID? = null,
@Column(length = 16) var audience: String? = null,
) )
@Entity @Entity
@@ -39,3 +42,19 @@ class ContentArticle(
@Column(nullable = false) var position: Int = 0, @Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true, @Column(nullable = false) var published: Boolean = true,
) )
/**
* Файл, на который ссылается статья. Видимость наследуется от статьи:
* `article_assets → articles → sections → chapters`.
*/
@Entity
@Table(name = "article_assets")
class ArticleAsset(
@Id var id: UUID = Ids.v7(),
@Column(name = "article_id") var articleId: UUID? = null,
@Column(nullable = false, length = 255) var filename: String = "",
@Column(name = "stored_path", nullable = false, length = 512) var storedPath: String = "",
@Column(nullable = false, length = 128) var mime: String = "",
@Column(nullable = false) var size: Long = 0,
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,72 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
/** Курс-дисциплина. `courseNo` — человекочитаемый номер для URL, выдаётся БД. */
@Entity
@Table(name = "courses")
class Course(
@Id
var id: UUID = Ids.v7(),
@Column(name = "course_no", insertable = false, updatable = false)
var courseNo: Long? = null,
@Column(nullable = false, length = 200)
var title: String = "",
@Column(name = "owner_account_id", nullable = false)
var ownerAccountId: UUID = UUID(0, 0),
@Column(nullable = false, length = 16)
var status: String = STATUS_ACTIVE,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now(),
) {
companion object {
const val STATUS_ACTIVE = "ACTIVE"
const val STATUS_ARCHIVED = "ARCHIVED"
val STATUSES = setOf(STATUS_ACTIVE, STATUS_ARCHIVED)
}
}
/** Назначение преподавателя на курс. Снятие — `removedAt`, история не удаляется. */
@Entity
@Table(name = "course_teachers")
class CourseTeacher(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "course_id", nullable = false)
var course: Course? = null,
@Column(name = "account_id", nullable = false)
var accountId: UUID = UUID(0, 0),
@Column(name = "assigned_at", nullable = false)
var assignedAt: Instant = Instant.now(),
@Column(name = "removed_at")
var removedAt: Instant? = null,
)
/** Привязка группы к курсу. Отвязка — `removedAt`, история не удаляется. */
@Entity
@Table(name = "course_groups")
class CourseGroup(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "course_id", nullable = false)
var course: Course? = null,
@Column(name = "group_id", nullable = false)
var groupId: UUID = UUID(0, 0),
@Column(name = "added_at", nullable = false)
var addedAt: Instant = Instant.now(),
@Column(name = "removed_at")
var removedAt: Instant? = null,
)

View File

@@ -23,6 +23,9 @@ class Lab(
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false) @JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null, var semester: Semester? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
var course: Course? = null,
@Column(name = "description_md", nullable = false) @Column(name = "description_md", nullable = false)
var descriptionMd: String = "", var descriptionMd: String = "",
@Column(name = "deadline_at") @Column(name = "deadline_at")

View File

@@ -0,0 +1,131 @@
package ru.ysu.lms.academic.identity
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import ru.ysu.lms.common.LmsException
import java.net.http.HttpClient
import java.time.Duration
import java.util.UUID
/**
* Клиент identity-service. Академическому сервису нужны текущие группы пользователя
* и состав группы по сервисному токену.
*/
@Service
class IdentityClient(
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
@Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String,
) {
private val client = restClient(baseUrl)
/** Идентификаторы групп, в которых пользователь состоит сейчас. */
fun currentGroupIds(token: String): List<UUID> =
try {
val groups =
client
.get()
.uri("/api/v1/groups/me")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.retrieve()
.body(GROUP_LIST) ?: emptyList()
groups.map { UUID.fromString(it.id) }
} catch (e: RestClientException) {
throw IdentityUnavailableException().apply { initCause(e) }
} catch (e: IllegalArgumentException) {
throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) }
}
/** Все группы identity для выбора при привязке к курсу. */
fun listGroups(): List<IdentityGroupSummary> =
try {
client
.get()
.uri("/api/v1/internal/groups")
.header(INTERNAL_TOKEN_HEADER, internalToken)
.retrieve()
.body(GROUP_SUMMARY_LIST) ?: emptyList()
} catch (e: RestClientException) {
throw IdentityUnavailableException().apply { initCause(e) }
}
/** Текущий состав группы через внутренний endpoint identity. */
fun getGroupRoster(groupId: String): GroupRosterInfo =
try {
client
.get()
.uri("/api/v1/internal/groups/{groupId}", groupId)
.header(INTERNAL_TOKEN_HEADER, internalToken)
.retrieve()
.body(GroupRosterInfo::class.java)
?: throw IdentityUnavailableException("Сервис идентификации вернул пустой ответ")
} catch (e: RestClientException) {
throw IdentityUnavailableException().apply { initCause(e) }
}
}
/**
* Identity недоступен или ответил ошибкой. Это не отказ в доступе пользователю,
* а сбой инфраструктуры, поэтому 502 вместо 403.
*/
class IdentityUnavailableException(
message: String = "Сервис идентификации недоступен",
) : LmsException(HttpStatus.BAD_GATEWAY, message, code = "IDENTITY_UNAVAILABLE")
@JsonIgnoreProperties(ignoreUnknown = true)
internal data class GroupRef(
val id: String = "",
)
/** Минимальный контракт `GET /api/v1/internal/groups`. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class IdentityGroupSummary(
val id: String = "",
val name: String = "",
val status: String = "",
)
/** Минимальный контракт `GET /api/v1/internal/groups/{groupId}`. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupRosterInfo(
val id: String = "",
val name: String = "",
val students: List<GroupRosterStudent> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupRosterStudent(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val status: String = "",
)
private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {}
private val GROUP_SUMMARY_LIST = object : ParameterizedTypeReference<List<IdentityGroupSummary>>() {}
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 30L
private fun restClient(baseUrl: String): RestClient =
RestClient
.builder()
.baseUrl(baseUrl)
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build(),
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
).build()

View File

@@ -0,0 +1,119 @@
package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Копирование лабораторной в другой курс. Копируются только описание, уровни и варианты;
* прогресс, сдачи и назначения студентов не переносятся. Копия создаётся черновиком.
*/
@Service
class LabCopyService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val courses: CourseRepository,
) {
@Transactional
fun copy(
sourceLabId: UUID,
request: LabCopyRequest,
): UUID {
val source = requireSource(sourceLabId)
val targetCourse = requireTargetCourse(request.courseId)
validateCopyTarget(source, targetCourse)
val number = request.number ?: source.number
if (number <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
val title = request.title?.trim()?.takeIf { it.isNotEmpty() } ?: source.title
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ensureNumberIsFree(number, targetCourse)
val copy =
labs.save(
Lab(
number = number,
title = title,
semester = source.semester,
course = targetCourse,
descriptionMd = source.descriptionMd,
deadlineAt = source.deadlineAt,
defenseAt = source.defenseAt,
pointsMax = source.pointsMax,
status = "draft",
),
)
copyLevelsAndVariants(source, copy)
return copy.id
}
private fun requireSource(labId: UUID): Lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
private fun requireTargetCourse(courseId: UUID): Course = courses.findById(courseId).orElseThrow { NotFoundException("Курс не найден") }
private fun validateCopyTarget(
source: Lab,
targetCourse: Course,
) {
if (source.course?.id == targetCourse.id) {
throw BadRequestException("Копирование в тот же курс не требуется")
}
if (targetCourse.status == Course.STATUS_ARCHIVED) {
throw BadRequestException("Курс архивирован")
}
}
private fun ensureNumberIsFree(
number: Int,
course: Course,
) {
if (labs.existsByNumberAndCourseId(number, course.id)) {
throw ConflictException("Лабораторная №$number уже существует в целевом курсе")
}
}
private fun copyLevelsAndVariants(
source: Lab,
copy: Lab,
) {
val sourceLevels = labLevels.findByLabIdOrderByPosition(source.id)
val copiedLevels =
sourceLevels.map { level ->
labLevels.save(
LabLevel(
lab = copy,
code = level.code,
title = level.title,
description = level.description,
position = level.position,
pointsMax = level.pointsMax,
),
)
}
val levelMapping = sourceLevels.zip(copiedLevels).associate { (from, to) -> from.id to to }
variantsRepo.findByLabIdOrderByNumber(source.id).forEach { variant ->
val sourceLevel = variant.labLevel ?: return@forEach
val targetLevel = levelMapping[sourceLevel.id] ?: return@forEach
variantsRepo.save(
Variant(
lab = copy,
labLevel = targetLevel,
number = variant.number,
title = variant.title,
statementMd = variant.statementMd,
position = variant.position,
),
)
}
}
}

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.academic.lab package ru.ysu.lms.academic.lab
import java.math.BigDecimal import java.math.BigDecimal
import java.util.UUID
data class LabMeta( data class LabMeta(
val number: Int, val number: Int,
@@ -52,6 +53,9 @@ data class LabSummary(
val defenseAt: String?, val defenseAt: String?,
val pointsMax: BigDecimal, val pointsMax: BigDecimal,
val variantCount: Int, val variantCount: Int,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
) )
data class LabDetail( data class LabDetail(
@@ -64,6 +68,9 @@ data class LabDetail(
val defenseAt: String?, val defenseAt: String?,
val pointsMax: BigDecimal, val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>, val levels: List<LabLevelDetail>,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
) )
data class LabLevelDetail( data class LabLevelDetail(
@@ -114,6 +121,13 @@ data class LabCreateRequest(
val status: String? = null, val status: String? = null,
val descriptionMd: String? = null, val descriptionMd: String? = null,
val levels: List<LevelSaveRequest> = emptyList(), val levels: List<LevelSaveRequest> = emptyList(),
val courseId: UUID? = null,
)
data class LabCopyRequest(
val courseId: UUID,
val number: Int? = null,
val title: String? = null,
) )
data class LevelRenameRequest( data class LevelRenameRequest(

View File

@@ -2,10 +2,12 @@ package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.Lab import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Semester import ru.ysu.lms.academic.domain.Semester
import ru.ysu.lms.academic.domain.Variant import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.LabLevelRepository import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository import ru.ysu.lms.academic.repo.SemesterRepository
@@ -20,27 +22,32 @@ import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.UUID import java.util.UUID
@Suppress("LongParameterList", "TooManyFunctions")
@Service @Service
class LabService( class LabService(
private val labs: LabRepository, private val labs: LabRepository,
private val labLevels: LabLevelRepository, private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository, private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository, private val semesters: SemesterRepository,
private val courses: CourseRepository,
private val copyService: LabCopyService,
private val parser: LabImportParser, private val parser: LabImportParser,
) { ) {
@Transactional @Transactional
fun importFromFile( fun importFromFile(
filename: String, filename: String,
bytes: ByteArray, bytes: ByteArray,
courseId: UUID? = null,
): LabImportResult { ): LabImportResult {
val bundle = parser.parse(filename, bytes) val bundle = parser.parse(filename, bytes)
val course = courses.resolveCourse(courseId)
val semester = val semester =
semesters.findFirstByActiveTrue() semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.") ?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) { if (labs.existsByNumberAndCourseId(bundle.meta.number, course.id)) {
throw ConflictException( throw ConflictException(
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.", "Лабораторная №${bundle.meta.number} уже существует в этом курсе. Удалите её перед повторным импортом.",
) )
} }
@@ -50,6 +57,7 @@ class LabService(
number = bundle.meta.number, number = bundle.meta.number,
title = bundle.meta.title, title = bundle.meta.title,
semester = semester, semester = semester,
course = course,
descriptionMd = bundle.descriptionMd, descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax, pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status, status = bundle.meta.status,
@@ -93,61 +101,42 @@ class LabService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun list(): List<LabSummary> = fun list(courseId: UUID? = null): List<LabSummary> {
labs.findAllByOrderByNumberAsc().map { lab -> val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id) return labList.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
LabSummary( }
id = lab.id.toString(),
number = lab.number, /** Список лабораторных только по доступным курсам (курсовая область видимости). */
title = lab.title, @Transactional(readOnly = true)
status = lab.status, fun list(courseIds: Collection<UUID>): List<LabSummary> {
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—", if (courseIds.isEmpty()) return emptyList()
deadlineAt = lab.deadlineAt?.toString(), return labs
defenseAt = lab.defenseAt?.toString(), .findAllByCourseIdInOrderByNumberAsc(courseIds)
pointsMax = lab.pointsMax, .map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
variantCount = variants.size, }
)
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail { fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id) return lab.toDetail(
return LabDetail( labLevels.findByLabIdOrderByPosition(lab.id),
id = lab.id.toString(), variantsRepo.findByLabIdOrderByNumber(lab.id),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsRepo
.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
) )
} }
/** Все лабораторные курса с уровнями и вариантами: три запроса вместо N+1. */
@Transactional(readOnly = true)
fun details(courseId: UUID): List<LabDetail> {
val labList = labs.findAllByCourseIdOrderByNumberAsc(courseId)
if (labList.isEmpty()) return emptyList()
val labIds = labList.map { it.id }
val levelsByLab = labLevels.findByLabIdInOrderByPosition(labIds).groupBy { it.lab?.id }
val variantsByLab = variantsRepo.findByLabIdInOrderByNumberAsc(labIds).groupBy { it.lab?.id }
return labList.map { lab ->
lab.toDetail(levelsByLab[lab.id].orEmpty(), variantsByLab[lab.id].orEmpty())
}
}
@Transactional @Transactional
fun delete(labId: UUID) { fun delete(labId: UUID) {
if (!labs.existsById(labId)) { if (!labs.existsById(labId)) {
@@ -283,9 +272,10 @@ class LabService(
@Transactional @Transactional
fun create(request: LabCreateRequest): LabDetail { fun create(request: LabCreateRequest): LabDetail {
validateCreate(request) validateCreate(request)
val course = courses.requireCourse(request.courseId)
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0) val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) { if (labs.existsByNumberAndCourseId(request.number ?: 0, course.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре") throw ConflictException("Лабораторная №${request.number} уже существует в этом курсе")
} }
val lab = val lab =
labs.save( labs.save(
@@ -293,6 +283,7 @@ class LabService(
number = request.number ?: 0, number = request.number ?: 0,
title = request.title.orEmpty().trim(), title = request.title.orEmpty().trim(),
semester = semester, semester = semester,
course = course,
descriptionMd = request.descriptionMd.orEmpty(), descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt), deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt), defenseAt = parseInstant(request.defenseAt),
@@ -304,6 +295,13 @@ class LabService(
return detail(lab.id) return detail(lab.id)
} }
/** Копирует лабораторную, её уровни и варианты в другой курс. Прогресс не копируется. */
@Transactional
fun copy(
sourceLabId: UUID,
request: LabCopyRequest,
): LabDetail = detail(copyService.copy(sourceLabId, request))
@Transactional @Transactional
fun renameLevel( fun renameLevel(
labId: UUID, labId: UUID,
@@ -374,14 +372,91 @@ class LabService(
} }
return semesters.save(Semester(title = t, year = y, active = true)) return semesters.save(Semester(title = t, year = y, active = true))
} }
}
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
private fun parseInstant(value: String?): Instant? { private fun CourseRepository.resolveCourse(courseId: UUID?): Course {
val v = value?.takeIf { it.isNotBlank() } ?: return null if (courseId != null) return requireCourse(courseId)
return runCatching { Instant.parse(v) } val all = findAllByOrderByCourseNoAsc()
.recoverCatching { OffsetDateTime.parse(v).toInstant() } if (all.size == 1) return all.single().rejectArchived()
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() } throw BadRequestException("Укажите курс: в системе должен быть ровно один курс для неявного выбора")
.getOrNull() }
}
private fun CourseRepository.requireCourse(courseId: UUID?): Course {
if (courseId == null) throw BadRequestException("Укажите курс")
return findById(courseId).orElseThrow { NotFoundException("Курс не найден") }.rejectArchived()
}
private fun Course.rejectArchived(): Course {
if (status == Course.STATUS_ARCHIVED) throw BadRequestException("Курс архивирован")
return this
}
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
/** Единая сборка деталей лабораторной для одиночного и пакетного ответов. */
private fun Lab.toDetail(
levels: List<LabLevel>,
variants: List<Variant>,
): LabDetail {
val variantsByLevel = variants.groupBy { it.labLevel?.id }
return LabDetail(
id = id.toString(),
number = number,
title = title,
status = status,
descriptionMd = descriptionMd,
deadlineAt = deadlineAt?.toString(),
defenseAt = defenseAt?.toString(),
pointsMax = pointsMax,
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsByLevel[level.id]
.orEmpty()
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
courseId = course?.id?.toString(),
courseNo = course?.courseNo,
courseTitle = course?.title,
)
}
private fun Lab.toSummary(variants: List<Variant>): LabSummary =
LabSummary(
id = id.toString(),
number = number,
title = title,
status = status,
semesterTitle = semester?.let { "${it.title} ${it.year}" } ?: "—",
deadlineAt = deadlineAt?.toString(),
defenseAt = defenseAt?.toString(),
pointsMax = pointsMax,
variantCount = variants.size,
courseId = course?.id?.toString(),
courseNo = course?.courseNo,
courseTitle = course?.title,
)
private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) }
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
.getOrNull()
} }

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.academic.repo package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.ArticleAsset
import ru.ysu.lms.academic.domain.ContentArticle import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection import ru.ysu.lms.academic.domain.ContentSection
@@ -11,6 +12,19 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findBySlug(slug: String): ContentChapter? fun findBySlug(slug: String): ContentChapter?
fun findFirstBySlugOrderByIdAsc(slug: String): ContentChapter?
fun findBySlugAndCourseIdIsNull(slug: String): ContentChapter?
fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List<ContentChapter>
fun findByCourseIdIsNullOrderByPositionAscIdAsc(): List<ContentChapter>
fun findByCourseIdAndSlug(
courseId: UUID,
slug: String,
): ContentChapter?
fun findByTitleIgnoreCase(title: String): ContentChapter? fun findByTitleIgnoreCase(title: String): ContentChapter?
} }
@@ -45,3 +59,13 @@ interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle> fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
} }
interface ArticleAssetRepository : JpaRepository<ArticleAsset, UUID> {
fun findByStoredPathEndingWith(suffix: String): List<ArticleAsset>
fun findByFilename(filename: String): List<ArticleAsset>
fun findByArticleId(articleId: UUID): List<ArticleAsset>
fun deleteByArticleId(articleId: UUID)
}

View File

@@ -0,0 +1,43 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import java.util.UUID
interface CourseRepository : JpaRepository<Course, UUID> {
fun findAllByOrderByCourseNoAsc(): List<Course>
fun findByCourseNo(courseNo: Long): Course?
fun findByTitleIgnoreCase(title: String): Course?
}
interface CourseTeacherRepository : JpaRepository<CourseTeacher, UUID> {
fun findByCourseIdOrderByAssignedAtAsc(courseId: UUID): List<CourseTeacher>
fun findByCourseIdAndAccountIdAndRemovedAtIsNull(
courseId: UUID,
accountId: UUID,
): CourseTeacher?
fun findByAccountIdAndRemovedAtIsNull(accountId: UUID): List<CourseTeacher>
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
}
interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> {
fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndRemovedAtIsNull(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndGroupIdAndRemovedAtIsNull(
courseId: UUID,
groupId: UUID,
): CourseGroup?
fun findByGroupIdInAndRemovedAtIsNull(groupIds: Collection<UUID>): List<CourseGroup>
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
}

View File

@@ -7,8 +7,14 @@ import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> { interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab> fun findAllByOrderByNumberAsc(): List<Lab>
fun existsByNumberAndSemesterId( fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
fun findAllByCourseIdInOrderByNumberAsc(courseIds: Collection<UUID>): List<Lab>
fun existsByNumberAndCourseId(
number: Int, number: Int,
semesterId: UUID, courseId: UUID,
): Boolean ): Boolean
fun countByCourseId(courseId: UUID): Long
} }

View File

@@ -7,8 +7,12 @@ import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> { interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel> fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
fun findByLabIdInOrderByPosition(labIds: Collection<UUID>): List<LabLevel>
} }
interface VariantRepository : JpaRepository<Variant, UUID> { interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant> fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
fun findByLabIdInOrderByNumberAsc(labIds: Collection<UUID>): List<Variant>
} }

View File

@@ -43,3 +43,7 @@ springdoc:
lms: lms:
content: content:
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets} assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
identity:
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
internal:
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}

View File

@@ -0,0 +1,3 @@
-- Один и тот же курс не должен появиться дважды из-за конкурентного bootstrap:
-- уникальный индекс по lower(title) сериализует вставку на уровне БД.
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));

View File

@@ -0,0 +1,7 @@
-- Глобальные главы (course_id IS NULL) должны быть публично доступны.
-- Backfill: старые глобальные главы без аудитории получают ALL.
-- Курсовые главы остаются с audience = NULL: ограничение chapters_course_audience_check.
UPDATE academic.chapters
SET audience = 'ALL'
WHERE course_id IS NULL
AND audience IS NULL;

View File

@@ -0,0 +1,44 @@
-- Backfill связей статья → актив из markdown-ссылок.
-- Исторические статьи уже содержат публичные URL /api/v1/content/assets/{name},
-- но строки article_assets не создавались при импорте. Восстанавливаем связи,
-- чтобы видимость актива наследовалась от статьи/раздела/главы.
WITH refs AS (
SELECT DISTINCT
a.id AS article_id,
(m.match)[1] AS asset_name
FROM articles a
CROSS JOIN LATERAL regexp_matches(
a.body_md,
'/api/v1/content/assets/([A-Za-z0-9._-]+)',
'g'
) AS m(match)
)
INSERT INTO article_assets (article_id, filename, stored_path, mime, size)
SELECT r.article_id,
r.asset_name,
r.asset_name,
CASE lower(substring(r.asset_name from '\.([^.]+)$'))
WHEN 'png' THEN 'image/png'
WHEN 'jpg' THEN 'image/jpeg'
WHEN 'jpeg' THEN 'image/jpeg'
WHEN 'gif' THEN 'image/gif'
WHEN 'svg' THEN 'image/svg+xml'
WHEN 'webp' THEN 'image/webp'
WHEN 'mp4' THEN 'video/mp4'
WHEN 'webm' THEN 'video/webm'
WHEN 'ogg' THEN 'video/ogg'
WHEN 'ogv' THEN 'video/ogg'
ELSE 'application/octet-stream'
END,
0
FROM refs r
WHERE NOT EXISTS (
SELECT 1
FROM article_assets aa
WHERE aa.article_id = r.article_id
AND (
aa.filename = r.asset_name
OR aa.stored_path = r.asset_name
OR right(aa.stored_path, length(r.asset_name)) = r.asset_name
)
);

Some files were not shown because too many files have changed in this diff Show More