Compare commits
80 Commits
9e16566160
...
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 | |||
| 920d96a0f5 | |||
| 2beffd84f1 | |||
| d8e28e88fc | |||
| 44edb5ebeb | |||
| e2e3ed1eaf | |||
| 7fb3e13548 | |||
| 9ddf2c6ce9 | |||
| a336612ad5 | |||
| c8581c620a | |||
| 54e5721267 | |||
| 26573f2210 | |||
| bfdae41fb8 |
@@ -10,6 +10,7 @@ oldLMS
|
||||
.env
|
||||
.env.prod
|
||||
node_modules
|
||||
e2e
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.iml
|
||||
|
||||
@@ -12,3 +12,24 @@ LMS_WEB_PORT=8090
|
||||
# Домен (используется в deploy-скрипте/nginx).
|
||||
LMS_DOMAIN=lms.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.prod
|
||||
/data/
|
||||
/backups/
|
||||
/exports/
|
||||
/texput.log
|
||||
node_modules/
|
||||
.jqwik-database
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
# Локальные сессии Kilo Code — не часть репозитория.
|
||||
.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.
|
||||
- `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 для сборки.
|
||||
@@ -52,7 +62,17 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты
|
||||
./gradlew evidenceCheck # LDD: P0-claims должны иметь зелёные свидетельства
|
||||
./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` — за качество (сложность, вложенность, ошибки).
|
||||
@@ -61,6 +81,18 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23
|
||||
не работает на JDK 25.
|
||||
- Тестовая инфраструктура — `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 (для сервера)
|
||||
|
||||
@@ -69,21 +101,25 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
|
||||
```bash
|
||||
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 ps
|
||||
./deploy/deploy.sh down
|
||||
```
|
||||
|
||||
Перед обновлением `deploy.sh up` обязательно делает backup БД и файлов.
|
||||
Ежедневное расписание, retention и восстановление — в [`deploy/BACKUP.md`](deploy/BACKUP.md).
|
||||
|
||||
Наружу публикуется только web на `127.0.0.1:8090`; reverse proxy — nginx
|
||||
(конфиг и полная инструкция по деплою на `lms.our-place.ru` в
|
||||
[`deploy/DEPLOY.md`](deploy/DEPLOY.md) и `deploy/nginx/lms.our-place.ru.conf`).
|
||||
|
||||
## Наполнение данными
|
||||
|
||||
Порядок ввода данных (всё через админку): `/setup` → администратор; импорт групп
|
||||
(`.xls`, логин = студенческий номер, временный пароль со сменой при первом входе);
|
||||
импорт лабораторной ZIP'ом (`lab.yaml` + `README.md` + `variants/*.md`); «Распределить»
|
||||
Порядок ввода данных (всё через админку): `/setup` → администратор; курс
|
||||
(bootstrap или «+ Новый курс»); импорт групп (`.xls`, логин = студенческий номер,
|
||||
временный пароль со сменой при первом входе); импорт лабораторной ZIP'ом с
|
||||
выбором курса (`lab.yaml` + `README.md` + `variants/*.md`); «Распределить»
|
||||
— случайная раздача по кругам в рамках уровня; оценки/защита — на странице группы;
|
||||
учебные материалы — статьи или ZIP (`chapters/…`, доступны гостям).
|
||||
|
||||
@@ -95,11 +131,46 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
|
||||
администратора (`/setup`); далее — вход по логину и паролю.
|
||||
|
||||
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
|
||||
проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах,
|
||||
проверка токена на gateway, роли ADMIN/TEACHER/STUDENT, CSRF на всех POST-формах,
|
||||
session fixation-защита; вход студента с обязательной сменой пароля.
|
||||
- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное
|
||||
добавление студента (генерация пароля) и «Добавить существующего» (перевод из
|
||||
другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля.
|
||||
Access-токен живёт 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh — 7 дней
|
||||
(`LMS_REFRESH_TOKEN_TTL`) с ротацией и отзывом; прямой API использует
|
||||
`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` +
|
||||
`assets/`); создание через UI с произвольным числом уровней; единая форма
|
||||
настроек с одной кнопкой «Сохранить всё»; статусы draft/published
|
||||
@@ -119,6 +190,9 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
|
||||
markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP
|
||||
(`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/
|
||||
удаление глав, разделов, статей; перенос статей между разделами.
|
||||
Видимость картинок наследуется от статьи; файл без привязки к статье отдаёт
|
||||
`404` обычным пользователям и доступен только админу, который получает
|
||||
список таких файлов через `GET /api/v1/content/legacy-assets`.
|
||||
- **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка),
|
||||
карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача
|
||||
файлов (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
|
||||
```
|
||||
|
||||
Обязательно задайте надёжный `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. Собрать и запустить
|
||||
|
||||
@@ -96,24 +125,74 @@ sudo certbot --nginx -d lms.our-place.ru --redirect -m admin@our-place.ru --agre
|
||||
```bash
|
||||
cd /opt/lms
|
||||
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. Резервное копирование
|
||||
|
||||
```bash
|
||||
# БД
|
||||
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
|
||||
Полная инструкция — [`BACKUP.md`](BACKUP.md). Кратко:
|
||||
|
||||
# Файлы (решения и материалы)
|
||||
docker run --rm -v lms_lms_submissions:/sub -v lms_lms_assets:/assets -v "$PWD":/backup alpine \
|
||||
tar czf /backup/lms-files-$(date +%F).tar.gz -C / sub assets
|
||||
```bash
|
||||
./deploy/backup.sh backup # БД + решения + материалы
|
||||
./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. Диагностика
|
||||
|
||||
@@ -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`
|
||||
(уже прописано в `docker-compose.prod.yml` для academic/progress/gateway).
|
||||
Внутри контейнера `localhost:8081` недоступен, поэтому токен не проверяется.
|
||||
- **403/502 в профиле студента или составе группы** — проверьте, что
|
||||
`LMS_INTERNAL_TOKEN` задан в `.env.prod` и одинаков у identity, academic
|
||||
и progress. При неверном внутреннем токене identity отвечает `403`.
|
||||
- **Не проходят загрузки больших ZIP** — увеличьте `client_max_body_size` в nginx
|
||||
(сейчас 120 МБ) и `spring.servlet.multipart.max-request-size`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Порядок наполнения LMS данными (через интерфейс)
|
||||
|
||||
Всё делается в админке, порядок: администратор → группы и студенты → лабораторная →
|
||||
раздача вариантов → защита → материалы.
|
||||
Всё делается в админке, порядок: администратор → курс (bootstrap) → группы
|
||||
и студенты → лабораторная → раздача вариантов → защита → материалы.
|
||||
|
||||
## Шаг 1. Деплой
|
||||
|
||||
@@ -9,24 +9,54 @@
|
||||
откройте домен — попадёте на `/setup`, создайте администратора (логин и пароль
|
||||
храните в секрете, в репозиторий их не писать).
|
||||
|
||||
## Шаг 2. Группы и студенты
|
||||
## Шаг 2. Курс
|
||||
|
||||
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы
|
||||
(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»).
|
||||
Название группы берётся из файла или указывается при импорте. Логином студента
|
||||
становится студенческий номер, пароль генерируется (временный, с обязательной
|
||||
сменой при первом входе).
|
||||
Откройте **Курсы** (`/admin/courses`). Все лабораторные принадлежат курсу,
|
||||
поэтому курс должен существовать до импорта лабораторных.
|
||||
|
||||
* **Существующая база:** если курсов ещё нет, нажмите «Создать курс и перенести
|
||||
данные». Bootstrap создаст курс «Промышленное программирование на языке C»,
|
||||
привяжет к нему все активные группы и перенесёт существующие лабораторные
|
||||
и главы. Повторный запуск безопасен и не создаёт дублей.
|
||||
* **Новая база:** создайте курс кнопкой «+ Новый курс» (номер присвоится
|
||||
автоматически) либо тем же bootstrap.
|
||||
* После импорта групп (шаг 3) привяжите их к курсу на странице курса, если их
|
||||
не было в момент bootstrap: блок «Привязать группу». Там же назначаются
|
||||
преподаватели — по UUID аккаунта со страницы «Пользователи».
|
||||
|
||||
Архивированный курс нельзя выбрать для новой лабораторной или копирования.
|
||||
Без курса создание и импорт лабораторной недоступны: в форме нужно выбрать курс.
|
||||
|
||||
## Шаг 3. Группы и студенты
|
||||
|
||||
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы.
|
||||
Обязательные столбцы:
|
||||
|
||||
```text
|
||||
поток | курс | номер | степень | год набора | ФИО | № | база
|
||||
```
|
||||
|
||||
Все строки файла должны относиться к одной группе. Группа создаётся по полям
|
||||
(имя собирается автоматически, например `ПИ-11БО`). Логином студента становится
|
||||
номер (`№`), пароль генерируется (временный, с обязательной сменой при первом
|
||||
входе). Ключ слияния — `student_no`: повторный импорт обновляет студента, а не
|
||||
дублирует.
|
||||
|
||||
Импорт атомарный: при ошибке в любой строке данные не меняются, а в интерфейсе
|
||||
показывается отчёт с номерами строк.
|
||||
|
||||
Дальше по каждому студенту при необходимости:
|
||||
* **«Сбросить пароль»** — выдать новый временный пароль;
|
||||
* **«Добавить существующего»** — перевести студента из другой группы;
|
||||
* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются).
|
||||
* **«Отчислить из группы»** — убрать из группы, студент получает статус
|
||||
«отчислен»; аккаунт, сдачи и оценки сохраняются.
|
||||
|
||||
Выгрузить логины: **«Логины XLSX» / «CSV»**.
|
||||
|
||||
## Шаг 3. Лабораторная
|
||||
## Шаг 4. Лабораторная
|
||||
|
||||
**Лабораторные → Импорт** — ZIP со структурой:
|
||||
**Лабораторные → Импорт** — сначала выберите **курс** (обязательно), затем
|
||||
загрузите ZIP со структурой:
|
||||
|
||||
```text
|
||||
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`)** доступны гостям. Для наполнения:
|
||||
создание глав/разделов/статей в админке или **импорт ZIP** со структурой:
|
||||
@@ -68,7 +101,9 @@ chapters/01-глава/01-раздел/01-статья.md # front-matter: titl
|
||||
assets/ # картинки для md
|
||||
```
|
||||
|
||||
Материалы видны всем; для редактирования — «Материалы» с правами администратора.
|
||||
Материалы видны всем; глобальные материалы редактирует только администратор
|
||||
(«Материалы»). Преподаватель редактирует материалы своего курса на
|
||||
`/teacher/courses/{courseNo}/content`.
|
||||
|
||||
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
|
||||
(синхронизация — архив считается источником истины) и без неё (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
|
||||
up)
|
||||
echo ">> Резервная копия перед обновлением (обязательно)..."
|
||||
./deploy/backup.sh backup --if-running
|
||||
./deploy/backup.sh verify-latest
|
||||
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
|
||||
$COMPOSE build
|
||||
echo ">> Запуск сервисов..."
|
||||
@@ -52,8 +55,20 @@ case "$cmd" in
|
||||
restart)
|
||||
$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
|
||||
;;
|
||||
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
|
||||
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:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
@@ -40,8 +43,13 @@ services:
|
||||
image: lms-identity:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *db-env
|
||||
<<: [*db-env, *internal-env]
|
||||
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:
|
||||
postgres: { condition: service_healthy }
|
||||
networks: [lms]
|
||||
@@ -51,8 +59,9 @@ services:
|
||||
image: lms-academic:latest
|
||||
restart: unless-stopped
|
||||
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_IDENTITY_URI: http://identity:8081
|
||||
LMS_CONTENT_ASSETS_DIR: /data/content-assets
|
||||
volumes:
|
||||
- lms_assets:/data/content-assets
|
||||
@@ -66,7 +75,7 @@ services:
|
||||
image: lms-progress:latest
|
||||
restart: unless-stopped
|
||||
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_SUBMISSIONS_DIR: /data/submissions
|
||||
LMS_IDENTITY_URI: http://identity:8081
|
||||
@@ -101,6 +110,8 @@ services:
|
||||
environment:
|
||||
LMS_GATEWAY_URL: http://gateway:8080
|
||||
SERVER_PORT: 8090
|
||||
LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d}
|
||||
LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d}
|
||||
ports:
|
||||
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
|
||||
depends_on:
|
||||
|
||||
@@ -32,8 +32,11 @@ flowchart LR
|
||||
id -.->|JWKS| pr
|
||||
```
|
||||
|
||||
- Браузер никогда не получает JWT: токен живёт в HTTP-сессии web-сервиса
|
||||
(`lms.token`), а в браузер уходит только cookie сессии (`HttpOnly`, 30 дней).
|
||||
- Браузер никогда не получает JWT: access-токен живёт в HTTP-сессии web-сервиса
|
||||
(`lms.token`), а в браузер уходит только cookie сессии `LMS_SESSION`
|
||||
(`HttpOnly`, `SameSite=Lax`, 7 дней sliding). Access-токен — 15 минут,
|
||||
обновляется автоматически за 2 минуты до истечения по refresh-токену;
|
||||
cookie `Max-Age` перевыпускается на каждом ответе.
|
||||
- Все REST-запросы web проксирует на gateway, добавляя `Authorization: Bearer`.
|
||||
- Gateway проверяет подпись токена по JWKS identity и передаёт вниз заголовки
|
||||
`X-Account-Id`, `X-Account-Login`, `X-Account-Role`.
|
||||
@@ -139,10 +142,13 @@ flowchart TD
|
||||
n -->|да| p["Контроллер → сервис → БД"]
|
||||
```
|
||||
|
||||
- Роли: `ADMIN`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_<role>`.
|
||||
- Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,jwks.json}`,
|
||||
`/api/v1/content/tree`, `/api/v1/content/assets/**`,
|
||||
`/api/v1/content/{chapter}/{section}/{article}`.
|
||||
- Роли: `ADMIN`, `TEACHER`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_<role>`.
|
||||
- Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,refresh,jwks.json}`,
|
||||
`/api/v1/content/tree`, `/api/v1/content/{chapter}/{section}/{article}`.
|
||||
`/api/v1/content/assets/**` gateway пропускает без JWT (публичные
|
||||
`ALL`-активы), но academic проверяет видимость статьи и для курсовых или
|
||||
ролевых активов требует токен. Файл без привязки к статье — `404` всем,
|
||||
кроме админа (secure-by-default).
|
||||
- RSA-ключи identity хранятся в таблице `settings` и публикуются в JWKS.
|
||||
|
||||
## 5. Рендеринг страниц и защита форм (web)
|
||||
|
||||
@@ -52,6 +52,12 @@ flowchart TB
|
||||
`LMS_JWT_JWK_SET_URI` обязателен для gateway, academic и progress —
|
||||
в контейнерах это `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. Сборка
|
||||
|
||||
```mermaid
|
||||
@@ -101,7 +107,7 @@ sequenceDiagram
|
||||
DC->>DC: docker build (5 стадий)
|
||||
DC->>PG: старт postgres (volume lms_pgdata)
|
||||
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`.
|
||||
|
||||
@@ -12,6 +12,8 @@ classDiagram
|
||||
+String login
|
||||
+String passwordHash
|
||||
+LmsRole role
|
||||
+AccountStatus status
|
||||
+Instant archivedAt
|
||||
+Boolean mustChangePassword
|
||||
+String tempPassword
|
||||
+Instant createdAt
|
||||
@@ -25,11 +27,19 @@ classDiagram
|
||||
+String middleName
|
||||
+String basis
|
||||
+String note
|
||||
+StudentStatus status
|
||||
+Short enrollmentYear
|
||||
+String fullName
|
||||
}
|
||||
class GroupEntity {
|
||||
+UUID id
|
||||
+String name
|
||||
+String stream
|
||||
+Short course
|
||||
+Short number
|
||||
+String degree
|
||||
+Short enrollmentYear
|
||||
+GroupStatus status
|
||||
+Int taskDurationMin
|
||||
+Instant createdAt
|
||||
}
|
||||
@@ -37,6 +47,17 @@ classDiagram
|
||||
+UUID id
|
||||
+Instant enrolledAt
|
||||
}
|
||||
class MembershipHistory {
|
||||
+UUID id
|
||||
+UUID studentId
|
||||
+UUID fromGroupId
|
||||
+UUID toGroupId
|
||||
+MembershipAction action
|
||||
+String reason
|
||||
+UUID actorAccountId
|
||||
+UUID promotionRunId
|
||||
+Instant at
|
||||
}
|
||||
class Setting {
|
||||
+String key
|
||||
+String value
|
||||
@@ -44,12 +65,15 @@ classDiagram
|
||||
class LmsRole {
|
||||
<<enumeration>>
|
||||
ADMIN
|
||||
TEACHER
|
||||
STUDENT
|
||||
}
|
||||
|
||||
Account "1" --> "1" Student : @MapsId account_id
|
||||
GroupEntity "1" --> "*" Enrollment : group_id
|
||||
Student "1" --> "*" Enrollment : student_id
|
||||
Student "1" --> "*" MembershipHistory : student_id
|
||||
GroupEntity "1" --> "*" MembershipHistory : from/to_group_id
|
||||
Account --> LmsRole : role
|
||||
```
|
||||
|
||||
@@ -68,20 +92,28 @@ classDiagram
|
||||
class GroupService {
|
||||
+list() List~GroupSummary~
|
||||
+create(CreateGroupRequest) GroupSummary
|
||||
+createStudent(UUID, CreateStudentRequest) Credential
|
||||
+createStudent(UUID, CreateStudentRequest, UUID) Credential
|
||||
+candidates(UUID) List~StudentCandidate~
|
||||
+enroll(UUID, UUID) GroupDetail
|
||||
+unenroll(UUID, UUID) GroupDetail
|
||||
+import(String, String, ByteArray) ImportResult
|
||||
+enroll(UUID, UUID, UUID) GroupDetail
|
||||
+unenroll(UUID, UUID, UUID) GroupDetail
|
||||
+groupOfStudent(UUID) GroupSummary
|
||||
+detail(UUID) GroupDetail
|
||||
+resetPassword(UUID) ResetPasswordResponse
|
||||
+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 GroupRepository
|
||||
class EnrollmentRepository
|
||||
class StudentRepository
|
||||
class MembershipHistoryRepository
|
||||
class SettingRepository
|
||||
class StudentImportParser {
|
||||
+parse(String, ByteArray) ParsedGroup
|
||||
@@ -96,6 +128,7 @@ classDiagram
|
||||
|
||||
AuthController --> AuthService
|
||||
GroupController --> GroupService
|
||||
GroupController --> GroupImportService
|
||||
StudentController --> GroupService
|
||||
AuthService --> AccountRepository
|
||||
AuthService --> StudentRepository
|
||||
@@ -103,7 +136,14 @@ classDiagram
|
||||
GroupService --> EnrollmentRepository
|
||||
GroupService --> StudentRepository
|
||||
GroupService --> AccountRepository
|
||||
GroupService --> StudentImportParser
|
||||
GroupService --> MembershipHistoryRepository
|
||||
GroupService --> GroupCohortResolver
|
||||
GroupImportService --> GroupRepository
|
||||
GroupImportService --> EnrollmentRepository
|
||||
GroupImportService --> StudentRepository
|
||||
GroupImportService --> AccountRepository
|
||||
GroupImportService --> MembershipHistoryRepository
|
||||
GroupImportService --> StudentImportParser
|
||||
GroupService --> CredentialsExporter
|
||||
GroupService --> Passwords
|
||||
```
|
||||
@@ -516,6 +556,8 @@ erDiagram
|
||||
varchar login UK
|
||||
varchar password_hash
|
||||
varchar role
|
||||
varchar status
|
||||
timestamptz archived_at
|
||||
boolean must_change_password
|
||||
varchar temp_password
|
||||
timestamptz created_at
|
||||
@@ -529,10 +571,18 @@ erDiagram
|
||||
varchar middle_name
|
||||
varchar basis
|
||||
text note
|
||||
varchar status
|
||||
smallint enrollment_year
|
||||
}
|
||||
GROUPS {
|
||||
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
|
||||
timestamptz created_at
|
||||
}
|
||||
@@ -542,14 +592,27 @@ erDiagram
|
||||
uuid student_id FK
|
||||
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 {
|
||||
varchar key PK
|
||||
varchar value
|
||||
}
|
||||
|
||||
ACCOUNTS ||--|| STUDENTS : "account_id"
|
||||
ACCOUNTS ||--o| STUDENTS : "account_id"
|
||||
GROUPS ||--o{ ENROLLMENTS : "group_id"
|
||||
STUDENTS ||--o{ ENROLLMENTS : "student_id"
|
||||
STUDENTS ||--o{ MEMBERSHIP_HISTORY : "student_id"
|
||||
GROUPS ||--o{ MEMBERSHIP_HISTORY : "from/to_group_id"
|
||||
|
||||
SEMESTERS {
|
||||
uuid id PK
|
||||
|
||||
@@ -125,8 +125,12 @@ printf("Ответ: 42\n");
|
||||
* Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную.
|
||||
* Минимальный архив (например, только один раздел + `assets/`) безопасно импортировать
|
||||
в режиме merge: остальные материалы останутся как есть.
|
||||
* Ссылки вида `assets/имя` автоматически заменяются на публичный адрес
|
||||
`/api/v1/content/assets/...`; сами файлы складываются в хранилище.
|
||||
* Ссылки вида `assets/имя` автоматически заменяются на адрес
|
||||
`/api/v1/content/assets/...`; сами файлы складываются в хранилище. Для
|
||||
курсовых статей этот адрес требует сессию пользователя курса, для публичных
|
||||
`ALL`-материалов остаётся публичным. Файл без привязки к статье больше не
|
||||
публичен: обычные пользователи получают `404`, админ — файл и его имя в
|
||||
`GET /api/v1/content/legacy-assets` для привязки или удаления.
|
||||
|
||||
## Публикация и скрытие
|
||||
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
# 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. Сущность **курс** живёт в `academic-service`; владелец — **админ**, преподавателей на курс
|
||||
@@ -31,8 +60,8 @@
|
||||
|
||||
**Разбор имени группы:** `^(.+)-(\d)(\d+)(БО|МО)$` → `stream` (буквы/дефисы), `course` (одна цифра),
|
||||
`number` (остальные цифры), `degree`. Примеры: `ПИ-11БО` → ПИ/1/1/БО; `ИВТ-ТОП-12БО` → ИВТ-ТОП/1/2/БО;
|
||||
`ПИ-101БО` → курс 1, номер 01 (ведущие нули допустимы). Разбор выполняется при создании/импорте,
|
||||
далее живём по полям; имя храним для отображения/выгрузок.
|
||||
`ПИ-101БО` → курс 1, номер 1. `number` — числовой; ведущий ноль не является отдельным значением.
|
||||
Разбор выполняется при создании/импорте, далее живём по полям; имя храним для отображения/выгрузок.
|
||||
|
||||
---
|
||||
|
||||
@@ -65,7 +94,12 @@
|
||||
|
||||
### progress
|
||||
Данные привязаны к `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
|
||||
- `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_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`
|
||||
(`ALL | STUDENTS | TEACHERS`, применяется при `course_id IS NULL`); `sections`/`articles` наследуют
|
||||
видимость от главы.
|
||||
видимость от главы. Текущие материалы переносятся в курс C и получают `course_id`.
|
||||
- `chapters.slug`: снять глобальный UNIQUE, ввести два частичных индекса (глобальные и внутри курса).
|
||||
- `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;
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
**V6 — курс (пересоздание)**
|
||||
```sql
|
||||
CREATE TABLE courses (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
course_no bigint GENERATED ALWAYS AS IDENTITY UNIQUE NOT NULL,
|
||||
title varchar(200) NOT NULL,
|
||||
owner_account_id uuid NOT NULL,
|
||||
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;
|
||||
```
|
||||
|
||||
**V7 — лабораторные привязываем к курсу**
|
||||
**V7 — лабораторные привязываем к курсу (nullable-фаза)**
|
||||
```sql
|
||||
-- «Курс по умолчанию» создаётся в сервисе (title='Общие лабораторные', owner=первый ADMIN)
|
||||
-- Курс «Промышленное программирование на языке C» создаётся bootstrap-сервисом
|
||||
-- после первого входа админа (owner = этот админ). В V7 только добавляем nullable-колонку.
|
||||
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 ALTER COLUMN course_id SET NOT NULL;
|
||||
DROP CONSTRAINT labs_number_semester_unique;
|
||||
ALTER TABLE labs ADD CONSTRAINT labs_number_course_unique UNIQUE (number, course_id);
|
||||
ALTER TABLE labs DROP CONSTRAINT labs_number_semester_unique;
|
||||
CREATE UNIQUE INDEX labs_number_course_uidx ON labs (number, 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 — материалы: курс и аудитория, слаг**
|
||||
```sql
|
||||
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)
|
||||
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;
|
||||
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;
|
||||
```
|
||||
Обновить URL-схему материалов: глобальные — как сейчас, курсовые — `/learn/{course}/{chapter}/{section}/{article}`;
|
||||
старые ссылки на глобальные материалы сохраняются (совместимость).
|
||||
Обновить URL-схему материалов: глобальные — `/learn/{chapter}/{section}/{article}`;
|
||||
курсовые — `/learn/course/{course_no}/{chapter}/{section}/{article}`.
|
||||
Старые ссылки на текущие материалы не поддерживаются (решение 2026-10-23).
|
||||
|
||||
**V9 — активы: наследуемая видимость**
|
||||
Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в
|
||||
маршруте выдачи активов (см. §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
|
||||
Схема **не меняется**. Проверки доступа — рантайм (по `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).
|
||||
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. Материалы, публичный доступ и активы
|
||||
@@ -253,10 +457,14 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
|
||||
- Аудитории: `ALL` (в т.ч. **неавторизованные**), `STUDENTS`, `TEACHERS`; глобальные публикует только админ.
|
||||
- Нужны **публичные маршруты** для `ALL` без аутентификации. `STUDENTS`/`TEACHERS`/курсовые материалы
|
||||
анонимно недоступны ни при каких условиях.
|
||||
- **Активы (риск):** материалы ссылаются на файлы (`/api/v1/assets/**`). Для публичных глав активы должны
|
||||
быть доступны, для приватных — нет. Решение: видимость актива наследуется от главы, и маршрут выдачи
|
||||
активов обязан это проверять (либо отдельный публичный префикс для `ALL`). Без этого публичные материалы
|
||||
будут ломаться, а приватные — утекать.
|
||||
- **Активы (реализовано):** видимость актива наследуется от статьи → раздела →
|
||||
главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без
|
||||
связи со статьёй не публичен: аноним/студент/преподаватель получают `404`,
|
||||
админ может скачать файл, открыть страницу `/admin/legacy-assets`, вызвать
|
||||
`GET /api/v1/content/legacy-assets` или `scripts/legacy-assets.sh` для
|
||||
привязки или удаления. Старые
|
||||
внешние ссылки на несвязанные файлы у обычных пользователей перестают
|
||||
работать — это осознанное secure-by-default поведение.
|
||||
- Поисковая индексация: для `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); внешняя система — источник истины по
|
||||
курсу/группе/статусу; LMS — зеркало; синхронизация односторонняя + отчёт дрифта.
|
||||
- Сейчас закладываем: `student_no` уникальный и стабильный (ключ склейки); внешние id добавим миграцией.
|
||||
- Обязательно: идемпотентность, журнал синхронизаций, отчёт расхождений, обработка академа/переводов/отчислений,
|
||||
устойчивость к недоступности внешней системы, ПДн.
|
||||
Решением от 2026-10-28 интеграция **не входит в объём работ**: нет API-спецификации
|
||||
и доступа к внешней системе, а без них можно сделать только непроверяемую заглушку.
|
||||
`student_no` остаётся уникальным и стабильным, поэтому при появлении нового ТЗ
|
||||
адаптер можно добавить без переделки модели.
|
||||
|
||||
Если интеграция понадобится позже: отдельный anti-corruption layer, идемпотентная
|
||||
синхронизация, журнал, отчёт дрифта, обработка академа/переводов/отчислений,
|
||||
устойчивость к недоступности внешней системы и ПДн.
|
||||
|
||||
---
|
||||
|
||||
@@ -340,14 +551,22 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
|
||||
3. Одноимённые группы — уникальность по году набора; отображение с годом.
|
||||
4. Академ vs групповой курс — правило перевода и автосоздание целевой группы.
|
||||
5. Публичные `ALL`-материалы и **активы** — проверка видимости, иначе утечка/ломка.
|
||||
6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема,
|
||||
совместимость старых ссылок.
|
||||
7. Миграция существующих лаб/материалов без курса (курс по умолчанию / `audience='ALL'`).
|
||||
6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема;
|
||||
старые ссылки на текущие материалы не поддерживаются.
|
||||
7. Миграция существующих лаб/материалов в курс C; «общих» лаб нет.
|
||||
8. Немедленный отзыв доступа (преподаватель/группа/публикация материала).
|
||||
9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации, архив/удаление.
|
||||
9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации,
|
||||
архив/удаление — реализован в `identity.audit_events` (`V10`), просмотр
|
||||
`/admin/audit`.
|
||||
10. Производительность: индексы `course_id`/`group_id`, пагинация материалов и групп.
|
||||
11. Бэкфилл `enrollment_year` для существующих групп — приблизительный, требует подтверждения админом.
|
||||
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.
|
||||
1. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ).
|
||||
2. Курсы + роль `TEACHER` + назначения (с историей) + скоуп-авторизация и тесты.
|
||||
2a. Профиль студента курса: статистика по лабам, комментарии, все попытки, скачивание файлов.
|
||||
3. Привязка лаб/материалов к курсу, аудитории материалов, публичные `ALL`-маршруты и активы, миграция данных.
|
||||
4. Запись/снятие групп на курс.
|
||||
5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
|
||||
6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
|
||||
7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`.
|
||||
7. ~~Сервис-адаптер «Контингент»/`next.uniyar.ac.ru`~~ — исключён из плана.
|
||||
|
||||
Открытых вопросов нет: все решения зафиксированы в §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) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы |
|
||||
| [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов |
|
||||
| [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"
|
||||
detekt = "1.23.8"
|
||||
mockk = "1.14.11"
|
||||
jqwik = "1.9.3"
|
||||
pitest = "1.15.0"
|
||||
pitestJunit5 = "1.2.1"
|
||||
|
||||
[libraries]
|
||||
# --- Gradle plugins used from buildSrc ---
|
||||
@@ -58,6 +61,7 @@ flywayPostgresql = { module = "org.flywaydb:flyway-database-postgresql" }
|
||||
postgresql = { module = "org.postgresql:postgresql" }
|
||||
jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" }
|
||||
jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
|
||||
jackson3ModuleKotlin = { module = "tools.jackson.module:jackson-module-kotlin", version = "3.1.5" }
|
||||
|
||||
# --- API docs ---
|
||||
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) ---
|
||||
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" }
|
||||
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
|
||||
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.ysu.lms.security
|
||||
|
||||
enum class LmsRole {
|
||||
ADMIN,
|
||||
TEACHER,
|
||||
STUDENT,
|
||||
;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ dependencies {
|
||||
api(libs.testcontainersJunitJupiter)
|
||||
api(libs.springBootStarterTest)
|
||||
api(libs.mockk)
|
||||
api(libs.jqwik)
|
||||
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
|
||||
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")
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
@@ -16,6 +68,7 @@ dependencies {
|
||||
runtimeOnly(libs.postgresql)
|
||||
implementation(libs.jacksonYaml)
|
||||
implementation(libs.jacksonModuleKotlin)
|
||||
implementation(libs.jackson3ModuleKotlin)
|
||||
implementation(libs.springdocStarterWebmvcUi)
|
||||
|
||||
implementation(project(":libs:common-http"))
|
||||
@@ -23,6 +76,10 @@ dependencies {
|
||||
implementation(project(":libs:security-jwt"))
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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>? {
|
||||
val clean = publicName.substringAfterLast('/')
|
||||
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.ContentSectionRepository
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.util.UUID
|
||||
|
||||
@@ -32,8 +33,14 @@ class ContentAuthoringService(
|
||||
articleId: UUID,
|
||||
title: 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("Заголовок не может быть пустым")
|
||||
a.title = title.trim()
|
||||
a.bodyMd = bodyMd
|
||||
@@ -41,46 +48,97 @@ class ContentAuthoringService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteArticle(articleId: UUID) {
|
||||
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
|
||||
fun deleteArticle(
|
||||
articleId: UUID,
|
||||
courseId: UUID? = null,
|
||||
) {
|
||||
if (courseId != null) {
|
||||
requireArticleInCourse(articleId, courseId)
|
||||
} else if (!articles.existsById(articleId)) {
|
||||
throw NotFoundException("Статья не найдена")
|
||||
}
|
||||
articles.deleteById(articleId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Глобальная глава (`courseId == null`) получает запрошенную аудиторию
|
||||
* (по умолчанию ALL) и уникальный slug среди глобальных глав. Курсовая глава
|
||||
* получает `courseId`, пустую аудиторию и уникальный slug внутри курса.
|
||||
*/
|
||||
@Transactional
|
||||
fun createChapter(
|
||||
title: String,
|
||||
slug: String?,
|
||||
courseId: UUID? = null,
|
||||
audience: String? = null,
|
||||
): ChapterTreeNode {
|
||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||
val requestedAudience = normalizeAudience(audience)
|
||||
val scope =
|
||||
if (courseId == null) {
|
||||
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
|
||||
} else {
|
||||
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId)
|
||||
}
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||
?: Slugs.uniqueSlug(title, scope.map { it.slug }.toSet())
|
||||
val maxPos = scope.maxOfOrNull { it.position } ?: 0
|
||||
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(
|
||||
id = ch.id.toString(),
|
||||
title = ch.title,
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
audience = ch.audience,
|
||||
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
|
||||
fun renameChapter(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
courseId: UUID? = null,
|
||||
) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
val ch = findChapter(chapterId, courseId)
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
ch.title = title.trim()
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteChapter(chapterId: UUID) {
|
||||
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
|
||||
fun deleteChapter(
|
||||
chapterId: UUID,
|
||||
courseId: UUID? = null,
|
||||
) {
|
||||
if (courseId != null) {
|
||||
requireChapterInCourse(chapterId, courseId)
|
||||
} else if (!chapters.existsById(chapterId)) {
|
||||
throw NotFoundException("Глава не найдена")
|
||||
}
|
||||
chapters.deleteById(chapterId)
|
||||
}
|
||||
|
||||
@@ -89,8 +147,9 @@ class ContentAuthoringService(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
slug: String?,
|
||||
courseId: UUID? = null,
|
||||
): SectionTreeNode {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
val ch = findChapter(chapterId, courseId)
|
||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||
val cleanSlug =
|
||||
@@ -115,16 +174,24 @@ class ContentAuthoringService(
|
||||
fun renameSection(
|
||||
sectionId: UUID,
|
||||
title: String,
|
||||
courseId: UUID? = null,
|
||||
) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val sec = findSection(sectionId, courseId, "Раздел не найден")
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
sec.title = title.trim()
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteSection(sectionId: UUID) {
|
||||
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
|
||||
fun deleteSection(
|
||||
sectionId: UUID,
|
||||
courseId: UUID? = null,
|
||||
) {
|
||||
if (courseId != null) {
|
||||
requireSectionInCourse(sectionId, courseId)
|
||||
} else if (!sections.existsById(sectionId)) {
|
||||
throw NotFoundException("Раздел не найден")
|
||||
}
|
||||
sections.deleteById(sectionId)
|
||||
}
|
||||
|
||||
@@ -133,9 +200,10 @@ class ContentAuthoringService(
|
||||
fun moveArticlesAndDeleteSection(
|
||||
sectionId: UUID,
|
||||
targetSectionId: UUID,
|
||||
courseId: UUID? = null,
|
||||
) {
|
||||
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
||||
val src = findSection(sectionId, courseId, "Раздел не найден")
|
||||
val dst = findSection(targetSectionId, courseId, "Раздел назначения не найден")
|
||||
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
||||
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
|
||||
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
|
||||
@@ -148,41 +216,39 @@ class ContentAuthoringService(
|
||||
sections.delete(src)
|
||||
}
|
||||
|
||||
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
||||
/**
|
||||
* Создаёт статью; если раздела нет — создаёт главу/раздел по названиям.
|
||||
* При `courseId != null` все вложенные сущности обязаны принадлежать этому курсу,
|
||||
* а новые главы и разделы создаются в нём.
|
||||
*/
|
||||
@Transactional
|
||||
fun createArticlePlaced(placement: ArticlePlacement): ContentArticleView {
|
||||
fun createArticlePlaced(
|
||||
placement: ArticlePlacement,
|
||||
courseId: UUID? = null,
|
||||
): ContentArticleView {
|
||||
val targetSection: ContentSection =
|
||||
when {
|
||||
placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> {
|
||||
val anchor =
|
||||
sections
|
||||
.findById(placement.sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val node = createSection(anchor.chapterId, placement.newSectionTitle, null)
|
||||
val anchor = findSection(placement.sectionId, courseId, "Раздел не найден")
|
||||
val node = createSection(anchor.chapterId, placement.newSectionTitle, null, courseId)
|
||||
sections
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
placement.sectionId != null ->
|
||||
sections
|
||||
.findById(placement.sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
placement.sectionId != null -> findSection(placement.sectionId, courseId, "Раздел не найден")
|
||||
!placement.newSectionTitle.isNullOrBlank() -> {
|
||||
val chapter: ContentChapter =
|
||||
when {
|
||||
placement.chapterId != null ->
|
||||
chapters
|
||||
.findById(placement.chapterId)
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
placement.chapterId != null -> findChapter(placement.chapterId, courseId)
|
||||
!placement.newChapterTitle.isNullOrBlank() -> {
|
||||
val node = createChapter(placement.newChapterTitle, null)
|
||||
val node = createChapter(placement.newChapterTitle, null, courseId)
|
||||
chapters
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите главу")
|
||||
}
|
||||
val node = createSection(chapter.id, placement.newSectionTitle, null)
|
||||
val node = createSection(chapter.id, placement.newSectionTitle, null, courseId)
|
||||
sections
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
@@ -243,4 +309,55 @@ class ContentAuthoringService(
|
||||
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.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.ArticleAsset
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
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.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
@@ -27,6 +29,7 @@ class ContentImporter(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
private val assetLinks: ArticleAssetRepository,
|
||||
private val assets: AssetStorage,
|
||||
) {
|
||||
@Transactional
|
||||
@@ -43,9 +46,9 @@ class ContentImporter(
|
||||
if (contentFiles.isEmpty()) {
|
||||
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||
}
|
||||
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
|
||||
val storedAssets = storeAssets(files.filterKeys(::isAssetsPath))
|
||||
val parsed = parseArticles(contentFiles)
|
||||
return mergeIntoDb(parsed, linkRewrite, sync)
|
||||
return mergeIntoDb(parsed, storedAssets, sync)
|
||||
}
|
||||
|
||||
private fun requireZip(filename: String) {
|
||||
@@ -54,22 +57,22 @@ class ContentImporter(
|
||||
|
||||
private fun mergeIntoDb(
|
||||
parsed: List<ParsedArticle>,
|
||||
linkRewrite: Map<String, String>,
|
||||
storedAssets: StoredAssets,
|
||||
sync: Boolean,
|
||||
): ContentImportResult {
|
||||
val state = ImportState()
|
||||
for (p in parsed) {
|
||||
val chapter = chapterFor(p, state)
|
||||
val section = sectionFor(chapter, p, state)
|
||||
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
|
||||
upsertArticle(section, p, body, state)
|
||||
val body = storedAssets.rewrite(p.body)
|
||||
upsertArticle(section, p, body, storedAssets, state)
|
||||
}
|
||||
val removed = if (sync) removeMissing(state) else 0
|
||||
return ContentImportResult(
|
||||
chapters = state.chapters,
|
||||
sections = state.sections,
|
||||
articles = state.articles,
|
||||
assets = linkRewrite.size,
|
||||
assets = storedAssets.refs.size,
|
||||
removed = removed,
|
||||
)
|
||||
}
|
||||
@@ -85,10 +88,13 @@ class ContentImporter(
|
||||
existing?.apply {
|
||||
title = p.chapterDir
|
||||
position = p.chapterPos
|
||||
// Импорт создаёт/обновляет глобальные главы; курсовые не трогаем.
|
||||
if (courseId == null && audience == null) audience = AUDIENCE_ALL
|
||||
} ?: ContentChapter(
|
||||
title = p.chapterDir,
|
||||
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
||||
position = p.chapterPos,
|
||||
audience = AUDIENCE_ALL,
|
||||
)
|
||||
state.chapter = chapters.save(entity)
|
||||
state.touchedChapters.add(state.chapter!!.id)
|
||||
@@ -133,6 +139,7 @@ class ContentImporter(
|
||||
section: ContentSection,
|
||||
p: ParsedArticle,
|
||||
body: String,
|
||||
storedAssets: StoredAssets,
|
||||
state: ImportState,
|
||||
) {
|
||||
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
|
||||
@@ -145,11 +152,33 @@ class ContentImporter(
|
||||
bodyMd = body
|
||||
position = p.articlePos
|
||||
}
|
||||
articles.save(article)
|
||||
// saveAndFlush гарантирует строку articles до вставки article_assets (FK).
|
||||
articles.saveAndFlush(article)
|
||||
replaceAssetLinks(article, body, storedAssets)
|
||||
state.seenArticles.add(article.id)
|
||||
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 =
|
||||
state.touchedChapters.sumOf { chapterId ->
|
||||
@@ -177,15 +206,22 @@ class ContentImporter(
|
||||
return removed
|
||||
}
|
||||
|
||||
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
|
||||
val linkRewrite = mutableMapOf<String, String>()
|
||||
private fun storeAssets(assetFiles: Map<String, ByteArray>): StoredAssets {
|
||||
val refs = mutableListOf<StoredAssetRef>()
|
||||
for ((path, data) in assetFiles) {
|
||||
val name = path.substringAfterLast('/')
|
||||
if (name.isBlank()) continue
|
||||
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/")
|
||||
@@ -294,6 +330,22 @@ class ContentImporter(
|
||||
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(
|
||||
val chapterPos: Int,
|
||||
val chapterDir: String,
|
||||
|
||||
@@ -2,6 +2,9 @@ package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
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.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
@@ -31,6 +34,7 @@ data class ChapterTreeNode(
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
val audience: String? = null,
|
||||
val sections: List<SectionTreeNode>,
|
||||
)
|
||||
|
||||
@@ -59,6 +63,17 @@ data class ContentImportResult(
|
||||
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 sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
private val articleAssets: ArticleAssetRepository,
|
||||
private val importer: ContentImporter,
|
||||
private val assetStorage: AssetStorage,
|
||||
) {
|
||||
/**
|
||||
* Публичное дерево: только глобальные главы, аудитория которых доступна роли.
|
||||
* Аноним видит ALL, студент — ALL/STUDENTS, преподаватель — ALL/TEACHERS,
|
||||
* администратор — все аудитории. Курсовые главы здесь не показываются.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||
fun tree(
|
||||
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 =
|
||||
sections
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.filter { it.chapterId in chapterIds && (includeHidden || it.published) }
|
||||
.groupBy { it.chapterId }
|
||||
val sectionIds = sectionList.values.flatten().mapTo(HashSet()) { it.id }
|
||||
val articleList =
|
||||
articles
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.filter { it.sectionId in sectionIds && (includeHidden || it.published) }
|
||||
.groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
@@ -120,6 +296,7 @@ class ContentService(
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
audience = ch.audience,
|
||||
sections =
|
||||
(sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
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(
|
||||
value: T?,
|
||||
message: String,
|
||||
@@ -212,24 +359,31 @@ class ContentService(
|
||||
articles.save(a)
|
||||
}
|
||||
|
||||
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
|
||||
@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 secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
|
||||
val sec = secById[a.sectionId] ?: return@mapNotNull null
|
||||
val ch = chById[sec.chapterId] ?: return@mapNotNull null
|
||||
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,
|
||||
)
|
||||
}
|
||||
val all =
|
||||
articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
|
||||
val sec = secById[a.sectionId] ?: return@mapNotNull null
|
||||
val ch = chById[sec.chapterId] ?: return@mapNotNull null
|
||||
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,
|
||||
)
|
||||
}
|
||||
if (limit <= 0) return all
|
||||
return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE))
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -238,4 +392,15 @@ class ContentService(
|
||||
bytes: ByteArray,
|
||||
sync: Boolean = true,
|
||||
): 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.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
|
||||
@@ -12,7 +14,9 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
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.ContentArticleView
|
||||
import ru.ysu.lms.academic.content.ContentAuthoringService
|
||||
import ru.ysu.lms.academic.content.ContentImportResult
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
@@ -25,6 +29,7 @@ import java.util.UUID
|
||||
class ContentAdminController(
|
||||
private val contentService: ContentService,
|
||||
private val authoring: ContentAuthoringService,
|
||||
private val audit: AuditClient,
|
||||
) {
|
||||
data class ArticleUpdatePayload(
|
||||
val title: String,
|
||||
@@ -55,6 +60,11 @@ class ContentAdminController(
|
||||
data class ChapterCreatePayload(
|
||||
val title: String,
|
||||
val slug: String? = null,
|
||||
val audience: String? = null,
|
||||
)
|
||||
|
||||
data class ChapterAudiencePayload(
|
||||
val audience: String,
|
||||
)
|
||||
|
||||
data class SectionCreatePayload(
|
||||
@@ -66,79 +76,140 @@ class ContentAdminController(
|
||||
@GetMapping("/tree/all")
|
||||
fun treeAll() = contentService.treeAll()
|
||||
|
||||
/** Файлы хранилища без связи со статьями — для очистки legacy-активов. */
|
||||
@GetMapping("/legacy-assets")
|
||||
fun legacyAssets(): List<String> = contentService.unlinkedAssetNames()
|
||||
|
||||
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */
|
||||
@GetMapping("/all/{chapter}/{section}/{article}")
|
||||
fun articleAll(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||
return ContentPublicController.ContentArticleViewDto(
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug,
|
||||
sectionTitle = a.sectionTitle,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
): ContentPublicController.ContentArticleViewDto =
|
||||
contentService
|
||||
.findArticleForAdmin(chapter, section, article)
|
||||
.toDto()
|
||||
|
||||
@PutMapping("/chapters/{id}/published")
|
||||
fun chapterPublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
contentService.setChapterPublished(id, body.published)
|
||||
audit.record(
|
||||
jwt.subject,
|
||||
"CONTENT_CHAPTER_PUBLISH",
|
||||
ENTITY_CHAPTER,
|
||||
id.toString(),
|
||||
details = "published=${body.published}",
|
||||
)
|
||||
}
|
||||
|
||||
@PutMapping("/sections/{id}/published")
|
||||
fun sectionPublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
contentService.setSectionPublished(id, body.published)
|
||||
audit.record(
|
||||
jwt.subject,
|
||||
"CONTENT_SECTION_PUBLISH",
|
||||
ENTITY_SECTION,
|
||||
id.toString(),
|
||||
details = "published=${body.published}",
|
||||
)
|
||||
}
|
||||
|
||||
@PutMapping("/articles/{id}/published")
|
||||
fun articlePublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
contentService.setArticlePublished(id, body.published)
|
||||
audit.record(
|
||||
jwt.subject,
|
||||
"CONTENT_ARTICLE_PUBLISH",
|
||||
ENTITY_ARTICLE,
|
||||
id.toString(),
|
||||
details = "published=${body.published}",
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(value = "sync", defaultValue = "true") sync: Boolean,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ContentImportResult {
|
||||
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")
|
||||
fun list() = contentService.listAll()
|
||||
fun list(
|
||||
@RequestParam(defaultValue = "0") offset: Int,
|
||||
@RequestParam(defaultValue = "0") limit: Int,
|
||||
) = contentService.listAll(offset, limit)
|
||||
|
||||
@PostMapping("/chapters")
|
||||
fun createChapter(
|
||||
@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}")
|
||||
fun renameChapter(
|
||||
@PathVariable id: UUID,
|
||||
@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}")
|
||||
fun deleteChapter(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<Void> {
|
||||
authoring.deleteChapter(id)
|
||||
audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString())
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@@ -146,60 +217,107 @@ class ContentAdminController(
|
||||
fun createSection(
|
||||
@RequestParam chapterId: UUID,
|
||||
@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}")
|
||||
fun renameSection(
|
||||
@PathVariable id: UUID,
|
||||
@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}")
|
||||
fun deleteSection(
|
||||
@PathVariable id: UUID,
|
||||
@RequestParam(required = false) moveTo: UUID?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<Void> {
|
||||
if (moveTo != null) {
|
||||
authoring.moveArticlesAndDeleteSection(id, moveTo)
|
||||
} else {
|
||||
authoring.deleteSection(id)
|
||||
}
|
||||
audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString())
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@PostMapping("/articles")
|
||||
fun createArticle(
|
||||
@RequestBody body: ArticleCreatePayload,
|
||||
) = 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,
|
||||
),
|
||||
)
|
||||
@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,
|
||||
),
|
||||
)
|
||||
audit.record(
|
||||
jwt.subject,
|
||||
"CONTENT_ARTICLE_CREATE",
|
||||
ENTITY_ARTICLE,
|
||||
article.id,
|
||||
details = "title=${body.title}",
|
||||
)
|
||||
return article
|
||||
}
|
||||
|
||||
@PutMapping("/articles/{id}")
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ArticleUpdatePayload,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
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")
|
||||
fun move(
|
||||
@PathVariable id: UUID,
|
||||
@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}")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<Void> {
|
||||
authoring.deleteArticle(id)
|
||||
audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString())
|
||||
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.http.MediaType
|
||||
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.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
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.ContentArticleView
|
||||
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
|
||||
@RequestMapping("/api/v1/content")
|
||||
class ContentPublicController(
|
||||
private val contentService: ContentService,
|
||||
private val assetStorage: AssetStorage,
|
||||
private val accessService: CourseAccessService,
|
||||
) {
|
||||
@GetMapping("/tree")
|
||||
fun tree() = contentService.tree()
|
||||
fun tree(
|
||||
@AuthenticationPrincipal jwt: Jwt?,
|
||||
) = contentService.tree(role = jwt?.getClaimAsString("role"))
|
||||
|
||||
@GetMapping("/{chapter}/{section}/{article}")
|
||||
fun article(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article)
|
||||
return ContentArticleViewDto(
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug,
|
||||
sectionTitle = a.sectionTitle,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
@AuthenticationPrincipal jwt: Jwt?,
|
||||
): ContentArticleViewDto =
|
||||
contentService
|
||||
.findArticle(chapter, section, article, role = jwt?.getClaimAsString("role"))
|
||||
.toDto()
|
||||
|
||||
data class ContentArticleViewDto(
|
||||
val id: String,
|
||||
@@ -55,7 +69,14 @@ class ContentPublicController(
|
||||
@GetMapping("/assets/{name}")
|
||||
fun asset(
|
||||
@PathVariable name: String,
|
||||
@AuthenticationPrincipal jwt: Jwt?,
|
||||
): ResponseEntity<FileSystemResource> {
|
||||
val accesses = contentService.assetAccesses(name)
|
||||
if (accesses.isEmpty()) {
|
||||
requireLegacyAssetAccess(jwt)
|
||||
} else {
|
||||
requireAssetAccess(accesses, jwt)
|
||||
}
|
||||
val (path, mime) =
|
||||
assetStorage.read(name)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
@@ -75,4 +96,120 @@ class ContentPublicController(
|
||||
builder.header("X-Content-Type-Options", "nosniff")
|
||||
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 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
|
||||
@@ -12,6 +14,9 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
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.LabDetail
|
||||
import ru.ysu.lms.academic.lab.LabImportResult
|
||||
@@ -26,99 +31,257 @@ import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/labs")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
class LabController(
|
||||
private val labService: LabService,
|
||||
private val accessService: CourseAccessService,
|
||||
private val audit: AuditClient,
|
||||
) {
|
||||
/**
|
||||
* Без `courseId` список ограничен доступными курсами (admin видит все).
|
||||
* С `courseId` доступ к курсу проверяется явно.
|
||||
*/
|
||||
@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}")
|
||||
fun detail(
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun importLab(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(required = false) courseId: UUID?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabImportResult {
|
||||
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}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
labService.delete(id)
|
||||
audit.record(jwt.subject, "LAB_DELETE", ENTITY_LAB, id.toString())
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun addVariant(
|
||||
@PathVariable id: UUID,
|
||||
@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}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun updateVariant(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable variantId: UUID,
|
||||
@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}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun deleteVariant(
|
||||
@PathVariable id: 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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun toggleStatus(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
labService.toggleStatus(id)
|
||||
audit.record(jwt.subject, "LAB_PUBLISH", ENTITY_LAB, id.toString())
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun create(
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun addLevel(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LevelSaveRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
labService.addLevel(id, body)
|
||||
audit.record(jwt.subject, "LAB_LEVEL_ADD", ENTITY_LAB, id.toString(), details = "code=${body.code}")
|
||||
return labService.detail(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun renameLevel(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable levelId: UUID,
|
||||
@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}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun deleteLevel(
|
||||
@PathVariable id: 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.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@@ -12,9 +13,11 @@ import java.util.UUID
|
||||
class ContentChapter(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@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 published: Boolean = true,
|
||||
@Column(name = "course_id") var courseId: UUID? = null,
|
||||
@Column(length = 16) var audience: String? = null,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@@ -39,3 +42,19 @@ class ContentArticle(
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@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)
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
var semester: Semester? = null,
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "course_id")
|
||||
var course: Course? = null,
|
||||
@Column(name = "description_md", nullable = false)
|
||||
var descriptionMd: String = "",
|
||||
@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
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
data class LabMeta(
|
||||
val number: Int,
|
||||
@@ -52,6 +53,9 @@ data class LabSummary(
|
||||
val defenseAt: String?,
|
||||
val pointsMax: BigDecimal,
|
||||
val variantCount: Int,
|
||||
val courseId: String? = null,
|
||||
val courseNo: Long? = null,
|
||||
val courseTitle: String? = null,
|
||||
)
|
||||
|
||||
data class LabDetail(
|
||||
@@ -64,6 +68,9 @@ data class LabDetail(
|
||||
val defenseAt: String?,
|
||||
val pointsMax: BigDecimal,
|
||||
val levels: List<LabLevelDetail>,
|
||||
val courseId: String? = null,
|
||||
val courseNo: Long? = null,
|
||||
val courseTitle: String? = null,
|
||||
)
|
||||
|
||||
data class LabLevelDetail(
|
||||
@@ -114,6 +121,13 @@ data class LabCreateRequest(
|
||||
val status: String? = null,
|
||||
val descriptionMd: String? = null,
|
||||
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(
|
||||
|
||||
@@ -2,10 +2,12 @@ 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.Semester
|
||||
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.SemesterRepository
|
||||
@@ -20,27 +22,32 @@ import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Service
|
||||
class LabService(
|
||||
private val labs: LabRepository,
|
||||
private val labLevels: LabLevelRepository,
|
||||
private val variantsRepo: VariantRepository,
|
||||
private val semesters: SemesterRepository,
|
||||
private val courses: CourseRepository,
|
||||
private val copyService: LabCopyService,
|
||||
private val parser: LabImportParser,
|
||||
) {
|
||||
@Transactional
|
||||
fun importFromFile(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
courseId: UUID? = null,
|
||||
): LabImportResult {
|
||||
val bundle = parser.parse(filename, bytes)
|
||||
val course = courses.resolveCourse(courseId)
|
||||
val semester =
|
||||
semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
|
||||
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
||||
if (labs.existsByNumberAndCourseId(bundle.meta.number, course.id)) {
|
||||
throw ConflictException(
|
||||
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.",
|
||||
"Лабораторная №${bundle.meta.number} уже существует в этом курсе. Удалите её перед повторным импортом.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ class LabService(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
semester = semester,
|
||||
course = course,
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
status = bundle.meta.status,
|
||||
@@ -93,61 +101,42 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<LabSummary> =
|
||||
labs.findAllByOrderByNumberAsc().map { lab ->
|
||||
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
||||
LabSummary(
|
||||
id = lab.id.toString(),
|
||||
number = lab.number,
|
||||
title = lab.title,
|
||||
status = lab.status,
|
||||
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
|
||||
deadlineAt = lab.deadlineAt?.toString(),
|
||||
defenseAt = lab.defenseAt?.toString(),
|
||||
pointsMax = lab.pointsMax,
|
||||
variantCount = variants.size,
|
||||
)
|
||||
}
|
||||
fun list(courseId: UUID? = null): List<LabSummary> {
|
||||
val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
|
||||
return labList.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
|
||||
}
|
||||
|
||||
/** Список лабораторных только по доступным курсам (курсовая область видимости). */
|
||||
@Transactional(readOnly = true)
|
||||
fun list(courseIds: Collection<UUID>): List<LabSummary> {
|
||||
if (courseIds.isEmpty()) return emptyList()
|
||||
return labs
|
||||
.findAllByCourseIdInOrderByNumberAsc(courseIds)
|
||||
.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(labId: UUID): LabDetail {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
|
||||
return LabDetail(
|
||||
id = lab.id.toString(),
|
||||
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,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
return lab.toDetail(
|
||||
labLevels.findByLabIdOrderByPosition(lab.id),
|
||||
variantsRepo.findByLabIdOrderByNumber(lab.id),
|
||||
)
|
||||
}
|
||||
|
||||
/** Все лабораторные курса с уровнями и вариантами: три запроса вместо 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
|
||||
fun delete(labId: UUID) {
|
||||
if (!labs.existsById(labId)) {
|
||||
@@ -283,9 +272,10 @@ class LabService(
|
||||
@Transactional
|
||||
fun create(request: LabCreateRequest): LabDetail {
|
||||
validateCreate(request)
|
||||
val course = courses.requireCourse(request.courseId)
|
||||
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
|
||||
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
|
||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
|
||||
if (labs.existsByNumberAndCourseId(request.number ?: 0, course.id)) {
|
||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом курсе")
|
||||
}
|
||||
val lab =
|
||||
labs.save(
|
||||
@@ -293,6 +283,7 @@ class LabService(
|
||||
number = request.number ?: 0,
|
||||
title = request.title.orEmpty().trim(),
|
||||
semester = semester,
|
||||
course = course,
|
||||
descriptionMd = request.descriptionMd.orEmpty(),
|
||||
deadlineAt = parseInstant(request.deadlineAt),
|
||||
defenseAt = parseInstant(request.defenseAt),
|
||||
@@ -304,6 +295,13 @@ class LabService(
|
||||
return detail(lab.id)
|
||||
}
|
||||
|
||||
/** Копирует лабораторную, её уровни и варианты в другой курс. Прогресс не копируется. */
|
||||
@Transactional
|
||||
fun copy(
|
||||
sourceLabId: UUID,
|
||||
request: LabCopyRequest,
|
||||
): LabDetail = detail(copyService.copy(sourceLabId, request))
|
||||
|
||||
@Transactional
|
||||
fun renameLevel(
|
||||
labId: UUID,
|
||||
@@ -374,14 +372,91 @@ class LabService(
|
||||
}
|
||||
return semesters.save(Semester(title = t, year = y, active = true))
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Явно заданный курс или единственный существующий (для импорта без 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 Lab.toDetail(
|
||||
levels: List<LabLevel>,
|
||||
variants: List<Variant>,
|
||||
): LabDetail {
|
||||
val variantsByLevel = variants.groupBy { it.labLevel?.id }
|
||||
return LabDetail(
|
||||
id = id.toString(),
|
||||
number = number,
|
||||
title = title,
|
||||
status = status,
|
||||
descriptionMd = descriptionMd,
|
||||
deadlineAt = deadlineAt?.toString(),
|
||||
defenseAt = defenseAt?.toString(),
|
||||
pointsMax = pointsMax,
|
||||
levels =
|
||||
levels.map { level ->
|
||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||
val vs =
|
||||
variantsByLevel[level.id]
|
||||
.orEmpty()
|
||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||
LabLevelDetail(
|
||||
id = level.id.toString(),
|
||||
code = level.code,
|
||||
title = level.title,
|
||||
variants =
|
||||
vs.map { v ->
|
||||
VariantDetail(
|
||||
id = v.id.toString(),
|
||||
number = v.number,
|
||||
title = v.title,
|
||||
statementMd = v.statementMd,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
courseId = course?.id?.toString(),
|
||||
courseNo = course?.courseNo,
|
||||
courseTitle = course?.title,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Lab.toSummary(variants: List<Variant>): LabSummary =
|
||||
LabSummary(
|
||||
id = id.toString(),
|
||||
number = number,
|
||||
title = title,
|
||||
status = status,
|
||||
semesterTitle = semester?.let { "${it.title} ${it.year}" } ?: "—",
|
||||
deadlineAt = deadlineAt?.toString(),
|
||||
defenseAt = defenseAt?.toString(),
|
||||
pointsMax = pointsMax,
|
||||
variantCount = variants.size,
|
||||
courseId = course?.id?.toString(),
|
||||
courseNo = course?.courseNo,
|
||||
courseTitle = course?.title,
|
||||
)
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.ysu.lms.academic.repo
|
||||
|
||||
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.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
@@ -11,6 +12,19 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||
|
||||
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?
|
||||
}
|
||||
|
||||
@@ -45,3 +59,13 @@ interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
||||
|
||||
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> {
|
||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||
|
||||
fun existsByNumberAndSemesterId(
|
||||
fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
|
||||
|
||||
fun findAllByCourseIdInOrderByNumberAsc(courseIds: Collection<UUID>): List<Lab>
|
||||
|
||||
fun existsByNumberAndCourseId(
|
||||
number: Int,
|
||||
semesterId: UUID,
|
||||
courseId: UUID,
|
||||
): Boolean
|
||||
|
||||
fun countByCourseId(courseId: UUID): Long
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ import java.util.UUID
|
||||
|
||||
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
|
||||
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
|
||||
|
||||
fun findByLabIdInOrderByPosition(labIds: Collection<UUID>): List<LabLevel>
|
||||
}
|
||||
|
||||
interface VariantRepository : JpaRepository<Variant, UUID> {
|
||||
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
|
||||
|
||||
fun findByLabIdInOrderByNumberAsc(labIds: Collection<UUID>): List<Variant>
|
||||
}
|
||||
|
||||
@@ -43,3 +43,7 @@ springdoc:
|
||||
lms:
|
||||
content:
|
||||
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