Compare commits
68 Commits
master
...
feature/gr
| Author | SHA1 | Date | |
|---|---|---|---|
| a42bc64193 | |||
| 82394cc688 | |||
| 7ef5c866a3 | |||
| 65ed08f5c2 | |||
| b57a5ffbce | |||
| 1b98b1828d | |||
| 1570196cc7 | |||
| 238c689175 | |||
| 9412d87fed | |||
| 9cb4d0d263 | |||
| f3d614bb33 | |||
| 685916c4bd | |||
| 1e1e9ae0e4 | |||
| d45137220c | |||
| 9dcba8ce0c | |||
| 71fa3284a0 | |||
| 4ef6c7f7ed | |||
| 3adfba9bec | |||
| 08e5a62c0e | |||
| 97149fa59d | |||
| 287e01cde8 | |||
| 85d34e78cb | |||
| 05f628a52b | |||
| 238fd91f69 | |||
| b7129b92e0 | |||
| 905feb440b | |||
| 80ccf26b61 | |||
| e70394d634 | |||
| 4130a1af2f | |||
| 0ac75bcd5c | |||
| 25a3dbd0fb | |||
| d7b886513f | |||
| 16e6c3282b | |||
| 97e14f040c | |||
| 4d97feaf9e | |||
| c6ca981eca | |||
| 1de7207c3c | |||
| 5affe9873b | |||
| 916046791a | |||
| 7c86194765 | |||
| d6461a3a9c | |||
| 9aef82e217 | |||
| 3fd0d41cf2 | |||
| fba648ea54 | |||
| 520a61085a | |||
| 60a318539c | |||
| 0b5d5d0ca5 | |||
| 43229059d3 | |||
| 4a2daf2fec | |||
| 1d21e66dc4 | |||
| e139a585b4 | |||
| 753cb5ba0a | |||
| fa097518f8 | |||
| a970fe62c4 | |||
| c11a308ff8 | |||
| dd1129fe80 | |||
| 8cc658bf97 | |||
| a8fa566d3c | |||
| 6f8add3736 | |||
| 9a539407f1 | |||
| 0c51bf2d12 | |||
| 0c68702e8f | |||
| cbd98f55f0 | |||
| 07a223b759 | |||
| 24a92632fd | |||
| 7c45de8d9f | |||
| de9535d35c | |||
| cde04487e8 |
@@ -10,6 +10,7 @@ oldLMS
|
|||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
node_modules
|
node_modules
|
||||||
|
e2e
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -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
6
.gitignore
vendored
@@ -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
22
.woodpecker/check.yaml
Normal 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
26
.woodpecker/release.yaml
Normal 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
25
.woodpecker/smoke.yaml
Normal 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
|
||||||
90
README.md
90
README.md
@@ -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
35
build.gradle.kts
Normal 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"))
|
||||||
|
}
|
||||||
171
buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt
Normal file
171
buildSrc/src/main/kotlin/lms/evidence/EvidenceCheckTask.kt
Normal 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
24
ci/Dockerfile
Normal 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
114
deploy/BACKUP.md
Normal 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. Проверить вход, группу, лабу, сдачу и скачивание файла.
|
||||||
104
deploy/DEPLOY.md
104
deploy/DEPLOY.md
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
204
deploy/WOODPECKER.md
Normal 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
294
deploy/backup.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
82
deploy/preflight/README.md
Normal file
82
deploy/preflight/README.md
Normal 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`.
|
||||||
38
deploy/preflight/groups-courses-post.sql
Normal file
38
deploy/preflight/groups-courses-post.sql
Normal 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;
|
||||||
124
deploy/preflight/groups-courses.sql
Normal file
124
deploy/preflight/groups-courses.sql
Normal 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;
|
||||||
96
deploy/preflight/progress-v10-v13.sql
Normal file
96
deploy/preflight/progress-v10-v13.sql
Normal 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.
|
||||||
15
deploy/systemd/lms-backup.service
Normal file
15
deploy/systemd/lms-backup.service
Normal 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
|
||||||
10
deploy/systemd/lms-backup.timer
Normal file
10
deploy/systemd/lms-backup.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily LMS backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:00:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=1800
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` для привязки или удаления.
|
||||||
|
|
||||||
## Публикация и скрытие
|
## Публикация и скрытие
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
391
docs/08-groups-courses-plan.md
Normal file
391
docs/08-groups-courses-plan.md
Normal 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 обновлены.
|
||||||
292
docs/09-teacher-student-profile.md
Normal file
292
docs/09-teacher-student-profile.md
Normal 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
99
docs/10-stage0-report.md
Normal 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
95
docs/11-stage1-report.md
Normal 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
158
docs/12-stage2-report.md
Normal 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
195
docs/13-stage3-report.md
Normal 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
176
docs/14-stage4-report.md
Normal 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.
|
||||||
72
docs/15-stage5-course-materials.md
Normal file
72
docs/15-stage5-course-materials.md
Normal 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
360
docs/16-stage6-report.md
Normal 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
321
docs/17-handoff.md
Normal 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
185
docs/18-goal-audit.md
Normal 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`.
|
||||||
@@ -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, миграции, доступ, статусы, повышение |
|
||||||
|
|
||||||
## Кратко о системе
|
## Кратко о системе
|
||||||
|
|
||||||
|
|||||||
151
docs/adr/0001-groups-courses-foundation.md
Normal file
151
docs/adr/0001-groups-courses-foundation.md
Normal 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
117
docs/evidence/README.md
Normal 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`.
|
||||||
322
docs/evidence/baseline-2026-10-27.md
Normal file
322
docs/evidence/baseline-2026-10-27.md
Normal 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. пакетно отдавать сдачи в ведомости, если появятся медленные курсы.
|
||||||
44
docs/evidence/baseline-2026-10-29.md
Normal file
44
docs/evidence/baseline-2026-10-29.md
Normal 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
979
docs/evidence/claims.json
Normal 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
6
e2e/fixtures/main.c
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
puts("LDD e2e");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
245
e2e/lms.spec.ts
Normal file
245
e2e/lms.spec.ts
Normal 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
58
e2e/package-lock.json
generated
Normal 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
7
e2e/package.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "lms-e2e",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.64.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
e2e/playwright.config.ts
Normal file
35
e2e/playwright.config.ts
Normal 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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ru.ysu.lms.security
|
|||||||
|
|
||||||
enum class LmsRole {
|
enum class LmsRole {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
|
TEACHER,
|
||||||
STUDENT,
|
STUDENT,
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -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
9
scripts/ci-build-image.sh
Executable 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
47
scripts/ci-dind.sh
Executable 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
124
scripts/ci-preflight.sh
Executable 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
173
scripts/ldd-browser.sh
Executable 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
226
scripts/ldd-migration-upgrade.sh
Executable 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
68
scripts/ldd-mutation.sh
Executable 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
31
scripts/ldd-release.sh
Executable 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
448
scripts/ldd-runtime.sh
Executable 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
6
scripts/ldd-smoke.sh
Executable 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
81
scripts/legacy-assets.sh
Executable 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()) }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,11 +359,16 @@ 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 =
|
||||||
|
articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
|
||||||
val sec = secById[a.sectionId] ?: return@mapNotNull null
|
val sec = secById[a.sectionId] ?: return@mapNotNull null
|
||||||
val ch = chById[sec.chapterId] ?: return@mapNotNull null
|
val ch = chById[sec.chapterId] ?: return@mapNotNull null
|
||||||
ContentArticleView(
|
ContentArticleView(
|
||||||
@@ -230,6 +382,8 @@ class ContentService(
|
|||||||
bodyMd = a.bodyMd,
|
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
|
||||||
|
|||||||
@@ -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,31 +217,49 @@ 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,
|
||||||
|
): ContentArticleView {
|
||||||
|
val article =
|
||||||
|
authoring.createArticlePlaced(
|
||||||
ArticlePlacement(
|
ArticlePlacement(
|
||||||
title = body.title,
|
title = body.title,
|
||||||
bodyMd = body.bodyMd,
|
bodyMd = body.bodyMd,
|
||||||
@@ -180,26 +269,55 @@ class ContentAdminController(
|
|||||||
newSectionTitle = body.newSectionTitle,
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "Новый курс"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,9 +372,87 @@ class LabService(
|
|||||||
}
|
}
|
||||||
return semesters.save(Semester(title = t, year = y, active = true))
|
return semesters.save(Semester(title = t, year = y, active = true))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
|
||||||
|
private fun CourseRepository.resolveCourse(courseId: UUID?): Course {
|
||||||
|
if (courseId != null) return requireCourse(courseId)
|
||||||
|
val all = findAllByOrderByCourseNoAsc()
|
||||||
|
if (all.size == 1) return all.single().rejectArchived()
|
||||||
|
throw BadRequestException("Укажите курс: в системе должен быть ровно один курс для неявного выбора")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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? {
|
private fun parseInstant(value: String?): Instant? {
|
||||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||||
return runCatching { Instant.parse(v) }
|
return runCatching { Instant.parse(v) }
|
||||||
@@ -384,4 +460,3 @@ class LabService(
|
|||||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Один и тот же курс не должен появиться дважды из-за конкурентного bootstrap:
|
||||||
|
-- уникальный индекс по lower(title) сериализует вставку на уровне БД.
|
||||||
|
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));
|
||||||
@@ -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;
|
||||||
@@ -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
Reference in New Issue
Block a user