баг-фикс 30/34

This commit is contained in:
Zuev
2026-07-19 14:40:43 +03:00
parent 3d798c13e3
commit bc0e1ab1b4
172 changed files with 13431 additions and 2910 deletions

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
# Скопируйте файл в .env и замените пустые значения локальными секретами.
POSTGRES_USER=myuser
POSTGRES_PASSWORD=
POSTGRES_DB=app_db
# Сгенерировать: openssl rand -base64 48
JWT_SECRET=
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
JWT_REFRESH_COOKIE_NAME=magistr_refresh
JWT_REFRESH_COOKIE_SECURE=false
JWT_REFRESH_CLEANUP_ENABLED=true
JWT_REFRESH_CLEANUP_RETENTION=30d
JWT_REFRESH_CLEANUP_BATCH_SIZE=500
JWT_REFRESH_CLEANUP_MAX_BATCHES=20
JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000
JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000
# Защита входа. Указывайте только адреса/сети фактических reverse proxy.
LOGIN_RATE_MAX_FAILURES=5
LOGIN_RATE_ATTEMPT_WINDOW=15m
LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_CLEANUP_ENABLED=true
LOGIN_AUDIT_RETENTION=90d
LOGIN_AUDIT_CLEANUP_BATCH_SIZE=500
LOGIN_AUDIT_CLEANUP_MAX_BATCHES=20
LOGIN_AUDIT_CLEANUP_INTERVAL_MS=86400000
LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS=120000
TRUSTED_PROXY_CIDRS=172.16.0.0/12
# Календарные бизнес-даты приложения.
BUSINESS_TIME_ZONE=Europe/Moscow
TZ=Europe/Moscow
# Локально Collector не входит в Compose. Укажите false при подключённом OTLP Collector.
OTEL_SDK_DISABLED=true
# Измените, если порт 80 занят.
HTTP_PORT=80

126
.gitea/workflows/docker-build.yaml Executable file → Normal file
View File

@@ -1,4 +1,4 @@
name: Build and Push Docker Images
name: Проверка, сборка и развёртывание Magistr
on:
push:
@@ -7,32 +7,79 @@ on:
tags:
- 'v*'
concurrency:
group: magistr-production
cancel-in-progress: false
env:
REGISTRY: gitea.zuev.company # Замените на реальный домен вашего Gitea
REGISTRY: gitea.zuev.company
BACKEND_IMAGE: zuev/magistr-backend
FRONTEND_IMAGE: zuev/magistr-frontend
jobs:
build-and-push-backend:
checks:
name: Обязательные проверки
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
- name: Получить исходный код
uses: actions/checkout@v4
- name: Log in to the Container registry
- name: Backend unit и component tests
run: >-
docker run --rm
-v "${{ github.workspace }}/backend:/workspace"
-w /workspace
maven:3.9.9-eclipse-temurin-17
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
- name: Frontend static и unit tests
run: >-
docker run --rm
-v "${{ github.workspace }}/frontend:/workspace"
-w /workspace
node:22-alpine3.23
sh -c "npm ci && npm run check"
- name: Проверить Docker Compose
env:
POSTGRES_PASSWORD: ci-config-only
JWT_SECRET: ci-only-secret-not-for-runtime-0123456789abcdef0123456789abcdef
run: docker compose config --quiet
- name: Проверить сценарий immutable rollout и rollback
run: bash scripts/test-deploy-images.sh
build-and-push-backend:
name: Собрать backend image
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Получить исходный код
uses: actions/checkout@v4
- name: Войти в Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }} # Нужно создать секрет ZUEV_TOKEN в настройках репозитория (Personal Access Token)
password: ${{ secrets.ZUEV_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Сформировать immutable tags и labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
flavor: latest=false
tags: |
type=sha,format=long,prefix=sha-
type=ref,event=tag
- name: Build and push Docker image
- name: Собрать и опубликовать backend image
id: build
uses: docker/build-push-action@v6
with:
context: ./backend
@@ -41,26 +88,38 @@ jobs:
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
build-and-push-frontend:
name: Собрать frontend image
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout repository
- name: Получить исходный код
uses: actions/checkout@v4
- name: Log in to the Container registry
- name: Войти в Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Сформировать immutable tags и labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
flavor: latest=false
tags: |
type=sha,format=long,prefix=sha-
type=ref,event=tag
- name: Build and push Docker image
- name: Собрать и опубликовать frontend image
id: build
uses: docker/build-push-action@v6
with:
context: ./frontend
@@ -69,29 +128,38 @@ jobs:
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
deploy-to-k8s:
name: Развернуть проверенные digests
needs: [build-and-push-backend, build-and-push-frontend]
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
env:
BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }}
FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }}
KUBECTL_VERSION: v1.33.12
KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e
steps:
- name: Create kubeconfig
- name: Получить deploy-скрипт
uses: actions/checkout@v4
- name: Создать kubeconfig
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
install -d -m 700 "$HOME/.kube"
printf '%s' "$KUBECONFIG_DATA" | base64 --decode > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
- name: Install kubectl
- name: Установить проверенный kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Trigger Kubernetes Rollout
run: |
# Перезапускаем поды, чтобы они скачали свежий :main образ
kubectl rollout restart deployment backend frontend -n magistr
# Ждём успешного обновления (5 минут на backend из-за Spring Boot)
kubectl rollout status deployment/frontend -n magistr --timeout=120s
kubectl rollout status deployment/backend -n magistr --timeout=300s
curl --fail --show-error --silent --location --retry 3 \
--output kubectl \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
echo "${KUBECTL_SHA256} kubectl" | sha256sum --check --strict
sudo install -m 0755 kubectl /usr/local/bin/kubectl
- name: Применить digests и проверить rollout
run: bash scripts/deploy-images.sh "$BACKEND_REF" "$FRONTEND_REF"

View File

@@ -1,23 +1,30 @@
# Журнал исправления проблем
Обновлено: 2026-07-17 (Europe/Moscow).
Обновлено: 2026-07-19 (Europe/Moscow).
## Область и неизменяемые данные
- В работе ровно 33 проблемы: `1` и `334`.
- Проблема № 2 исключена: демонстрационные учётные записи, seed-данные и документация по ним не изменяются.
- `BUG_REPORT.md` не редактируется.
- Существующие Flyway-миграции не редактируются.
- По прямому разрешению владельца проекта миграции V2V7 удалены, а их итоговая схема
объединена в `V1__init.sql`: проект находится на этапе разработки, клиентских tenant-БД нет.
Для этой редакции требуется полный сброс локальной БД.
Исходное рабочее дерево перед началом реализации было чистым (`git status --short` без вывода).
Контрольные суммы исходных миграций:
Контрольные суммы исходных миграций до разрешённой консолидации (историческая справка):
| Файл | SHA-256 |
|---|---|
| `V1__init.sql` | `404f65f270dd87f4db996495fc970456c4fd0f762e7c32638199dffaa812ea7b` |
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
Текущая единая baseline-миграция: `V1__init.sql`, SHA-256
`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`.
Упоминания V2V7 в исторических записях ниже описывают последовательность разработки до
консолидации и не означают наличие этих файлов в текущем дереве.
## Исходная линия проверок
| Проверка | Результат до изменений |
@@ -50,36 +57,36 @@
| 1 | требуется внешнее действие | В коде и production YAML были встроенные/placeholder JWT и DB credentials; tenant и OTel credentials находились в отслеживаемой конфигурации; Secure-cookie не был обязательным для production. | Встроенный JWT default удалён; добавлен fail-fast validator; production использует только внешние `app-secret`, `tenants-secret`, `otel-postgres-secret`; создан `docs/SECURITY_RUNBOOK.md`. Репозиторная часть исправлена. Оператору остаются ротация, provisioning, rollout и очистка истории. | 11 JWT unit/context tests; общий backend: 45/0/0; `scripts/check-production-secrets.sh`; Kustomize и shell syntax — успешно. |
| 3 | исправлено и проверено | Ротация refresh-токена выполняла read-check-write без блокировки/условного update. | `RefreshTokenService.rotate()` использует блокирующий `findByTokenHashForUpdate()` с `PESSIMISTIC_WRITE` и `JOIN FETCH` внутри транзакции. | Два синхронных запроса к Spring proxy и PostgreSQL: один успех, один отказ, один активный новый токен. Полный backend: 46/0/0/0. |
| 4 | исправлено и проверено | `AuthContext` устанавливался до role-check и сохранялся в servlet-потоке, когда `preHandle=false` не приводил к `afterCompletion`. | `AuthorizationInterceptor` очищает контекст в начале каждого запроса и устанавливает пользователя только после успешной проверки ролей; финальная очистка сохранена. | Один MockMvc-поток: `403`, публичный запрос, разрешённый запрос и очистка после завершения. Полный backend: 47/0/0/0. |
| 5 | исправлено и проверено | Override сохранялся без доказательства исходной пары, action-specific полей и ресурсных конфликтов. | `ScheduleOverrideService` строит base/effective day, валидирует матрицу и фактическое изменение, проверяет ресурсы и сериализует CRUD через PostgreSQL advisory locks; V3 закрепляет структурные инварианты. | 27 целевых unit/MockMvc/PostgreSQL tests; полный backend: 74/0/0/0. |
| 5 | исправлено и проверено | Override сохранялся без доказательства исходной пары, action-specific полей и ресурсных конфликтов. | `ScheduleOverrideService` строит base/effective day, валидирует матрицу и фактическое изменение, проверяет ресурсы и сериализует CRUD через PostgreSQL advisory locks; структурные CHECK включены в V1. | 27 целевых unit/MockMvc/PostgreSQL tests; полный backend: 74/0/0/0. |
| 6 | исправлено и проверено | Teacher-only поиск загружал только базовые правила исходного преподавателя до применения override. | Один snapshot overrides; релевантные `newTeacher`-замены достраивают exact base occurrences по уникальным датам, затем применяются overrides, финальный teacher-фильтр и deduplicate. | 5 новых A→B/phantom/cache/dedup/regression тестов; узкий прогон 10/0/0/0, полный backend 79/0/0/0. |
| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. V4 закрепляет чётность и точный дубль в БД. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. |
| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. Чётность и точный дубль закреплены в V1. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. |
| 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. |
| 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ persistence мог сочетаться с изменённым локальным состоянием. | `TenantLifecycleService` выполняет prepare → connection validation → Flyway → Secret persistence → atomic snapshot swap; candidate закрывается при отказе, прежний route сохраняется, Secret компенсируется, старый pool закрывается после drain. | 18 целевых unit/MockMvc/PostgreSQL tests; create/update, credentials, invalid connection, Flyway, persistence, swap, HTTP `503` и in-flight connection. Полный backend: 132/0/0/0. |
| 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. |
| 11 | исправлено и проверено | Воскресенье вычислялось повторным мутированием одной даты, date-only строился через UTC, а ошибки кафедральных запросов превращались в пустой успешный результат. | `dashboard-conflicts.js` формирует локальный диапазон на отдельных объектах, загружает кафедры через `Promise.allSettled` и fail-closed различает `COMPLETE`/`PARTIAL`/`NOT_RUN`; зелёная карточка разрешена только для полного результата без конфликтов. | 12 `node:test`: 02.07.2026 00:15 Europe/Moscow, переход года, полный/частичный/нулевой успех и строгий UI-контракт; `npm run check` и синтаксис 25 JS/MJS-файлов — успешно. |
| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. |
| 12 | исправлено и проверено | Четыре frontend-клиента хранили access JWT и профиль в Web Storage; login/admin исполняли OTel с `esm.sh`, а inline JS/styles не позволяли включить строгий CSP. | Общий `auth-session.js` держит access JWT только в памяти и восстанавливает его через HttpOnly refresh-cookie; OTel собирается из exact npm-зависимостей в same-origin bundle; Apache выдаёт CSP без `unsafe-inline`/`unsafe-eval`. | 21 frontend test, `npm audit` 0 vulnerabilities, Docker build и live HTTP/CSP/bundle check — успешно. |
| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. |
| 14 | исправлено и проверено | Kubernetes HTTP-клиент имел trust-all TLS fallback. | `KubernetesTenantSecretUpdater` загружает service-account CA, строит PKIX trust store, включает HTTPS hostname verification и не имеет небезопасного fallback. | Mock HTTPS: доверенный CA принят; чужой CA, неверный hostname и отсутствующий CA отклонены. Backend 45/0/0. |
| 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. |
| 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
| 17 | не начато | Изменение измерений группы/календаря оставляет несовместимые assignments/grid/subjects. | Отклонять несовместимое изменение транзакционно с `409`; контролировать `courseCount`. | Assignment сохранён после отказа; старшие строки не остаются. |
| 18 | не начато | Бизнес-решения смешивают `users.department_id` с датированными assignments и фильтруют архивные записи по текущему состоянию. | Date-aware resolver кафедры и единое использование истории. | Будущий перевод, прошлый период, архивный преподаватель, совместительство. |
| 19 | не начато | Workload принимает произвольный `departmentId` для роли `DEPARTMENT`. | Принудительный scope из `AuthContext`; глобальный доступ только разрешённым ролям. | Межкафедральный запрос кафедры не раскрывает данные. |
| 20 | не начато | Агрегаты переиспользуют интерактивный широкий поиск с лимитом 50 групп. | Специализированная batch/aggregate генерация без UI-лимита. | 51 и 100 активных групп. |
| 21 | не начато | Импорт ищет дисциплину по глобальному имени и меняет её владельца. | `409` для чужой записи, идемпотентность своей; scoped uniqueness при миграции. | Повторный свой импорт и совпадение чужой кафедры. |
| 22 | не начато | Видимые EDUCATION_OFFICE экраны вызывают backend endpoints с ADMIN-only правами. | Единая role matrix для backend/frontend; согласовать read/write capabilities. | Интеграционный сценарий календаря и форм обучения без штатного `403`. |
| 23 | не начато | Генератор повторно запрашивает semester/assignment/calendar day на каждой комбинации. | Батч-загрузка и request-scoped lookup maps. | Query-count test на 120 датах и десятках групп. |
| 24 | не начато | Истёкшие/отозванные refresh rows не удаляются. | Tenant-aware scheduled cleanup с retention и безопасностью двух pod. | Active/fresh rows сохраняются; cleanup идемпотентен и конкурентно безопасен. |
| 25 | не начато | Нет общего mapping DB constraints; контроллеры возвращают клиенту `getMessage()`. | Централизованный sanitizer/mapping `400`/`409`, убрать raw exception body. | Известный/неизвестный constraint без JDBC details. |
| 26 | не начато | Compose требует внешнюю сеть, не публикует HTTP, рассогласует DB/JWT и не имеет named volume. | Самодостаточная сеть/reverse proxy, согласованные env и volume. | Чистый `docker compose up -d --build`, HTTP `/` и `/api`. |
| 27 | не начато | Pipeline собирает/деплоит без обязательных тестов и для tag перезапускает mutable `main`. | Test gates, concurrency, immutable tag/digest rollout, rollback check. | Workflow lint/static inspection и shell tests. |
| 28 | не начато | Login не имеет shared rate limit; IP безусловно доверяет forwarded header. | PostgreSQL-backed tenant+trusted-IP+username limiter, lockout/audit, `429`/`Retry-After`. | Multi-instance integration tests, отсутствие user enumeration. |
| 15 | исправлено и проверено | CRUD слотов жил в контроллере, проверял только номер пары и принимал клиентскую длительность; DB не запрещала overlap и перенос используемого базового слота. | Транзакционный `TimeSlotService` блокирует сетки, валидирует границы, вычисляет duration и отклоняет overlap; V1 содержит duration CHECK, GiST exclusion и триггеры привязки правил к `DEFAULT`; UI показывает расчётное readonly-поле. | 6 unit; общий backend без Testcontainers 209/0/0/0; чистая V1 и конкурентные DB-сценарии проверены на PostgreSQL 16.3. |
| 16 | исправлено и проверено | CRUD проверял только порядок дат; update не проверял duplicate title/type, годы и семестры пересекались, а семестр мог выходить за год. | `AcademicPeriodService` централизует inclusive-range validation и блокирует родительский год; V1 содержит GiST exclusion constraints и взаимные триггеры границ года/семестра. | 8 unit; общий backend без Testcontainers 209/0/0/0; чистая V1, adjacent/overlap/bounds и конкурентные годы проверены на PostgreSQL 16.3. |
| 17 | исправлено и проверено | Изменение измерений группы/календаря оставляло несовместимые assignments/grid/subjects. | `AcademicStructureService` транзакционно блокирует группу/график, перевалидирует назначения, курсы, сетку и дисциплины; V1 дублирует инварианты конкурентно безопасными триггерами. Несовместимое изменение даёт русский `409` без мутации данных. | 7 unit; общий backend без Testcontainers 209/0/0/0; чистая V1, DB-отказы и гонка двух транзакций проверены на PostgreSQL 16.3. |
| 18 | исправлено и проверено | Бизнес-решения смешивали `users.department_id` с датированными assignments и фильтровали архивные записи по текущему состоянию. | `TeacherDepartmentService` централизует разрешение кафедры на дату; списки, права и нагрузка используют историю, будущий перевод не меняет текущую принадлежность; V1 запрещает overlap основных периодов. | 5 service + 7 controller tests; полный backend 216/0/0/0; чистая V1, overlap и конкурентная запись проверены на PostgreSQL 16.3. |
| 19 | исправлено и проверено | Workload принимал произвольный `departmentId` или глобальный scope для роли `DEPARTMENT`. | Все пять workload-сценариев вычисляют scope через `AuthContext`: кафедра получает только свою выборку, чужой ID даёт русский `403`; глобальный scope сохранён уполномоченным ролям. | 5 controller tests; полный backend 219/0/0/0; compose и diff-check успешны. |
| 20 | исправлено и проверено | Workload и free-classrooms переиспользовали интерактивный широкий поиск, который отклонял tenant с более чем 50 активными группами. | Добавлен узкий `searchForAggregation`: общие validation/overrides/filter/dedup сохранены, UI-лимит отключён только для агрегатов; обычный search не изменён. | 51 и 100 активных групп обработаны полностью; целевые 9/0/0/0, полный backend 221/0/0/0. |
| 21 | исправлено и проверено | Импорт находил дисциплину по глобальному имени и без проверки менял её `department_id`. | Транзакционный `SubjectImportService` сохраняет глобальное владение: свой повтор обновляет тот же ID, чужой даёт `409`; V1 содержит case-insensitive уникальность названия. | 4 unit; полный backend 225/0/0/0; чистая V1, регистровый дубль и конкурентная вставка проверены на PostgreSQL 16.3. |
| 22 | исправлено и проверено | Календарный график учебного отдела получал `403` на specialties/profiles, а видимый CRUD форм обучения был ADMIN-only. | EDUCATION_OFFICE получила read-only specialities/profiles и CRUD education forms; frontend admin/settings используют единый `role-capabilities.js`. | 2 MockMvc role-сценария через реальный interceptor; frontend role test; полный backend 227/0/0/0, npm check успешен. |
| 23 | исправлено и проверено | Генератор искал semester внутри дат и правил, а assignment/calendar day и эффективную сетку времени — внутри комбинаций дат и групп. | `AcademicDateService.ScheduleSnapshot`, batch-репозитории и `buildScheduleForGroups()` загружают данные один раз на запрос; lookup-карты используются во всех внутренних циклах. | 120 дат, 30 групп и 20 правил: ровно 7 repository reads, 30 корректных занятий; полный backend 228/0/0/0. |
| 24 | исправлено и проверено | Login/refresh добавляли строки, а revoke/rotate только ставили `revoked_at`; cleanup отсутствовал. | `RefreshTokenCleanupJob` обходит snapshot tenant-БД, удаляет старые строки ограниченными транзакционными пачками с `FOR UPDATE SKIP LOCKED`; retention и расписание настраиваются, cleanup-индексы включены в V1. | 3 unit; полный backend 231/0/0/0; точная V1 и две конкурентные PostgreSQL-транзакции удалили разные пачки, fresh/active сохранены, повтор удалил 0. |
| 25 | исправлено и проверено | Не было общего mapping DB constraints; несколько контроллеров включали `Exception.getMessage()` в HTTP-body. | `GlobalExceptionHandler` и `DatabaseConstraintViolationMapper` централизованно преобразуют CHECK в безопасный русский `400`, UNIQUE/FK/GiST и неизвестные нарушения — в `409`; raw exception body удалён. | 4 новых теста известных и неизвестных constraints без JDBC/SQL details; полный backend 235/0/0/0. |
| 26 | исправлено и проверено | Compose требовал внешнюю сеть, не публиковал HTTP, рассогласовывал DB/JWT и использовал anonymous volume. Live startup также выявил два неоднозначных Spring-конструктора. | Внутренняя bridge-сеть, Apache reverse proxy и `HTTP_PORT`; согласованные datasource/JWT env, healthchecks, named volume; production-конструкторы явно помечены для DI. | `up --build --wait`; PostgreSQL/backend/frontend healthy, `/` и liveness `200`, API `401`, named volume; полный backend 235/0/0/0. |
| 27 | исправлено и проверено | Pipeline собирал и деплоил без тестов, а release tag только перезапускал Deployment на mutable `:main`. | Обязательный checks job, production concurrency lock, SHA/release build tags и deploy только digest из build outputs; общий shell rollback обоих Deployment. | Workflow YAML/static scan, shell success/failure/rollback, production-secret scan и Kustomize — успешно. |
| 28 | исправлено и проверено | Login не имел shared rate limit, различал архивного пользователя и безусловно доверял forwarded header. | Транзакционный PostgreSQL limiter по tenant + NFKC username + проверенному IP; прогрессивная блокировка, audit/cleanup, единый `401`, `429`/`Retry-After` и trusted proxy chain. | 12 целевых unit/PostgreSQL tests; два service instance дали ровно один `401` и один `429`, общий failure count 2; V1 и cleanup проверены. |
| 29 | не начато | Business dates зависят от timezone процесса; frontend date-only строится через UTC. | Инъекция `Clock`/`Europe/Moscow`, UTC timestamps, локальное форматирование date-only, TZ контейнеров. | Boundary tests 00:0003:00 и первые дни месяца. |
| 30 | не начато | Образы/скачиваемые инструменты используют mutable tags/latest без checksum/SBOM/scan. | Pin versions+digests, SHA verification, SBOM и image scan gates. | Static scan production refs и CI configuration. |
| 31 | не начато | Group create/update допускает неположительные значения и уменьшение ниже активных подгрупп. | Общий validator, locking, новые CHECK constraints. | Create/update/concurrency и migration tests. |
| 32 | не начато | `between(start,end) > 120` разрешает 121 включительную дату. | Проверять `between + 1 <= 120` в обоих сервисах. | Границы 120/121. |
| 33 | не начато | Несколько `e.message` вставляются как HTML; парольные поля имеют `type=text`. | `textContent`/экранирование; `type=password` и autocomplete. | DOM/XSS static/unit test и HTML assertions. |
| 34 | не начато | В затронутых UI/log paths остаются английские метки/логи и raw exception text. | Русификация и безопасные сообщения на границе API/UI. | Поиск английских production сообщений и regression tests. |
| 33 | исправлено и проверено | Несколько `e.message` вставлялись как HTML; парольные поля имели `type=text`. | Ошибки вкладок, БД и заявок рендерятся фиксированным текстом через `textContent`/`replaceChildren`; поля создания пользователя, tenant и одобрения заявки используют `type=password` и `autocomplete=new-password`. | 3 новых static regression tests; полный frontend: 24 проверки, 0 ошибок; CSP-хэши синхронизированы. |
| 34 | исправлено и проверено | В затронутых UI/log paths оставались английские метки/логи и raw exception text. | Статусы БД и пользовательские ошибки русифицированы; начальная tenant-конфигурация, маршрутизация и interceptor пишут русские production-сообщения с безопасным типом исключения. | Static-проверка исходных английских сообщений; полный backend 200/0/0/0; live Docker HTTP/CSP — успешно. |
## Завершённые этапы
@@ -418,17 +425,515 @@
| `git diff --check` | Успешно. |
| Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. |
### № 12 — access JWT только в памяти и same-origin JavaScript
- Добавлен единый `frontend/auth-session.js` для login, admin/settings, teacher и student.
Access JWT, роль, кафедра и ID пользователя существуют только в памяти ES-модуля;
legacy-ключи удаляются из `localStorage` и `sessionStorage`, но больше не читаются и не
записываются. После navigation/reload новый документ восстанавливает профиль через
`POST /api/auth/refresh` и `HttpOnly` cookie.
- `fetchWithAuth()` добавляет bearer-токен из памяти, дедуплицирует параллельные refresh-
запросы одним Promise и повторяет исходный запрос не более одного раза. Logout отзывает
серверную cookie-сессию и очищает память даже при сетевой ошибке.
- Inline CSS/JS кабинетов преподавателя и студента вынесены в `style.css`/`app.js`; inline-
редиректы кафедры и учебного отдела удалены. Admin/settings запускают UI только после
успешного async session bootstrap и проверяют роль из общего профиля.
- Удалён `admin/js/otel.js` с runtime-import из `esm.sh`. OpenTelemetry 2.9.0/0.220.0 и
auto-instrumentations 0.65.0 зафиксированы exact-версиями в `package-lock.json`, собираются
esbuild в `/vendor/otel.js` на первом этапе Dockerfile и загружаются только с текущего
origin. `npm audit` не обнаруживает уязвимостей.
- Apache подключает `security.conf`: `script-src 'self'`, `script-src-attr 'none'`,
`style-src 'self'`, exact SHA-256 для статических style-атрибутов, `connect-src 'self'`,
запрет frame/object и дополнительные защитные заголовки. `unsafe-inline`, `unsafe-eval`,
удалённые JS imports и динамические inline-стили отсутствуют; тест проверяет полный набор
CSP-хэшей. Базовые frontend-образы зафиксированы digest; остальные пункты № 30 остаются в
его собственном объёме.
- AutoUpdateDocs синхронизировал `docs/FRONTEND.md`, `docs/INFRASTRUCTURE.md`,
`docs/ARCHITECTURE.md`, `docs/API.md` и устаревший пример в `docs/DEVELOPMENT.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `npm run check` в `frontend/` | 21 тест: 12 dashboard + 5 auth session + 4 CSP/security; синтаксис критических модулей и все тесты успешны. |
| `npm audit --audit-level=moderate` | `found 0 vulnerabilities`. |
| Локальный `npm run build:vendor` | Same-origin bundle `dist/vendor/otel.js`, 327471 байт; удалённых imports нет. |
| `docker build -t magistr-frontend-codex:bug12 frontend` | Multi-stage npm/esbuild → Apache завершён успешно. |
| Live container HTTP check | `/` и `/vendor/otel.js` вернули `200`; CSP и дополнительные headers присутствуют, `unsafe-inline`/`unsafe-eval` отсутствуют. Временный контейнер остановлен и удалён. |
| `git diff --check` | Успешно. |
| Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. |
### № 33 и № 34 — безопасный DOM, скрытые пароли и языковой регламент
- Для ошибок загрузки SPA-вкладок, статуса/списка tenant-БД и заявок преподавателей добавлены
общие `renderStatusMessage()`/`renderTableMessage()`. Они создают DOM-узлы, записывают
фиксированный русский текст через `textContent` и атомарно заменяют содержимое через
`replaceChildren`; `exception.message` больше не попадает в эти UI-границы.
- Поля пароля при создании пользователя, настройке tenant-БД и одобрении заявки преподавателя
используют `type="password"` и `autocomplete="new-password"`. Поля логина помечены
`autocomplete="username"`.
- Метки доступности БД `Online`/`Offline` заменены на `Доступно`/`Недоступно`. Затронутые
production-логи `TenantDataSourceConfig`, `TenantRoutingDataSource` и `TenantInterceptor`
переведены на русский; вместо текста исключения фиксируется только безопасный технический
тип, а стек остаётся в отдельной русскоязычной записи `DEBUG`. Англоязычные frontend-логи
загрузки оборудования, форм обучения и подгрупп также русифицированы; raw `error.message`
больше не дописывается в собственные browser-console сообщения.
- CSP пересчитан после удаления двух неиспользуемых style-атрибутов. Статический тест требует
точного равенства множества SHA-256-хэшей реальным атрибутам и не допускает ослабления
`unsafe-inline`/`unsafe-eval`.
- AutoUpdateDocs синхронизировал `docs/FRONTEND.md`, `docs/ARCHITECTURE.md` и
`docs/LOGGING.md` с безопасным DOM, парольными полями и правилами журналирования.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `npm run check` в `frontend/` | 24 проверки: 12 dashboard + 5 auth session + 7 security; все успешны. |
| `npm audit --audit-level=moderate` | `found 0 vulnerabilities`. |
| Полный контейнерный `mvn -Dapi.version=1.44 test` | 200 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `docker build -t magistr-frontend-codex:bug12-34 frontend` | Multi-stage npm/esbuild → Apache завершён успешно. |
| Live container HTTP check | `/` и `/vendor/otel.js` вернули `200`; актуальный CSP и дополнительные headers присутствуют. Временный контейнер остановлен и удалён. |
| `git diff --check` | Успешно. |
| Защищённые файлы | `BUG_REPORT.md` и Flyway V1V4 не изменялись; SHA-256 совпадают с ранее зафиксированными значениями. |
### № 15 — инварианты временных слотов
- CRUD слотов перенесён из `TimeSlotAdminController` в публичный транзакционный
`TimeSlotService`. Сервис проверяет положительный номер пары, существование сетки,
`startTime < endTime`, длительность не меньше минуты и вычисляет `durationMinutes` только
по границам времени, игнорируя одноимённое поле входного DTO.
- Создание блокирует строку целевой сетки, update — строку слота и старую/новую сетки в
стабильном порядке. Внутри сетки запрещены повторный номер и пересечение по условию
`start < other.end AND end > other.start`; касающиеся границами интервалы разрешены.
- Используемый правилом слот нельзя перенести из `DEFAULT`; удаление используемого слота
также отклоняется до SQL-ошибки. Конфликт номера или интервала возвращается как русский
`409 Conflict` через общий `ScheduleConflictException`.
- Раздел инвариантов временных слотов в `V1__init.sql` проверяет seed, добавляет
`btree_gist`, CHECK вычисленной длительности, GiST exclusion constraint с
полуоткрытыми интервалами и триггеры, сериализующие создание правила с переносом слота
или изменением режима сетки.
- В настройках поле минут стало `readonly`, всегда пересчитывается при изменении границ и
больше не отправляется как доверенное значение. AutoUpdateDocs синхронизировал
`docs/API.md`, `docs/BUSINESS_LOGIC.md`, `docs/DATABASE.md` и `docs/FRONTEND.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=TimeSlotServiceTest test` без Docker socket | 6 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 194 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `TimeSlotInvariantMigrationIntegrationTest` | 2 PostgreSQL-сценария базовой V1 добавлены и успешно скомпилированы; запуск Testcontainers из Maven-контейнера среда отклонила из-за запрета передачи Docker socket. |
| Изолированный PostgreSQL 16.3 без томов | Единая V1 применилась с нуля; соседние слоты приняты; overlap (`23P01`), неверная длительность (`23514`) и три нарушения базовой сетки (`23514`) отклонены. |
| Две параллельные пересекающиеся транзакции | Ровно один `COMMIT`; вторая операция отклонена `ex_time_slots_scope_no_overlap`. Одноразовый контейнер удалён. |
| `npm run check` в `frontend/` | 24 проверки, все успешны. |
| `git diff --check` | Успешно. |
| Консолидация Flyway | По прямому разрешению владельца V2V7 удалены, их итоговая схема включена в V1. |
### № 16 — инварианты учебных годов и семестров
- CRUD годов и семестров перенесён из `AcademicCalendarAdminController` в публичный
транзакционный `AcademicPeriodService`. Названия годов нормализуются до duplicate-check,
даты обязательны и упорядочены, а календарные диапазоны трактуются как включительные.
- Учебные годы не могут пересекаться. Семестр должен полностью находиться внутри своего
года, не пересекаться с другим семестром этого года и иметь уникальный тип. Update года
отклоняет границы, которые исключат любой сохранённый семестр; update повторно проверяет
title/type и возвращает безопасный русский `409`, а не JDBC `500`.
- Semester create/update блокируют строку родительского года; update затем перечитывает
строку семестра с `PESSIMISTIC_WRITE`. Инвалидация кэша расписания регистрируется только
после успешного commit.
- Раздел учебных периодов в `V1__init.sql` проверяет seed, добавляет GiST exclusion
constraints годов и семестров с
`daterange(..., '[]')` и два триггера. Блокировка родительской строки в триггере закрывает
гонку между изменением года и конкурентной записью семестра.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`,
`docs/DATABASE.md` и `docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=AcademicPeriodServiceTest test` без Docker socket | 8 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 202 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `AcademicPeriodInvariantMigrationIntegrationTest` | 2 PostgreSQL-сценария базовой V1 добавлены и успешно скомпилированы; Testcontainers-прогон из Maven-контейнера недоступен из-за запрета передачи Docker socket. |
| Изолированный PostgreSQL 16.3 без томов | Единая V1 применилась с нуля; соседние периоды приняты; overlap года/семестра (`23P01`), выход семестра и сужение года (`23514`) отклонены. |
| Две параллельные пересекающиеся транзакции годов | Ровно один `COMMIT`; вторая операция отклонена `ex_academic_years_no_overlap`. Одноразовый контейнер удалён. |
| `git diff --check` | Успешно. |
| Текущая baseline V1 | SHA-256 `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. |
### № 17 — совместимость сохранённых календарных назначений и сеток
- Добавлен публичный транзакционный `AcademicStructureService`. Обновление графика
блокирует его строку и до мутации проверяет все назначения, старшие курсы сетки,
дисциплины старших семестров и даты сетки относительно нового учебного года.
- Обновление группы блокирует её строку и повторно проверяет год начала обучения,
специальность, профиль и форму для всех назначений. При несовместимости
`ScheduleConflictException` преобразуется в русский `409 Conflict`; сущность и назначения
остаются без изменений.
- Сохранение назначения блокирует группу, график, учебный год и существующую запись,
проверяет совпадение измерений и вычисленный курс. Инвалидация кэша выполняется только
после commit.
- V1 содержит триггеры совместимости назначения, обратной защиты группы и графика,
размерности строк сетки и дисциплин, а также защиты зависимостей при изменении границ
учебного года. Seed назначения теперь сразу связывает календарь по году, специальности,
профилю и форме обучения.
- Добавлены 7 unit-тестов сервиса и 2 Testcontainers-сценария базовой схемы, включая гонку
изменения группы с созданием назначения.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=AcademicStructureServiceTest test` | 7 тестов, 0 failures, 0 errors, BUILD SUCCESS. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` | 209 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Контейнерный `mvn -DskipTests test` | Все 46 test source-файлов, включая DB-интеграционные, успешно скомпилированы. |
| Чистая PostgreSQL 16.3 без томов | Единая V1 применилась; 0 несовместимых seed-назначений; созданы 4 ключевых constraint и 5 dependency-trigger. |
| Прямые DB-изменения | Изменения формы графика/группы, уменьшение `course_count`, строка курса 5 и дисциплина семестра 9 отклонены с `23514`; назначение сохранено. |
| Две конкурентные транзакции | Ровно один `COMMIT`; несовместимое назначение отклонено с `23514`, итоговое состояние согласовано. |
| `git diff --check` | Успешно. |
### № 18 — историческая модель кафедр преподавателя
- Добавлен публичный транзакционный `TeacherDepartmentService`. Он разрешает основную и
дополнительную кафедру на целевую дату, пакетно загружает назначения за диапазон и
возвращает исторических преподавателей по периоду действия пользователя и назначения.
- `UserController`, `DepartmentWorkspaceController`, `TeacherSubjectController` и
`WorkloadController` больше не используют `users.department_id` как источник бизнес-
решений. Legacy-поле остаётся зеркалом основной кафедры на текущую дату.
- Будущий перевод закрывает прежний основной период днём перед `validFrom`, но сохраняет
текущую принадлежность до вступления новой записи в силу. Перевод на текущую или прошлую
дату синхронизирует legacy-зеркало. Пользователь и история блокируются до изменения.
- Нагрузка определяет кафедру отдельно на дату каждого занятия: сначала по основной, затем
по дополнительной связи. Перевод внутри диапазона разделяет агрегат одного преподавателя
между кафедрами, а прошлый отчёт не зависит от его текущей кафедры.
- V1 содержит GiST exclusion constraint
`ex_teacher_primary_department_no_overlap`, запрещающий пересечение любых основных
периодов одного преподавателя, включая конкурентную запись из нескольких pod.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`,
`docs/DATABASE.md` и `docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 216 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; все 49 test source-файлов скомпилированы. |
| Целевые service/controller tests | 12 тестов: будущий и текущий перевод, архивная история, приоритет основной связи, fallback на дополнительную, доступ к дисциплине и разделение нагрузки; все успешны. |
| Чистая PostgreSQL 16.3 без томов | Точная текущая V1 применилась с нуля; constraint `ex_teacher_primary_department_no_overlap` создан. |
| Прямые DB-изменения | Соседние основные периоды и дополнительное назначение приняты; пересекающийся основной период отклонён с `23P01`. |
| Две конкурентные транзакции | Ровно один `COMMIT`; вторая пересекающаяся основная запись отклонена с `23P01`. |
| `npm run check` в `frontend/` | 3 test suite и синтаксические проверки завершены успешно. |
| `docker compose config --quiet` | Успешно. |
| `git diff --check` | Успешно. |
| Текущая baseline V1 | SHA-256 `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. |
### № 19 — ограничение кафедрального доступа к workload
- `WorkloadController` вычисляет разрешённый `departmentId` до любого вызова
`ScheduleQueryService`. Для роли `DEPARTMENT` отсутствие фильтра означает кафедру из
`AuthContext`, а несовпадающий ID отклоняется с русским `403 Forbidden`.
- Единое правило применено к отчётам преподавателей, аудиторий, кафедр, временных слотов и
свободным аудиториям. Последний endpoint не принимает `departmentId`, но его внутренняя
выборка для кафедры также больше не является глобальной.
- `ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER` сохраняют явно документированное право на
глобальный workload или выбор конкретной кафедры. Отсутствующий `AuthContext` обрабатывается
fail-closed с русским `401`.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md` и
`docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=WorkloadControllerTest test` без Docker socket | 5 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Межкафедральный сценарий | Чужой `departmentId` даёт `403` до вызова поиска; отсутствие ID и все пять endpoints используют кафедру из `AuthContext`. |
| Привилегированный сценарий | `EDUCATION_OFFICE` сохраняет глобальный scope без `departmentId`. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 219 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `docker compose config --quiet` | Успешно. |
| `git diff --check` | Успешно. |
### № 20 — workload без интерактивного лимита 50 групп
- В `ScheduleQueryService` выделен узкий публичный `searchForAggregation(departmentId,
timeSlotId, startDate, endDate)`. Он не принимает остальные UI-фильтры и поэтому не
становится вторым общим поисковым API.
- Обычный `search()` продолжает отклонять широкий пользовательский запрос, если тот затрагивает
больше 50 активных групп. Оба пути используют единый внутренний алгоритм проверки диапазона,
выбора групп, генерации, применения одного снимка overrides, фильтрации и дедупликации.
- Все workload-отчёты и free-classrooms переведены на aggregate-путь. Кафедральный scope из
№19 вычисляется до генерации и сохранён без изменений.
- Параметризованный тест доказывает полный вызов генератора для 51 и 100 активных групп;
отдельный regression-test сохраняет отказ интерактивного поиска на 51-й группе.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md` и
`docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=ScheduleQueryServiceTest,WorkloadControllerTest test` | 9 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Aggregate 51/100 групп | Все активные группы переданы генератору, overrides прочитаны один раз, исключение лимита не возникает. |
| Интерактивный regression | Обычный широкий `search()` с 51 активной группой по-прежнему отклонён с просьбой уточнить scope. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 221 тест, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `docker compose config --quiet` | Успешно. |
| `git diff --check` | Успешно. |
### № 21 — безопасный импорт дисциплин кафедры
- Импорт вынесен из `DepartmentWorkspaceController` в транзакционный
`SubjectImportService`. Весь payload нормализуется и дедуплицируется по названию без учёта
регистра до мутации; повтор внутри payload обрабатывается один раз.
- Глобальная уникальность названия, уже заявленная в бизнес-документации, сохранена. Если
запись принадлежит текущей кафедре, повторный импорт обновляет её код, восстанавливает из
архива и сохраняет прежний ID. Совпадение с другой кафедрой даёт русский `409 Conflict`,
не меняя владельца или код чужой записи.
- В V1 прежний case-sensitive `UNIQUE(name)` заменён функциональным уникальным индексом
`uq_subjects_name_ci` на `lower(name)`. Он закрывает регистровые дубли и гонку импортов
разных кафедр; admin-create также проверяет имя без учёта регистра.
- Ошибка уникальности при `saveAllAndFlush` преобразуется в безопасный доменный конфликт.
Новых миграционных файлов не создано.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`,
`docs/DATABASE.md` и `docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=SubjectImportServiceTest,DepartmentWorkspaceControllerTest test` | 7 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Повторный собственный импорт | Два последовательных импорта возвращают тот же объект/ID, обновляют код и восстанавливают архивную запись. |
| Совпадение чужой кафедры | Получен доменный `409`; чужие `departmentId` и код не изменены, `saveAllAndFlush` не вызван. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 225 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 51 test source-файл скомпилирован. |
| Чистая PostgreSQL 16.3 без томов | Точная текущая V1 применилась с нуля; `информатика` отклонена как дубль seed `Информатика` по `uq_subjects_name_ci` (`23505`). |
| Две конкурентные транзакции | Первая запись `Теория систем` закоммичена; параллельная `теория систем` после ожидания отклонена `uq_subjects_name_ci` (`23505`). |
| `docker compose config --quiet` | Успешно. |
| `git diff --check` | Успешно. |
| Текущая baseline V1 | SHA-256 `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. |
### № 22 — согласованная матрица прав учебного отдела
- `SpecialityController` сохранил class-level `ADMIN` для всех write-операций, но обязательные
GET специальностей, общего списка профилей и профилей специальности явно разрешены
`EDUCATION_OFFICE`.
- `EducationFormController` разрешает `ADMIN` и `EDUCATION_OFFICE` создавать и удалять формы
обучения. Широкий read-only GET для остальных ранее разрешённых ролей сохранён method-level
аннотацией; защита используемых группами/графиками форм не ослаблена.
- Дубли матриц `ROLE_NAVIGATION` и `ROLE_TABS` удалены. Основной admin SPA и settings SPA
используют единый `frontend/admin/js/role-capabilities.js`, где для учебного отдела явно
перечислены календарный график, временные слоты и формы обучения.
- MockMvc-тест проходит через реальный `AuthorizationInterceptor`: GET specialties/profiles и
GET/POST/DELETE education forms возвращают `200`, а POST специальности остаётся `403`.
- Frontend-тест импортирует матрицу как ES-модуль, проверяет capabilities учебного отдела и
отсутствие локальных дубликатов в двух entrypoint-файлах.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`,
`docs/FRONTEND.md` и `docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=EducationOfficeRoleMatrixTest test` | 2 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| MockMvc role matrix | Штатная инициализация справочников календаря и CRUD форм обучения не получают `403`; изменение специальности учебным отделом отклонено. |
| `npm run check` в `frontend/` | Синтаксис entrypoints и 3 test suite, включая единую role matrix, завершены успешно. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 227 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 52 test source-файла скомпилированы. |
| `docker compose config --quiet` | Успешно. |
| `git diff --check` | Успешно. |
### № 23 — batch-снимок генерации расписания
- `ScheduleQueryService` больше не вызывает генератор отдельно для каждой группы: обычный
поиск, workload-агрегаты и построение полного базового дня передают весь набор групп в
`ScheduleGeneratorService.buildScheduleForGroups()`.
- `AcademicDateService.ScheduleSnapshot` одним набором запросов загружает пересекающиеся
семестры, назначения календарей групп и `academic_calendar_days` от начала затронутого
семестра до конца диапазона. Внутренние проверки semester/assignment/activity работают по
неизменяемым lookup-картам.
- Правила группы и преподавателя получили batch-запросы по набору семестров. Генератор
индексирует их по `groupId + semesterId`, поэтому репозиторий не вызывается из циклов дат,
правил и групп.
- Ручные назначения сетки звонков, weekday scopes и временные слоты также читаются один раз
на диапазон; фактический слот выбирается по `date/scope/orderNumber` без дополнительных
запросов на каждое занятие.
- `ScheduleGenerationQueryCountTest` обрабатывает 120 включительных дат, 30 групп и 20
правил. Тест измеряет ровно 7 чтений репозиториев, запрещает старые point-методы и
одновременно подтверждает 30 корректных занятий с эффективной weekday-сеткой.
- AutoUpdateDocs синхронизировал `docs/BUSINESS_LOGIC.md` и `docs/ARCHITECTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `ScheduleGenerationQueryCountTest` | 1 тест, 0 failures, 0 errors, 0 skipped; 120 дат, 30 групп, 20 правил, 7 repository reads. |
| `ScheduleQueryServiceTest,ScheduleQueryServiceTeacherOverrideTest` | 9 тестов, 0 failures, 0 errors, 0 skipped; batch-путь и overrides сохранены. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 228 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 53 test source-файла скомпилированы. |
### № 24 — tenant-aware cleanup refresh-сессий
- `RefreshTokenCleanupJob` получает атомарный snapshot доменов из
`TenantRoutingDataSource`, устанавливает `TenantContext` до вызова repository proxy и
очищает каждую tenant-БД отдельно. Исходный tenant потока восстанавливается в `finally`.
- По умолчанию истёкшие и отозванные строки сохраняются 30 дней для аудита. Retention,
включение job, размер пачки, максимум пачек, interval и initial delay задаются через
`JWT_REFRESH_CLEANUP_*`.
- Native batch-delete выбирает только записи с `expires_at < cutoff` либо старым
`revoked_at`, использует `FOR UPDATE SKIP LOCKED` и отдельную транзакцию repository-метода.
Два pod разбирают непересекающиеся строки; повторная очистка безопасно возвращает 0.
- Локальный `AtomicBoolean` не допускает наложения двух запусков внутри одного pod, а лимит
пачек ограничивает длительность одного прохода. Ошибка одной tenant-БД логируется по-русски
без технического текста и не останавливает очистку остальных.
- В V1 добавлены частичный индекс по `revoked_at` и индекс по `expires_at`; новых файлов
миграций не создавалось. Текущий SHA-256 V1:
`5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`.
- AutoUpdateDocs синхронизировал `docs/DATABASE.md`, `docs/ARCHITECTURE.md` и
`docs/INFRASTRUCTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `RefreshTokenCleanupJobTest,RefreshTokenServiceTest` | 7 тестов, 0 failures, 0 errors, 0 skipped; два job, retention, idempotency, overlap guard и disabled mode. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 231 тест, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 54 test source-файла скомпилированы. |
| Точная V1 на чистой PostgreSQL 16.3 | Схема и два cleanup-индекса созданы без ошибок. |
| Две конкурентные PostgreSQL-транзакции | При незавершённой первой транзакции обе удалили разные пачки по 3 строки; повтор удалил 0, fresh-expired/fresh-revoked/active сохранены. |
| Testcontainers через Maven-контейнер | Не запускался: среда отклонила проброс `/var/run/docker.sock`; PostgreSQL-специфичный SQL проверен безопасным отдельным контейнером без socket mount. |
### № 25 — безопасное преобразование DB/JDBC-ошибок
- `GlobalExceptionHandler` перехватывает `DataIntegrityViolationException` до общего `500`
и делегирует определение безопасного ответа в `DatabaseConstraintViolationMapper`.
- Именованные CHECK-ограничения текущей V1 преобразуются в русские `400`; UNIQUE, FK и
GiST exclusion constraints — в русские `409`. Неизвестный SQLState класса integrity
получает обобщённый `409` без текста драйвера.
- Технические `constraint` и SQLState доступны только в структурированном журнале.
JDBC/SQL message, stack trace, запросы и значения параметров в HTTP JSON не включаются.
- Локальные `catch Exception`, которые формировали тело ответа через `getMessage()`, удалены:
persistence-ошибки доходят до общего handler, а контролируемые доменные сообщения сохранены.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
`docs/DEVELOPMENT.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `GlobalExceptionHandlerDataIntegrityTest` | 4 теста, 0 failures, 0 errors, 0 skipped; известные CHECK/UNIQUE и неизвестные нарушения не раскрывают constraint, JDBC URL, SQL или пароль. |
| Связанные controller tests | `ScheduleOverrideControllerTest` и `ScheduleRuleAdminControllerTest`: 5 тестов, 0 failures, 0 errors. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 235 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 55 test source-файлов скомпилированы. |
| `docker compose config --quiet` | Успешно. |
| `git diff --check` | Успешно. |
| Каталог Flyway | Содержит только `V1__init.sql`; SHA-256 не изменился: `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. |
### № 26 — самодостаточный локальный Docker Compose
- Compose больше не требует заранее созданную внешнюю сеть: bridge-сеть `magistr`, DNS
сервисов и именованный том `postgres_data` создаются самим проектом.
- Frontend публикует `${HTTP_PORT:-80}` и через Apache `mod_proxy` направляет same-origin
`/api` и `/actuator/health` во внутренний backend. Исходный `Host` сохраняется, поэтому
локальный запрос выбирает tenant `default`; backend и PostgreSQL наружу не публикуются.
- `${POSTGRES_DB:-app_db}` одновременно задаёт создаваемую БД и
`SPRING_DATASOURCE_URL`. Backend получает обязательные `POSTGRES_PASSWORD`, `JWT_SECRET`,
JWT TTL и cleanup-параметры из `.env`; безопасный шаблон добавлен в `.env.example`.
- PostgreSQL закреплён на версии 16.3 и хранит данные в named volume. Backend имеет
liveness healthcheck, а frontend запускается после его готовности; `up --wait` теперь
подтверждает не только старт процесса, но и рабочий Spring context.
- Live startup обнаружил, что Spring не мог выбрать production-конструкторы
`TenantDatabaseHealthMonitor` и `RefreshTokenCleanupJob`, хотя unit-тесты создавали их
вручную. Оба конструктора явно помечены для injection; backend стабильно выходит в
`Started Application`.
- Скачивание mutable OpenTelemetry `latest`, блокировавшее чистую сборку, заменено
фиксированным Maven-артефактом `2.28.1` с обязательной SHA-256 проверкой. Локальный OTel
SDK по умолчанию отключён, так как Collector не входит в Compose.
- AutoUpdateDocs синхронизировал `docs/README.md` и `docs/INFRASTRUCTURE.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `docker compose up -d --build --wait` | PostgreSQL, backend healthcheck и frontend готовы; внутренняя сеть и `magistr_postgres_data` созданы автоматически. |
| Live HTTP через frontend proxy | `/` → `200`, `/actuator/health/liveness` → `200`, защищённый `/api/departments` → ожидаемый `401`. |
| Публикация порта | Порт 80 уже занят пользовательским Caddy; эквивалентная проверка успешно выполнена через документированный `HTTP_PORT=18080`, чужой сервис не останавливался. |
| PostgreSQL mount | Docker inspect: named volume `magistr_postgres_data` смонтирован в `/var/lib/postgresql/data`; backend и DB не имеют host ports. |
| Java Agent supply chain | Maven Central `opentelemetry-javaagent:2.28.1`, SHA-256 `faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589`, build check `OK`. |
| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 235 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `docker compose config --quiet` и `git diff --check` | Успешно. |
| Каталог Flyway | По-прежнему содержит только `V1__init.sql`; новых миграций нет. |
### № 27 — обязательные CI/CD gates и immutable rollout
- `.gitea/workflows/docker-build.yaml` получил обязательный `checks` job: полный backend
non-integration suite, frontend `npm ci && npm run check`, Compose validation и shell-тест
immutable deployment. Build/push jobs имеют `needs: checks`, поэтому непроверенный commit
не публикуется и не разворачивается.
- Metadata публикует полный `sha-<commit>` и release tag без `latest`. Оба build job отдают
registry digest, а deploy формирует ссылки `image@sha256:...`; release tag больше не
перезапускает старый `:main`.
- Workflow-wide `magistr-production` concurrency lock сериализует production-запуски.
`kubectl v1.33.12` загружается с официального CDN и проходит проверку закреплённого
SHA-256 до установки.
- `scripts/deploy-images.sh` отклоняет любые tag-only ссылки, сохраняет текущие backend и
frontend image refs, применяет оба новых digest и ждёт rollout. Ошибка `set image` или
readiness запускает восстановление обоих предыдущих образов и повторную проверку.
- `scripts/test-deploy-images.sh` использует fake kubectl для трёх сценариев: tag отклонён,
успешные digests применены, failed backend rollout вернул оба предыдущих digest.
- Во внешних `../k8s/backend.yaml` и `frontend.yaml` удалены `:main`; нулевой digest-sentinel
не может случайно выбрать mutable image. `../k8s/deploy.sh` требует два реальных digest и
подставляет их локально до apply. Backend probes переведены на HTTP liveness/readiness,
а ConfigMap теперь задаёт корректные `TENANTS_CONFIG_PATH` и
`TENANTS_CONFIG_REQUIRED=true`.
- AutoUpdateDocs синхронизировал `docs/INFRASTRUCTURE.md` и `../k8s/README.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `bash scripts/test-deploy-images.sh` | Mutable tag отклонён; success rollout завершён; failed rollout восстановил backend и frontend, тест завершён успешно. |
| Workflow static validation | Ruby YAML parser — успешно; отсутствуют `:main`, `stable.txt` и `rollout restart`; build jobs зависят от checks и экспортируют digest. |
| `bash -n` | `scripts/deploy-images.sh`, `scripts/test-deploy-images.sh` и `../k8s/deploy.sh` синтаксически корректны. |
| `K8S_DIR=../k8s bash scripts/check-production-secrets.sh` | Успешно. |
| `kubectl kustomize ../k8s >/dev/null` | Успешно. |
| `docker compose config --quiet` и `git diff --check` | Успешно. |
| Изменённые внешние файлы | `../k8s/backend.yaml`, `frontend.yaml`, `config.yaml`, `deploy.sh`, `README.md`; они находятся вне Git-корня `magistr` и отдельно проверены. |
| Production rollout | Не выполнялся: это внешняя необратимая операция, требующая отдельных полномочий и реальных build digests. |
### № 28 — общий PostgreSQL rate limit входа
- `LoginRateLimitService` выполняет создание/блокировку строки, bcrypt-проверку, изменение
счётчика и запись аудита в одной транзакции. Ключ состоит из tenant, NFKC-нормализованного
username в нижнем регистре и проверенного IP. `INSERT ... ON CONFLICT DO NOTHING` вместе
с `PESSIMISTIC_WRITE` сериализует первые и последующие попытки на разных backend-pod.
- Пять отказов за 15 минут по умолчанию дают блокировку на 60 секунд. Следующая ошибка после
окончания блокировки удваивает срок до максимума 15 минут. Controller возвращает `429`
с округлённым вверх `Retry-After`; успешный вход удаляет состояние.
- Неизвестный, архивный и ошибочный пользователь проходят одинаковый bcrypt-путь и получают
одинаковый `401`. В production-журнал попадает только SHA-256 fingerprint нормализованного
имени; пароль и признак существования пользователя не сохраняются и не логируются.
- `ClientIpResolver` игнорирует `X-Forwarded-For` от недоверенного непосредственного
источника, ограничивает длину/число hops и разбирает допустимую цепочку справа налево.
Compose доверяет внутренней Docker-сети, K3s ConfigMap — стандартной pod-сети Traefik;
нестандартный CIDR документирован как обязательная настройка оператора.
- В V1 добавлены `auth_login_rate_limits`, `auth_login_attempt_audit`, уникальный ключ,
CHECK и cleanup-индексы. `LoginAttemptAuditCleanupJob` раз в сутки tenant-aware пачками
`FOR UPDATE SKIP LOCKED` удаляет audit и неактивные счётчики старше 90 дней. Новых файлов
миграций не создано; текущий SHA-256 V1:
`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md`,
`docs/DATABASE.md`, `docs/INFRASTRUCTURE.md` и внешний `../k8s/README.md`.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| `ClientIpResolverTest`, `LoginRateLimitServiceTest`, `AuthControllerTest` | 10 тестов, 0 failures; trusted chain, spoofed header, uniform reject, progressive block и `Retry-After`. |
| `LoginRateLimitConcurrencyIntegrationTest` | 2 теста, 0 failures; две отдельные service instance одной PostgreSQL-БД атомарно получили `REJECTED` + `BLOCKED`, failure count 2; cleanup сохранил fresh/active rows. |
| Полный контейнерный `mvn test` | 266 тестов: 263 успешны; все 12 тестов № 28 прошли. Три ранее добавленных конкурентных migration-теста нестабильны: два получили допустимый PostgreSQL `40P01` вместо ожидаемого только `23P01`, один запуск не воспроизвёл гонку. Стабилизация оставлена в финальном регрессионном аудите. |
| Чистая PostgreSQL 16.3 | Единственная V1 многократно применена Flyway без ошибок; обе новые таблицы и индексы созданы. |
| Compose, Kustomize, secret-scan, `git diff --check` | Успешно. |
| Изменённые внешние файлы | `../k8s/config.yaml`, `../k8s/README.md`; каталог вне Git-корня и проверен отдельно. |
## Точка продолжения
Текущий этап: **№ 12 — access JWT и удалённый JavaScript в одном origin**.
Текущий этап: **№ 29 — единая временная зона бизнес-дат**.
Следующая операция:
1. инвентаризировать `localStorage`/`sessionStorage`, refresh-cookie, удалённые `import()` и
inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации;
2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly
refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload;
3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без
`unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки;
4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP
перед AutoUpdateDocs и изменением статуса № 12.
1. построить graphify-контекст всех `LocalDate.now()`/`LocalDateTime.now()` и frontend
`toISOString()` в бизнес-операциях;
2. внедрить единый `Clock`/`ZoneId` Europe/Moscow без изменения абсолютных timestamp;
3. зафиксировать `TZ`/`user.timezone` в Compose и Kubernetes;
4. проверить границы московской полуночи и date-only форматирование без UTC-сдвига.

View File

@@ -4,7 +4,15 @@ COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
ARG OTEL_JAVAAGENT_VERSION=2.28.1
ARG OTEL_JAVAAGENT_SHA256=faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589
RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \
-Dartifact=io.opentelemetry.javaagent:opentelemetry-javaagent:${OTEL_JAVAAGENT_VERSION} \
-DoutputDirectory=/app \
-Dmdep.stripVersion=true \
-B \
&& echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c -
FROM eclipse-temurin:17-jre-alpine

View File

@@ -0,0 +1,146 @@
package com.magistr.app.config.auth;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy.
*/
@Component
public class ClientIpResolver {
private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class);
private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048;
private static final int MAX_PROXY_HOPS = 20;
private static final String UNKNOWN_ADDRESS = "неизвестен";
private final List<CidrRange> trustedProxyRanges;
public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) {
this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs);
}
public String resolve(HttpServletRequest request) {
Optional<InetAddress> remoteAddress = parseAddress(request.getRemoteAddr());
String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS);
if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) {
return fallback;
}
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded == null || forwarded.isBlank()) {
return fallback;
}
if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) {
log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина");
return fallback;
}
String[] hops = forwarded.split(",", -1);
if (hops.length > MAX_PROXY_HOPS) {
log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy");
return fallback;
}
InetAddress leftmost = null;
for (int index = hops.length - 1; index >= 0; index--) {
Optional<InetAddress> hop = parseAddress(hops[index].trim());
if (hop.isEmpty()) {
log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес");
return fallback;
}
leftmost = hop.get();
if (!isTrusted(hop.get())) {
return hop.get().getHostAddress();
}
}
return leftmost == null ? fallback : leftmost.getHostAddress();
}
private boolean isTrusted(InetAddress address) {
return trustedProxyRanges.stream().anyMatch(range -> range.contains(address));
}
private List<CidrRange> parseTrustedRanges(String rawRanges) {
if (rawRanges == null || rawRanges.isBlank()) {
return List.of();
}
List<CidrRange> ranges = new ArrayList<>();
for (String rawRange : rawRanges.split(",")) {
String value = rawRange.trim();
if (!value.isEmpty()) {
ranges.add(CidrRange.parse(value));
}
}
return List.copyOf(ranges);
}
private static Optional<InetAddress> parseAddress(String rawAddress) {
if (rawAddress == null) {
return Optional.empty();
}
String value = rawAddress.trim().toLowerCase(Locale.ROOT);
if (value.startsWith("[") && value.endsWith("]")) {
value = value.substring(1, value.length() - 1);
}
if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) {
return Optional.empty();
}
try {
return Optional.of(InetAddress.getByName(value));
} catch (UnknownHostException ignored) {
return Optional.empty();
}
}
private record CidrRange(byte[] network, int prefixLength) {
static CidrRange parse(String value) {
String[] parts = value.split("/", -1);
Optional<InetAddress> address = ClientIpResolver.parseAddress(parts[0]);
if (address.isEmpty()) {
throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value);
}
int addressBits = address.get().getAddress().length * 8;
int prefix;
try {
prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]);
} catch (NumberFormatException exception) {
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception);
}
if (parts.length > 2 || prefix < 0 || prefix > addressBits) {
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value);
}
return new CidrRange(mask(address.get().getAddress(), prefix), prefix);
}
boolean contains(InetAddress candidate) {
byte[] candidateBytes = candidate.getAddress();
return candidateBytes.length == network.length
&& Arrays.equals(mask(candidateBytes, prefixLength), network);
}
private static byte[] mask(byte[] address, int prefix) {
byte[] masked = Arrays.copyOf(address, address.length);
int fullBytes = prefix / 8;
int remainingBits = prefix % 8;
if (fullBytes < masked.length && remainingBits > 0) {
masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits));
fullBytes++;
}
Arrays.fill(masked, fullBytes, masked.length, (byte) 0);
return masked;
}
}
}

View File

@@ -0,0 +1,167 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */
@Component
public class LoginAttemptAuditCleanupJob {
private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class);
private final TenantRoutingDataSource routingDataSource;
private final AuthLoginAttemptAuditRepository auditRepository;
private final AuthLoginRateLimitRepository rateLimitRepository;
private final boolean enabled;
private final Duration retention;
private final int batchSize;
private final int maxBatchesPerTenant;
private final Clock clock;
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
@Autowired
public LoginAttemptAuditCleanupJob(
TenantRoutingDataSource routingDataSource,
AuthLoginAttemptAuditRepository auditRepository,
AuthLoginRateLimitRepository rateLimitRepository,
@Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled,
@Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention,
@Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize,
@Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention,
batchSize, maxBatchesPerTenant, Clock.systemUTC());
}
LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource,
AuthLoginAttemptAuditRepository auditRepository,
AuthLoginRateLimitRepository rateLimitRepository,
boolean enabled,
Duration retention,
int batchSize,
int maxBatchesPerTenant,
Clock clock) {
this.routingDataSource = Objects.requireNonNull(routingDataSource);
this.auditRepository = Objects.requireNonNull(auditRepository);
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
if (retention == null || retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным");
}
if (batchSize < 1 || batchSize > 10_000) {
throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000");
}
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000");
}
this.enabled = enabled;
this.retention = retention;
this.batchSize = batchSize;
this.maxBatchesPerTenant = maxBatchesPerTenant;
this.clock = Objects.requireNonNull(clock);
}
@Scheduled(
fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}",
initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}"
)
public void cleanupScheduled() {
if (enabled) {
cleanupNow();
}
}
public CleanupSummary cleanupNow() {
if (!cleanupInProgress.compareAndSet(false, true)) {
return new CleanupSummary(0, 0, 0, 0, true);
}
String previousTenant = TenantContext.getCurrentTenant();
try {
TenantContext.clear();
Map<String, ?> tenants = routingDataSource.snapshotTenantConfigs();
Instant now = clock.instant();
Instant cutoff = now.minus(retention);
int deletedAudits = 0;
int deletedLimits = 0;
int failedTenants = 0;
for (String tenant : tenants.keySet()) {
TenantContext.setCurrentTenant(tenant);
try {
deletedAudits += cleanupAudit(tenant, cutoff);
deletedLimits += cleanupLimits(tenant, cutoff, now);
} catch (RuntimeException failure) {
failedTenants++;
log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}",
tenant, failure.getClass().getSimpleName());
log.debug("Технические детали очистки аудита входа", failure);
} finally {
TenantContext.clear();
}
}
if (deletedAudits + deletedLimits > 0) {
log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}",
tenants.size(), deletedAudits, deletedLimits, failedTenants);
}
return new CleanupSummary(
tenants.size(), deletedAudits, deletedLimits, failedTenants, false
);
} finally {
restoreTenant(previousTenant);
cleanupInProgress.set(false);
}
}
private int cleanupAudit(String tenant, Instant cutoff) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant);
return deletedTotal;
}
private int cleanupLimits(String tenant, Instant cutoff, Instant now) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant);
return deletedTotal;
}
private void restoreTenant(String tenant) {
if (tenant == null || tenant.isBlank()) {
TenantContext.clear();
} else {
TenantContext.setCurrentTenant(tenant);
}
}
public record CleanupSummary(int tenantCount,
int auditDeletedCount,
int rateLimitDeletedCount,
int failedTenantCount,
boolean skippedBecauseAlreadyRunning) {
}
}

View File

@@ -0,0 +1,226 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@Service
public class LoginRateLimitService {
private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
private static final String OUTCOME_FAILURE = "FAILURE";
private static final String OUTCOME_BLOCKED = "BLOCKED";
private final AuthLoginRateLimitRepository rateLimitRepository;
private final AuthLoginAttemptAuditRepository auditRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final LoginSecurityProperties properties;
private final Clock clock;
private final String dummyPasswordHash;
@Autowired
public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
AuthLoginAttemptAuditRepository auditRepository,
UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
LoginSecurityProperties properties) {
this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
properties, Clock.systemUTC());
}
LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
AuthLoginAttemptAuditRepository auditRepository,
UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
LoginSecurityProperties properties,
Clock clock) {
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
this.auditRepository = Objects.requireNonNull(auditRepository);
this.userRepository = Objects.requireNonNull(userRepository);
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
this.properties = Objects.requireNonNull(properties);
this.clock = Objects.requireNonNull(clock);
this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
}
/**
* Блокировка строки удерживается до завершения проверки пароля и записи результата.
* Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
*/
@Transactional
public AuthenticationDecision authenticate(String tenant,
String username,
String rawPassword,
String clientIp) {
String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
Instant now = clock.instant();
rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
AuthLoginRateLimit state = rateLimitRepository
.findForUpdate(safeTenant, normalizedUsername, safeClientIp)
.orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));
if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
return AuthenticationDecision.blocked(retryAfter);
}
if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
state.setFailureCount(0);
state.setWindowStartedAt(now);
state.setBlockedUntil(null);
}
Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
boolean passwordMatches = passwordEncoder.matches(
rawPassword == null ? "" : rawPassword,
storedHash
);
boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();
if (authenticated) {
rateLimitRepository.delete(state);
return AuthenticationDecision.authenticated(user.get());
}
int failureCount = state.getFailureCount() + 1;
state.setFailureCount(failureCount);
state.setLastFailureAt(now);
state.setUpdatedAt(now);
if (failureCount >= properties.getMaxFailures()) {
Duration blockDuration = progressiveBlockDuration(failureCount);
Instant blockedUntil = now.plus(blockDuration);
state.setBlockedUntil(blockedUntil);
long retryAfter = retryAfterSeconds(now, blockedUntil);
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
return AuthenticationDecision.blocked(retryAfter);
}
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
return AuthenticationDecision.rejected();
}
private Duration progressiveBlockDuration(int failureCount) {
int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
long multiplier = 1L << exponent;
Duration candidate;
try {
candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
} catch (ArithmeticException overflow) {
return properties.getMaxBlockDuration();
}
return candidate.compareTo(properties.getMaxBlockDuration()) > 0
? properties.getMaxBlockDuration()
: candidate;
}
private void audit(String tenant,
String username,
String clientIp,
String outcome,
Instant occurredAt,
Long retryAfterSeconds) {
Integer retryAfter = retryAfterSeconds == null
? null
: Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
auditRepository.save(new AuthLoginAttemptAudit(
tenant, username, clientIp, outcome, occurredAt, retryAfter
));
}
private void logDenied(String tenant,
String username,
String clientIp,
String outcome,
Long retryAfterSeconds) {
log.warn(
"Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
tenant,
fingerprint(username),
clientIp,
outcome,
retryAfterSeconds
);
}
private String normalizeAndLimit(String value, int maxLength, String fallback) {
String bounded = value == null
? fallback
: value.substring(0, Math.min(value.length(), maxLength * 4));
String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
.replaceAll("\\p{C}", "")
.strip()
.toLowerCase(Locale.ROOT);
if (normalized.isBlank()) {
normalized = fallback;
}
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
private long retryAfterSeconds(Instant now, Instant blockedUntil) {
Duration remaining = Duration.between(now, blockedUntil);
long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
return Math.max(1, seconds);
}
private String fingerprint(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
}
}
public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {
static AuthenticationDecision authenticated(User user) {
return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
}
static AuthenticationDecision rejected() {
return new AuthenticationDecision(Status.REJECTED, null, 0);
}
static AuthenticationDecision blocked(long retryAfterSeconds) {
return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
}
public enum Status {
AUTHENTICATED,
REJECTED,
BLOCKED
}
}
}

View File

@@ -0,0 +1,70 @@
package com.magistr.app.config.auth;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "app.login-security")
public class LoginSecurityProperties {
private int maxFailures = 5;
private Duration attemptWindow = Duration.ofMinutes(15);
private Duration baseBlockDuration = Duration.ofMinutes(1);
private Duration maxBlockDuration = Duration.ofMinutes(15);
@PostConstruct
void validate() {
if (maxFailures < 2 || maxFailures > 100) {
throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100");
}
requirePositive(attemptWindow, "Окно учёта попыток входа");
requirePositive(baseBlockDuration, "Начальный срок блокировки входа");
requirePositive(maxBlockDuration, "Максимальный срок блокировки входа");
if (maxBlockDuration.compareTo(baseBlockDuration) < 0) {
throw new IllegalStateException(
"Максимальный срок блокировки входа не может быть меньше начального"
);
}
}
private void requirePositive(Duration duration, String fieldName) {
if (duration == null || duration.isZero() || duration.isNegative()) {
throw new IllegalStateException(fieldName + " должен быть положительным");
}
}
public int getMaxFailures() {
return maxFailures;
}
public void setMaxFailures(int maxFailures) {
this.maxFailures = maxFailures;
}
public Duration getAttemptWindow() {
return attemptWindow;
}
public void setAttemptWindow(Duration attemptWindow) {
this.attemptWindow = attemptWindow;
}
public Duration getBaseBlockDuration() {
return baseBlockDuration;
}
public void setBaseBlockDuration(Duration baseBlockDuration) {
this.baseBlockDuration = baseBlockDuration;
}
public Duration getMaxBlockDuration() {
return maxBlockDuration;
}
public void setMaxBlockDuration(Duration maxBlockDuration) {
this.maxBlockDuration = maxBlockDuration;
}
}

View File

@@ -0,0 +1,163 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД.
*/
@Component
public class RefreshTokenCleanupJob {
private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class);
private final TenantRoutingDataSource routingDataSource;
private final AuthRefreshTokenRepository repository;
private final boolean enabled;
private final Duration retention;
private final int batchSize;
private final int maxBatchesPerTenant;
private final Clock clock;
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
@Autowired
public RefreshTokenCleanupJob(
TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
@Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled,
@Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention,
@Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize,
@Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
this(
routingDataSource,
repository,
enabled,
retention,
batchSize,
maxBatchesPerTenant,
Clock.systemUTC()
);
}
RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
boolean enabled,
Duration retention,
int batchSize,
int maxBatchesPerTenant,
Clock clock) {
this.routingDataSource = Objects.requireNonNull(
routingDataSource,
"Маршрутизатор tenant-БД обязателен"
);
this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен");
if (retention == null || retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным");
}
if (batchSize < 1 || batchSize > 10_000) {
throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000");
}
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000");
}
this.enabled = enabled;
this.retention = retention;
this.batchSize = batchSize;
this.maxBatchesPerTenant = maxBatchesPerTenant;
this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны");
}
@Scheduled(
fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}",
initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}"
)
public void cleanupScheduled() {
if (enabled) {
cleanupNow();
}
}
/**
* Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков
* внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod.
*/
public CleanupSummary cleanupNow() {
if (!cleanupInProgress.compareAndSet(false, true)) {
return new CleanupSummary(0, 0, 0, true);
}
String previousTenant = TenantContext.getCurrentTenant();
try {
TenantContext.clear();
Map<String, ?> tenantSnapshot = routingDataSource.snapshotTenantConfigs();
Instant cutoff = clock.instant().minus(retention);
int deletedTotal = 0;
int failedTenants = 0;
for (String tenant : tenantSnapshot.keySet()) {
TenantContext.setCurrentTenant(tenant);
try {
deletedTotal += cleanupTenant(tenant, cutoff);
} catch (RuntimeException cleanupFailure) {
failedTenants++;
log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}",
tenant, cleanupFailure.getClass().getSimpleName());
log.debug("Технические детали очистки refresh-сессий", cleanupFailure);
} finally {
TenantContext.clear();
}
}
if (deletedTotal > 0) {
log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}",
tenantSnapshot.size(), deletedTotal, failedTenants);
}
return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false);
} finally {
restoreTenant(previousTenant);
cleanupInProgress.set(false);
}
}
private int cleanupTenant(String tenant, Instant cutoff) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = repository.deleteCleanupBatch(cutoff, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход",
tenant);
return deletedTotal;
}
private void restoreTenant(String previousTenant) {
if (previousTenant == null || previousTenant.isBlank()) {
TenantContext.clear();
} else {
TenantContext.setCurrentTenant(previousTenant);
}
}
public record CleanupSummary(int tenantCount,
int deletedCount,
int failedTenantCount,
boolean skippedBecauseAlreadyRunning) {
}
}

View File

@@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
@@ -25,17 +27,24 @@ public class RefreshTokenService {
private final SecureRandom secureRandom = new SecureRandom();
private final AuthRefreshTokenRepository repository;
private final JwtProperties properties;
private final Clock clock;
@Autowired
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
this(repository, properties, Clock.systemUTC());
}
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
this.repository = repository;
this.properties = properties;
this.clock = clock;
}
@Transactional
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
String refreshToken = generateRawToken();
AuthRefreshToken token = new AuthRefreshToken();
LocalDateTime now = LocalDateTime.now();
Instant now = clock.instant();
token.setUser(user);
token.setTenant(tenant);
token.setTokenHash(hashToken(refreshToken));
@@ -53,7 +62,7 @@ public class RefreshTokenService {
return Optional.empty();
}
LocalDateTime now = LocalDateTime.now();
Instant now = clock.instant();
String currentHash = hashToken(rawToken);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
if (storedOpt.isEmpty()) {
@@ -101,7 +110,7 @@ public class RefreshTokenService {
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
.filter(token -> token.getRevokedAt() == null)
.ifPresent(token -> {
token.setRevokedAt(LocalDateTime.now());
token.setRevokedAt(clock.instant());
repository.save(token);
});
}

View File

@@ -65,7 +65,7 @@ public class TenantDataSourceConfig {
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
);
tenants.add(defaultTenant);
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
}
readinessRegistry.clearConfigurationFailure();
@@ -79,10 +79,10 @@ public class TenantDataSourceConfig {
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
}
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
if (routingDataSource.getTenantConfigs().isEmpty()) {
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
log.warn("Добавьте тенант через POST /api/database/tenants");
TenantConfig h2Fallback = new TenantConfig(
@@ -155,7 +155,7 @@ public class TenantDataSourceConfig {
log.info("Загружено конфигураций тенантов: {}", list.size());
return new TenantConfigLoadResult(List.copyOf(list), false);
} catch (Exception e) {
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали чтения конфигурации тенантов", e);
return new TenantConfigLoadResult(List.of(), true);
@@ -189,15 +189,15 @@ public class TenantDataSourceConfig {
readinessRegistry.markConnectivity(tenant.getDomain(), true);
}
closeDataSource(previous == null ? null : previous.dataSource());
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
return true;
} catch (Exception startupFailure) {
if (requiredForReadiness) {
readinessRegistry.markMigrationFailed(tenant);
}
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
tenant.getDomain(), startupFailure.getClass().getSimpleName());
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
return false;
} finally {
if (!activated) {
@@ -211,9 +211,9 @@ public class TenantDataSourceConfig {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
closeFailure.getClass().getSimpleName());
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
}
}
}

View File

@@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant);
return true;
}
// Проверяем, существует ли тенант
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host);
response.setStatus(404);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
@@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
return true;
}

View File

@@ -141,7 +141,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
try (Connection connection = tenantDataSource.get().getConnection()) {
return connection.isValid(5);
} catch (SQLException e) {
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
normalizeDomain(domain), e.getClass().getSimpleName());
log.debug("Технические детали проверки подключения тенанта", e);
return false;
@@ -165,7 +165,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
}
return "Подключение не валидно";
} catch (Exception e) {
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали проверки внешнего подключения", e);
return "Не удалось подключиться к базе данных";

View File

@@ -4,6 +4,7 @@ import com.magistr.app.config.tenant.TenantRoutingDataSource;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -41,6 +42,7 @@ public class TenantDatabaseHealthMonitor {
private final ExecutorService executor;
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
@Autowired
public TenantDatabaseHealthMonitor(
TenantReadinessRegistry registry,
TenantRoutingDataSource routingDataSource,

View File

@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicPeriodService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -22,17 +23,20 @@ public class AcademicCalendarAdminController {
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicPeriodService academicPeriodService;
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarDayRepository calendarDayRepository,
ScheduleGeneratorService scheduleGeneratorService) {
ScheduleGeneratorService scheduleGeneratorService,
AcademicPeriodService academicPeriodService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarDayRepository = calendarDayRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicPeriodService = academicPeriodService;
}
@GetMapping("/years")
@@ -45,43 +49,17 @@ public class AcademicCalendarAdminController {
@PostMapping("/years")
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
String validationError = validateYear(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
}
AcademicYear year = new AcademicYear();
applyYear(year, request);
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request)));
}
@PutMapping("/years/{id}")
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
AcademicYear year = academicYearRepository.findById(id).orElse(null);
if (year == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateYear(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
applyYear(year, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
}
@DeleteMapping("/years/{id}")
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
if (!academicYearRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
academicYearRepository.deleteById(id);
scheduleGeneratorService.clearCache();
academicPeriodService.deleteYear(id);
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
}
@@ -94,39 +72,12 @@ public class AcademicCalendarAdminController {
@PostMapping("/years/{academicYearId}/semesters")
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
if (year == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
}
String validationError = validateSemester(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
}
Semester semester = new Semester();
semester.setAcademicYear(year);
applySemester(semester, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
}
@PutMapping("/semesters/{id}")
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
Semester semester = semesterRepository.findById(id).orElse(null);
if (semester == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateSemester(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
applySemester(semester, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
}
@GetMapping("/activity-types")
@@ -185,32 +136,6 @@ public class AcademicCalendarAdminController {
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
}
private String validateYear(AcademicYearDto request) {
if (request.title() == null || request.title().isBlank()) {
return "Название учебного года обязательно";
}
if (request.startDate() == null || request.endDate() == null) {
return "Даты начала и окончания учебного года обязательны";
}
if (request.endDate().isBefore(request.startDate())) {
return "Дата окончания не может быть раньше даты начала";
}
return null;
}
private String validateSemester(SemesterDto request) {
if (request.semesterType() == null) {
return "Тип семестра обязателен";
}
if (request.startDate() == null || request.endDate() == null) {
return "Даты начала и окончания семестра обязательны";
}
if (request.endDate().isBefore(request.startDate())) {
return "Дата окончания семестра не может быть раньше даты начала";
}
return null;
}
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
if (request == null) {
return "Передайте код активности";
@@ -224,18 +149,6 @@ public class AcademicCalendarAdminController {
return null;
}
private void applyYear(AcademicYear year, AcademicYearDto request) {
year.setTitle(request.title().trim());
year.setStartDate(request.startDate());
year.setEndDate(request.endDate());
}
private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate());
semester.setEndDate(request.endDate());
}
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
activityType.setCode(request.code().trim());
activityType.setName(request.name().trim());

View File

@@ -5,6 +5,7 @@ import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -32,6 +33,7 @@ public class AcademicCalendarController {
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicStructureService academicStructureService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
@@ -42,7 +44,8 @@ public class AcademicCalendarController {
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
ScheduleGeneratorService scheduleGeneratorService) {
ScheduleGeneratorService scheduleGeneratorService,
AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.academicYearRepository = academicYearRepository;
@@ -53,6 +56,7 @@ public class AcademicCalendarController {
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicStructureService = academicStructureService;
}
@GetMapping
@@ -85,17 +89,7 @@ public class AcademicCalendarController {
@PutMapping("/{id}")
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
}
@DeleteMapping("/{id}")
@@ -103,14 +97,9 @@ public class AcademicCalendarController {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
try {
calendarRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
}
calendarRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
}
@GetMapping("/{id}/grid")

View File

@@ -3,17 +3,18 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.JwtProperties;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.auth.ClientIpResolver;
import com.magistr.app.config.auth.LoginRateLimitService;
import com.magistr.app.config.auth.RefreshTokenRotation;
import com.magistr.app.config.auth.RefreshTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -27,8 +28,8 @@ import java.util.Optional;
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final LoginRateLimitService loginRateLimitService;
private final ClientIpResolver clientIpResolver;
private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
@@ -42,13 +43,13 @@ public class AuthController {
"STUDENT", "/student/"
);
public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
public AuthController(LoginRateLimitService loginRateLimitService,
ClientIpResolver clientIpResolver,
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.loginRateLimitService = loginRateLimitService;
this.clientIpResolver = clientIpResolver;
this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
@@ -56,28 +57,46 @@ public class AuthController {
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() ||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
TenantContext.getCurrentTenant(),
request.getUsername(),
request.getPassword(),
clientIpResolver.resolve(servletRequest)
);
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) {
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds()))
.body(new LoginResponse(
false,
"Слишком много попыток входа. Повторите позже",
null,
null,
null,
null
));
}
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
.body(new LoginResponse(
false,
"Неверное имя пользователя или пароль",
null,
null,
null,
null
));
}
User user = userOpt.get();
if (user.isArchivedRecord()) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
}
User user = decision.user();
String tenant = TenantContext.getCurrentTenant();
String accessToken = jwtTokenService.createAccessToken(user, tenant);
String refreshToken = refreshTokenService.createSession(
user,
tenant,
servletRequest.getHeader("User-Agent"),
clientIp(servletRequest)
clientIpResolver.resolve(servletRequest)
);
return ResponseEntity.ok()
@@ -96,7 +115,7 @@ public class AuthController {
refreshToken.get(),
TenantContext.getCurrentTenant(),
request.getHeader("User-Agent"),
clientIp(request)
clientIpResolver.resolve(request)
);
if (rotation.isEmpty()) {
return unauthorizedWithClearedCookie();
@@ -192,11 +211,4 @@ public class AuthController {
return Optional.empty();
}
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -0,0 +1,190 @@
package com.magistr.app.controller;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки.
*/
final class DatabaseConstraintViolationMapper {
private static final Pattern CONSTRAINT_PATTERN = Pattern.compile(
"(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?"
);
private static final Map<String, ViolationResponse> KNOWN_CONSTRAINTS = Map.ofEntries(
badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"),
badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"),
badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"),
badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"),
badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"),
badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"),
badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"),
badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"),
badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"),
badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"),
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"),
badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"),
badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"),
badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"),
badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"),
badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"),
badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"),
badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"),
badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"),
badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"),
conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"),
conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"),
conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"),
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
conflict("uq_calendar_days", "День календарного графика уже существует"),
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),
conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"),
conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"),
conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"),
conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"),
conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"),
conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением")
);
ViolationResponse map(DataIntegrityViolationException exception) {
String constraintName = constraintName(exception).orElse(null);
String sqlState = sqlState(exception).orElse(null);
if (constraintName != null) {
ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName);
if (known != null) {
return known.withDetails(constraintName, sqlState);
}
if (constraintName.endsWith("_fkey")) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция невозможна из-за связанных данных",
constraintName,
sqlState
);
}
}
if ("23502".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.BAD_REQUEST,
"Не заполнено обязательное поле",
constraintName,
sqlState
);
}
if ("23514".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.BAD_REQUEST,
"Данные нарушают правила предметной области",
constraintName,
sqlState
);
}
if ("23505".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Такая запись уже существует",
constraintName,
sqlState
);
}
if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция невозможна из-за конфликта или связанных данных",
constraintName,
sqlState
);
}
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция нарушает ограничения целостности данных",
constraintName,
sqlState
);
}
private Optional<String> constraintName(Throwable failure) {
for (Throwable current = failure; current != null; current = nextCause(current)) {
if (current instanceof ConstraintViolationException constraintViolation
&& constraintViolation.getConstraintName() != null) {
return normalize(constraintViolation.getConstraintName());
}
String message = current.getMessage();
if (message != null) {
Matcher matcher = CONSTRAINT_PATTERN.matcher(message);
if (matcher.find()) {
return normalize(matcher.group(1));
}
}
}
return Optional.empty();
}
private Optional<String> sqlState(Throwable failure) {
for (Throwable current = failure; current != null; current = nextCause(current)) {
if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) {
return Optional.of(sqlException.getSQLState());
}
}
return Optional.empty();
}
private Throwable nextCause(Throwable current) {
Throwable cause = current.getCause();
return cause == current ? null : cause;
}
private Optional<String> normalize(String constraintName) {
if (constraintName == null || constraintName.isBlank()) {
return Optional.empty();
}
String normalized = constraintName.trim()
.replace("\"", "")
.toLowerCase(Locale.ROOT);
int dot = normalized.lastIndexOf('.');
return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized);
}
private static Map.Entry<String, ViolationResponse> badRequest(String name, String message) {
return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514"));
}
private static Map.Entry<String, ViolationResponse> conflict(String name, String message) {
return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null));
}
record ViolationResponse(HttpStatus status,
String message,
String constraintName,
String sqlState) {
private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) {
return new ViolationResponse(status, message, actualConstraintName, actualSqlState);
}
}
}

View File

@@ -9,7 +9,6 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -93,9 +92,8 @@ public class DepartmentController {
)
);
} catch (Exception e) {
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
logger.error("Ошибка при создании кафедры", e);
throw e;
}
}
@@ -148,9 +146,8 @@ public class DepartmentController {
department.getDepartmentCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
throw e;
}
}

View File

@@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.SubjectImportService;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +44,9 @@ public class DepartmentWorkspaceController {
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService;
private final TeacherDepartmentService teacherDepartmentService;
private final SubjectImportService subjectImportService;
private final BusinessTimeService businessTime;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
@@ -47,7 +54,26 @@ public class DepartmentWorkspaceController {
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) {
ScheduleQueryService scheduleQueryService,
TeacherDepartmentService teacherDepartmentService,
SubjectImportService subjectImportService) {
this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository,
teacherDepartmentAssignmentRepository, teacherCreationRequestRepository,
scheduleQueryService, teacherDepartmentService, subjectImportService,
BusinessTimeService.systemDefault());
}
@Autowired
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService,
TeacherDepartmentService teacherDepartmentService,
SubjectImportService subjectImportService,
BusinessTimeService businessTime) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
@@ -55,6 +81,9 @@ public class DepartmentWorkspaceController {
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService;
this.teacherDepartmentService = teacherDepartmentService;
this.subjectImportService = subjectImportService;
this.businessTime = businessTime;
}
@GetMapping("/subjects")
@@ -77,19 +106,7 @@ public class DepartmentWorkspaceController {
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
List<Subject> saved = requests.stream()
.filter(request -> request.getName() != null && !request.getName().isBlank())
.map(request -> {
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
subject.setName(request.getName().trim());
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
subject.setDepartmentId(departmentId);
if (subject.isArchivedRecord()) {
subject.restore();
}
return subjectRepository.save(subject);
})
.toList();
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
return ResponseEntity.ok(Map.of(
"message", "Дисциплины загружены",
"count", saved.size()
@@ -118,6 +135,7 @@ public class DepartmentWorkspaceController {
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
}
SubjectComment comment = new SubjectComment();
comment.setCreatedAt(businessTime.now());
comment.setSubject(subject);
comment.setComment(commentText.trim());
Long currentUserId = AuthContext.currentUserId();
@@ -132,21 +150,19 @@ public class DepartmentWorkspaceController {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.map(user -> toUserResponse(
user,
teacherDepartmentService.findPrimaryDepartmentIdAtDate(
user.getId(),
businessTime.today()
).orElse(user.getDepartmentId())
))
.toList();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return teachersById.values().stream()
.map(this::toUserResponse)
return teacherDepartmentService
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
.map(user -> toUserResponse(user, effectiveDepartmentId))
.toList();
}
@@ -167,7 +183,7 @@ public class DepartmentWorkspaceController {
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
}
LocalDate today = LocalDate.now();
LocalDate today = businessTime.today();
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
}
@@ -225,6 +241,8 @@ public class DepartmentWorkspaceController {
}
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
teacherRequest.setCreatedAt(businessTime.now());
teacherRequest.setUpdatedAt(businessTime.now());
teacherRequest.setDepartment(department);
teacherRequest.setUsername(username);
teacherRequest.setFullName(fullName);
@@ -257,14 +275,14 @@ public class DepartmentWorkspaceController {
);
}
private UserResponse toUserResponse(User user) {
private UserResponse toUserResponse(User user, Long departmentId) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
departmentId
);
response.setStatus(user.getStatus());
return response;

View File

@@ -15,7 +15,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN})
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EducationFormController {
private final EducationFormRepository educationFormRepository;

View File

@@ -4,6 +4,7 @@ import com.magistr.app.service.ScheduleConflictException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
@@ -20,6 +21,8 @@ import java.util.NoSuchElementException;
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final DatabaseConstraintViolationMapper constraintViolationMapper =
new DatabaseConstraintViolationMapper();
@ExceptionHandler(ScheduleConflictException.class)
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
@@ -58,6 +61,23 @@ public class GlobalExceptionHandler {
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
DataIntegrityViolationException exception,
HttpServletRequest request) {
DatabaseConstraintViolationMapper.ViolationResponse violation =
constraintViolationMapper.map(exception);
log.warn(
"Нарушение целостности данных {} {}: constraint={}, sqlState={}",
request.getMethod(),
request.getRequestURI(),
violation.constraintName() == null ? "не определено" : violation.constraintName(),
violation.sqlState() == null ? "не определено" : violation.sqlState()
);
log.debug("Технические детали нарушения целостности данных", exception);
return error(violation.status(), violation.message(), request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
HttpServletRequest request) {
@@ -67,7 +87,7 @@ public class GlobalExceptionHandler {
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("timestamp", Instant.now().toString());
body.put("status", status.value());
body.put("error", reason(status));
body.put("message", message);

View File

@@ -5,9 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
@@ -17,12 +15,15 @@ import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.service.BusinessTimeService;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -45,33 +46,49 @@ public class GroupController {
private final EducationFormRepository educationFormRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicYearRepository academicYearRepository;
private final AcademicCalendarRepository academicCalendarRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicStructureService academicStructureService;
private final BusinessTimeService businessTime;
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService) {
this(groupRepository, educationFormRepository, specialtiesRepository,
specialtyProfileRepository, calendarSubjectRepository, assignmentRepository,
scheduleGeneratorService, groupLifecycleService, academicStructureService,
BusinessTimeService.systemDefault());
}
@Autowired
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService,
BusinessTimeService businessTime) {
this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
this.academicYearRepository = academicYearRepository;
this.academicCalendarRepository = academicCalendarRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
this.academicStructureService = academicStructureService;
this.businessTime = businessTime;
}
@GetMapping
@@ -82,7 +99,7 @@ public class GroupController {
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.toList();
List<GroupResponse> response = groups.stream()
@@ -101,7 +118,7 @@ public class GroupController {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.toList();
if(groups.isEmpty()) {
@@ -163,10 +180,9 @@ public class GroupController {
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка при создании группы", e);
throw e;
}
}
@@ -174,50 +190,9 @@ public class GroupController {
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id);
try {
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
if (groupOpt.isEmpty()) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
ResponseEntity<?> validationError = validateGroupRequest(request);
if (validationError != null) {
return validationError;
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
}
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElse(null);
if (speciality == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
}
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
.orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
}
StudentGroup group = groupOpt.get();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(speciality);
group.setSpecialtyProfile(profile);
groupRepository.save(group);
logger.info("Группа с ID - {} успешно обновлена", id);
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e) {
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
}
StudentGroup group = academicStructureService.updateGroup(id, request);
logger.info("Группа с ID - {} успешно обновлена", id);
return ResponseEntity.ok(mapToResponse(group));
}
@DeleteMapping("/{id}")
@@ -229,7 +204,7 @@ public class GroupController {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
group.archive("Группа архивирована");
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
groupRepository.save(group);
logger.info("Группа с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
@@ -278,11 +253,11 @@ public class GroupController {
}
private GroupResponse mapToResponse(StudentGroup group) {
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
LocalDate today = businessTime.today();
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile();
LocalDate today = LocalDate.now();
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse(
group.getId(),
@@ -323,42 +298,9 @@ public class GroupController {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
@RequestBody GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
}
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
if (academicYear == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
}
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
if (calendar == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
}
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
}
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
}
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
}
StudentGroupCalendarAssignment assignment = assignmentRepository
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
.orElseGet(StudentGroupCalendarAssignment::new);
assignment.setStudentGroup(group);
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
scheduleGeneratorService.clearCache();
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
.findByCalendarIdWithSubject(calendar.getId())
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
.stream()
.map(this::toCalendarSubjectDto)
.toList();

View File

@@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -36,6 +35,7 @@ public class SpecialityController {
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
@@ -102,9 +102,8 @@ public class SpecialityController {
)
);
} catch (Exception e) {
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
logger.error("Ошибка при создании специальности", e);
throw e;
}
}
@@ -158,9 +157,8 @@ public class SpecialityController {
speciality.getSpecialityCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
throw e;
}
}
@@ -194,6 +192,7 @@ public class SpecialityController {
}
@GetMapping("/profiles")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public List<SpecialtyProfileDto> getAllProfiles() {
logger.info("Получен запрос на получение всех профилей обучения");
return specialtyProfileRepository.findAll().stream()
@@ -204,6 +203,7 @@ public class SpecialityController {
}
@GetMapping("/{specialtyId}/profiles")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) {
return ResponseEntity.notFound().build();
@@ -255,14 +255,8 @@ public class SpecialityController {
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
return ResponseEntity.notFound().build();
}
try {
specialtyProfileRepository.delete(profile);
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
} catch (Exception e) {
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
return ResponseEntity.badRequest()
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
}
specialtyProfileRepository.delete(profile);
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
}
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {

View File

@@ -9,7 +9,6 @@ import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -125,14 +124,10 @@ public class SubgroupController {
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try {
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
}
private String validate(SubgroupDto request) {

View File

@@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -87,7 +86,7 @@ public class SubjectController {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) {
String errorMessage = "Дисциплина с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
@@ -119,10 +118,9 @@ public class SubjectController {
subject.getDepartmentId()
)
);
} catch (Exception e){
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка при создании дисциплины", e);
throw e;
}
}

View File

@@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -30,17 +29,20 @@ public class TeacherCreationRequestController {
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final BusinessTimeService businessTime;
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
BCryptPasswordEncoder passwordEncoder) {
BCryptPasswordEncoder passwordEncoder,
BusinessTimeService businessTime) {
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.passwordEncoder = passwordEncoder;
this.businessTime = businessTime;
}
@GetMapping
@@ -98,7 +100,8 @@ public class TeacherCreationRequestController {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setValidFrom(businessTime.today());
assignment.setCreatedAt(businessTime.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Создан по заявке кафедры #" + request.getId());
assignment.setCreatedBy(AuthContext.currentUserId());
@@ -110,7 +113,7 @@ public class TeacherCreationRequestController {
request.setDepartment(department);
request.setStatus(TeacherCreationRequestStatus.APPROVED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewedAt(businessTime.now());
request.setReviewComment(trimToNull(review.getReviewComment()));
request.setCreatedTeacherId(teacher.getId());
teacherCreationRequestRepository.save(request);
@@ -132,7 +135,7 @@ public class TeacherCreationRequestController {
}
request.setStatus(TeacherCreationRequestStatus.REJECTED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewedAt(businessTime.now());
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
}

View File

@@ -11,6 +11,9 @@ import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -25,13 +28,28 @@ public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
private final UserRepository userRepository;
private final SubjectRepository subjectRepository;
private final TeacherDepartmentService teacherDepartmentService;
private final BusinessTimeService businessTime;
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository) {
SubjectRepository subjectRepository,
TeacherDepartmentService teacherDepartmentService) {
this(teacherSubjectRepository, userRepository, subjectRepository,
teacherDepartmentService, BusinessTimeService.systemDefault());
}
@Autowired
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository,
TeacherDepartmentService teacherDepartmentService,
BusinessTimeService businessTime) {
this.teacherSubjectRepository = teacherSubjectRepository;
this.userRepository = userRepository;
this.subjectRepository = subjectRepository;
this.teacherDepartmentService = teacherDepartmentService;
this.businessTime = businessTime;
}
@GetMapping
@@ -111,7 +129,11 @@ public class TeacherSubjectController {
return departmentId != null
&& teacher != null
&& subject != null
&& departmentId.equals(teacher.getDepartmentId())
&& teacherDepartmentService.hasAssignmentAtDate(
teacher.getId(),
departmentId,
businessTime.today()
)
&& departmentId.equals(subject.getDepartmentId());
}
}

View File

@@ -11,11 +11,10 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.dao.DataIntegrityViolationException;
import com.magistr.app.service.TimeSlotService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -35,13 +34,16 @@ public class TimeSlotAdminController {
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final TimeSlotService timeSlotService;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
TimeSlotDateAssignmentRepository dateAssignmentRepository,
TimeSlotService timeSlotService) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
this.timeSlotService = timeSlotService;
}
@GetMapping
@@ -151,83 +153,18 @@ public class TimeSlotAdminController {
@PostMapping
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (hasDuplicate(request, null)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
TimeSlot timeSlot = new TimeSlot();
apply(timeSlot, request);
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
return ResponseEntity.ok(toDto(timeSlotService.create(request)));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
if (timeSlot == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (hasDuplicate(request, id)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
apply(timeSlot, request);
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!timeSlotRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
try {
timeSlotRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
}
}
private String validate(TimeSlotDto request) {
if (request.orderNumber() == null || request.orderNumber() <= 0) {
return "Номер пары должен быть больше нуля";
}
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
return "Выберите сетку времени";
}
if (request.startTime() == null || request.endTime() == null) {
return "Время начала и окончания обязательно";
}
if (!request.startTime().isBefore(request.endTime())) {
return "Время начала должно быть раньше времени окончания";
}
return null;
}
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
timeSlot.setOrderNumber(request.orderNumber());
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
timeSlot.setStartTime(request.startTime());
timeSlot.setEndTime(request.endTime());
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
? request.durationMinutes()
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
timeSlot.setDurationMinutes(duration);
}
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
.isPresent();
timeSlotService.delete(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
}
private List<TimeSlot> effectiveSlots(LocalDate date) {

View File

@@ -14,20 +14,20 @@ import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/users")
@@ -38,16 +38,33 @@ public class UserController {
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherDepartmentService teacherDepartmentService;
private final BCryptPasswordEncoder passwordEncoder;
private final BusinessTimeService businessTime;
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherDepartmentService teacherDepartmentService) {
this(userRepository, passwordEncoder, departmentRepository,
teacherDepartmentAssignmentRepository, teacherDepartmentService,
BusinessTimeService.systemDefault());
}
@Autowired
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherDepartmentService teacherDepartmentService,
BusinessTimeService businessTime) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherDepartmentService = teacherDepartmentService;
this.businessTime = businessTime;
}
@GetMapping
@@ -82,7 +99,10 @@ public class UserController {
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
departmentId,
businessTime.today()
);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
@@ -92,7 +112,7 @@ public class UserController {
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
return ResponseEntity.ok(users.stream()
.map(this::toUserResponse)
.map(user -> toUserResponse(user, departmentId))
.toList());
}
@@ -161,7 +181,7 @@ public class UserController {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(requestedDepartment);
assignment.setValidFrom(LocalDate.now());
assignment.setValidFrom(businessTime.today());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
@@ -180,7 +200,7 @@ public class UserController {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
user.archive("Пользователь архивирован");
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
userRepository.save(user);
logger.info("Пользователь с ID - {} успешно архивирован", id);
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
@@ -206,7 +226,11 @@ public class UserController {
var currentUser = AuthContext.getCurrentUser();
if (currentUser != null
&& currentUser.role() == Role.DEPARTMENT
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
&& !teacherDepartmentService.hasAssignmentAtDate(
id,
currentUser.departmentId(),
businessTime.today()
)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
}
@@ -216,46 +240,13 @@ public class UserController {
}
@PostMapping("/{id}/department-transfer")
@Transactional
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
User teacher = userRepository.findById(id).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
}
if (request.departmentId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
.ifPresent(current -> {
if (current.getValidFrom().isBefore(validFrom)) {
current.setValidTo(validFrom.minusDays(1));
teacherDepartmentAssignmentRepository.save(current);
} else {
teacherDepartmentAssignmentRepository.delete(current);
}
});
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setPrimaryAssignment(true);
assignment.setComment(request.comment());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
id,
request,
businessTime.today(),
AuthContext.currentUserId()
);
return ResponseEntity.ok(toAssignmentDto(assignment));
}
@@ -268,16 +259,25 @@ public class UserController {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
.map(this::toUserResponse)
LocalDate effectiveDate = date == null ? businessTime.today() : date;
return ResponseEntity.ok(teacherDepartmentService
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
.map(user -> toUserResponse(user, departmentId))
.toList());
}
private UserResponse toUserResponse(User user) {
String departmentName = user.getDepartmentId() == null
Long departmentId = user.getRole() == Role.TEACHER
? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today())
.orElse(user.getDepartmentId())
: user.getDepartmentId();
return toUserResponse(user, departmentId);
}
private UserResponse toUserResponse(User user, Long departmentId) {
String departmentName = departmentId == null
? null
: departmentRepository.findById(user.getDepartmentId())
: departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Неизвестно");
UserResponse response = new UserResponse(
@@ -288,7 +288,7 @@ public class UserController {
user.getJobTitle(),
departmentName
);
response.setDepartmentId(user.getDepartmentId());
response.setDepartmentId(departmentId);
response.setStatus(user.getStatus());
return response;
}
@@ -307,23 +307,6 @@ public class UserController {
);
}
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
Map<Long, User> teachersById = new LinkedHashMap<>();
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
if (assignments != null) {
assignments.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
}
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
if (directTeachers != null) {
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
}
return List.copyOf(teachersById.values());
}
private boolean canAccessDepartment(Long departmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser == null

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.dto.RenderedLessonDto;
@@ -7,11 +8,13 @@ import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.TeacherDepartmentService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
@@ -27,17 +30,17 @@ public class WorkloadController {
private final ScheduleQueryService scheduleQueryService;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentService teacherDepartmentService;
public WorkloadController(ScheduleQueryService scheduleQueryService,
ClassroomRepository classroomRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository) {
DepartmentRepository departmentRepository,
TeacherDepartmentService teacherDepartmentService) {
this.scheduleQueryService = scheduleQueryService;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentService = teacherDepartmentService;
}
@GetMapping("/teachers")
@@ -46,19 +49,11 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
teacherId -> {
User teacher = teachersById.get(teacherId);
return teacher == null ? null : teacher.getDepartmentId();
});
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarizeTeacherWorkload(lessons, startDate, endDate);
}
@GetMapping("/classrooms")
@@ -67,8 +62,10 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
}
@@ -78,22 +75,20 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
Map<Long, String> departments = departmentRepository.findAll().stream()
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
.collect(Collectors.groupingBy(lesson -> {
User teacher = teachersById.get(lesson.teacherId());
return teacher == null ? 0L : teacher.getDepartmentId();
}));
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
lesson,
assignmentsByTeacher
).orElse(0L)));
return byDepartment.entrySet().stream()
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
@@ -101,14 +96,76 @@ public class WorkloadController {
.toList();
}
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey(
lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
),
LinkedHashMap::new,
Collectors.toList()
));
return grouped.entrySet().stream()
.map(entry -> summary(
entry.getKey().teacherId(),
entry.getValue().get(0).teacherName(),
entry.getKey().departmentId(),
entry.getValue()
))
.sorted(Comparator
.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))
.thenComparing(
WorkloadSummaryDto::departmentId,
Comparator.nullsLast(Long::compareTo)
))
.toList();
}
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate
) {
Set<Long> teacherIds = lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return teacherDepartmentService.findAssignmentsForTeachersBetween(
teacherIds,
startDate,
endDate
);
}
private Optional<Long> departmentIdAtLesson(
RenderedLessonDto lesson,
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher
) {
if (lesson.teacherId() == null || lesson.date() == null) {
return Optional.empty();
}
return teacherDepartmentService.resolveDepartmentIdAtDate(
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
lesson.date()
);
}
@GetMapping("/time-slots")
public List<WorkloadSummaryDto> timeSlotWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarize(lessons, RenderedLessonDto::timeSlotId,
lesson -> lesson.timeSlotOrder() == null
? "Пара"
@@ -121,8 +178,10 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam Long timeSlotId
) {
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
null, null, timeSlotId, null, date, date)
Long effectiveDepartmentId = effectiveDepartmentId(null);
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, timeSlotId, date, date
)
.stream()
.map(RenderedLessonDto::classroomId)
.filter(Objects::nonNull)
@@ -166,6 +225,9 @@ public class WorkloadController {
if (departmentId == null) {
return null;
}
if (departmentId == 0L) {
return "Кафедра не указана";
}
return departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Кафедра не найдена");
@@ -185,4 +247,35 @@ public class WorkloadController {
new ArrayList<>(classroom.getEquipments())
);
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
if (currentUser == null) {
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"Не удалось определить текущего пользователя"
);
}
if (currentUser.role() != Role.DEPARTMENT) {
return requestedDepartmentId;
}
Long ownDepartmentId = currentUser.departmentId();
if (ownDepartmentId == null) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Для пользователя кафедры не указана кафедра"
);
}
if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Нельзя просматривать нагрузку другой кафедры"
);
}
return ownDepartmentId;
}
private record TeacherDepartmentKey(Long teacherId, Long departmentId) {
}
}

View File

@@ -1,7 +1,7 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
public class ClassroomResponse {
@@ -12,7 +12,7 @@ public class ClassroomResponse {
private Integer floor;
private Boolean isAvailable;
private String status;
private LocalDateTime archivedAt;
private Instant archivedAt;
private String archiveReason;
private List<Equipment> equipments;
@@ -25,7 +25,7 @@ public class ClassroomResponse {
}
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, String status, LocalDateTime archivedAt,
Boolean isAvailable, String status, Instant archivedAt,
String archiveReason, List<Equipment> equipments) {
this.id = id;
this.name = name;
@@ -95,11 +95,11 @@ public class ClassroomResponse {
this.status = status;
}
public LocalDateTime getArchivedAt() {
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}

View File

@@ -1,7 +1,7 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
public record ScheduleOverrideDto(
Long id,
@@ -14,6 +14,6 @@ public record ScheduleOverrideDto(
String newLessonFormat,
String comment,
Long createdBy,
LocalDateTime createdAt
Instant createdAt
) {
}

View File

@@ -1,6 +1,6 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
import java.time.Instant;
public record SubjectCommentDto(
Long id,
@@ -8,6 +8,6 @@ public record SubjectCommentDto(
Long authorId,
String authorName,
String comment,
LocalDateTime createdAt
Instant createdAt
) {
}

View File

@@ -1,6 +1,6 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
import java.time.Instant;
public record TeacherCreationRequestResponse(
Long id,
@@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse(
String comment,
String status,
Long requestedBy,
LocalDateTime createdAt,
Instant createdAt,
Long reviewedBy,
LocalDateTime reviewedAt,
Instant reviewedAt,
String reviewComment,
Long createdTeacherId
) {

View File

@@ -0,0 +1,78 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "auth_login_attempt_audit")
public class AuthLoginAttemptAudit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String tenant;
@Column(name = "username_normalized", nullable = false, length = 100)
private String usernameNormalized;
@Column(name = "client_ip", nullable = false, length = 64)
private String clientIp;
@Column(nullable = false, length = 20)
private String outcome;
@Column(name = "occurred_at", nullable = false)
private Instant occurredAt;
@Column(name = "retry_after_seconds")
private Integer retryAfterSeconds;
public AuthLoginAttemptAudit() {
}
public AuthLoginAttemptAudit(String tenant,
String usernameNormalized,
String clientIp,
String outcome,
Instant occurredAt,
Integer retryAfterSeconds) {
this.tenant = tenant;
this.usernameNormalized = usernameNormalized;
this.clientIp = clientIp;
this.outcome = outcome;
this.occurredAt = occurredAt;
this.retryAfterSeconds = retryAfterSeconds;
}
public String getTenant() {
return tenant;
}
public String getUsernameNormalized() {
return usernameNormalized;
}
public String getClientIp() {
return clientIp;
}
public String getOutcome() {
return outcome;
}
public Instant getOccurredAt() {
return occurredAt;
}
public Integer getRetryAfterSeconds() {
return retryAfterSeconds;
}
}

View File

@@ -0,0 +1,83 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "auth_login_rate_limits")
public class AuthLoginRateLimit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String tenant;
@Column(name = "username_normalized", nullable = false, length = 100)
private String usernameNormalized;
@Column(name = "client_ip", nullable = false, length = 64)
private String clientIp;
@Column(name = "failure_count", nullable = false)
private int failureCount;
@Column(name = "window_started_at", nullable = false)
private Instant windowStartedAt;
@Column(name = "last_failure_at")
private Instant lastFailureAt;
@Column(name = "blocked_until")
private Instant blockedUntil;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() {
return id;
}
public int getFailureCount() {
return failureCount;
}
public void setFailureCount(int failureCount) {
this.failureCount = failureCount;
}
public Instant getWindowStartedAt() {
return windowStartedAt;
}
public void setWindowStartedAt(Instant windowStartedAt) {
this.windowStartedAt = windowStartedAt;
}
public Instant getLastFailureAt() {
return lastFailureAt;
}
public void setLastFailureAt(Instant lastFailureAt) {
this.lastFailureAt = lastFailureAt;
}
public Instant getBlockedUntil() {
return blockedUntil;
}
public void setBlockedUntil(Instant blockedUntil) {
this.blockedUntil = blockedUntil;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "auth_refresh_tokens")
@@ -31,13 +31,13 @@ public class AuthRefreshToken {
private String tokenHash;
@Column(name = "issued_at", nullable = false)
private LocalDateTime issuedAt;
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
private Instant expiresAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
private Instant revokedAt;
@Column(name = "rotated_to_token_hash", length = 64)
private String rotatedToTokenHash;
@@ -80,27 +80,27 @@ public class AuthRefreshToken {
this.tokenHash = tokenHash;
}
public LocalDateTime getIssuedAt() {
public Instant getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(LocalDateTime issuedAt) {
public void setIssuedAt(Instant issuedAt) {
this.issuedAt = issuedAt;
}
public LocalDateTime getExpiresAt() {
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public LocalDateTime getRevokedAt() {
public Instant getRevokedAt() {
return revokedAt;
}
public void setRevokedAt(LocalDateTime revokedAt) {
public void setRevokedAt(Instant revokedAt) {
this.revokedAt = revokedAt;
}
@@ -128,7 +128,7 @@ public class AuthRefreshToken {
this.ipAddress = ipAddress;
}
public boolean isActive(LocalDateTime now) {
public boolean isActive(Instant now) {
return revokedAt == null && expiresAt.isAfter(now);
}
}

View File

@@ -5,7 +5,8 @@ import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
@MappedSuperclass
public abstract class LifecycleEntity {
@@ -23,7 +24,7 @@ public abstract class LifecycleEntity {
private LocalDate activeTo;
@Column(name = "archived_at")
private LocalDateTime archivedAt;
private Instant archivedAt;
@Column(name = "archive_reason")
private String archiveReason;
@@ -52,11 +53,11 @@ public abstract class LifecycleEntity {
this.activeTo = activeTo;
}
public LocalDateTime getArchivedAt() {
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}
@@ -92,9 +93,13 @@ public abstract class LifecycleEntity {
}
public void archive(String reason) {
archive(reason, LocalDate.now(ZoneId.of("Europe/Moscow")), Instant.now());
}
public void archive(String reason, LocalDate businessDate, Instant archiveMoment) {
status = STATUS_ARCHIVED;
archivedAt = LocalDateTime.now();
activeTo = LocalDate.now();
archivedAt = archiveMoment;
activeTo = businessDate;
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
}

View File

@@ -3,7 +3,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "schedule_overrides")
@@ -45,7 +45,7 @@ public class ScheduleOverride {
private Long createdBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
public Long getId() {
return id;
@@ -123,11 +123,11 @@ public class ScheduleOverride {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 200)
@Column(nullable = false, length = 200)
private String name;
@Column(name = "code")

View File

@@ -2,7 +2,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "subject_comments")
@@ -24,7 +24,7 @@ public class SubjectComment {
private String comment;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
public Long getId() {
return id;
@@ -54,11 +54,11 @@ public class SubjectComment {
this.comment = comment;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -2,7 +2,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "teacher_creation_requests")
@@ -36,16 +36,16 @@ public class TeacherCreationRequest {
private Long requestedBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
private Instant updatedAt = Instant.now();
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
private Instant reviewedAt;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@@ -55,7 +55,7 @@ public class TeacherCreationRequest {
@PreUpdate
public void touch() {
updatedAt = LocalDateTime.now();
updatedAt = Instant.now();
}
public Long getId() {
@@ -118,19 +118,19 @@ public class TeacherCreationRequest {
this.requestedBy = requestedBy;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
@@ -142,11 +142,11 @@ public class TeacherCreationRequest {
this.reviewedBy = reviewedBy;
}
public LocalDateTime getReviewedAt() {
public Instant getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(LocalDateTime reviewedAt) {
public void setReviewedAt(Instant reviewedAt) {
this.reviewedAt = reviewedAt;
}

View File

@@ -3,7 +3,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "teacher_department_assignments")
@@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment {
private String comment;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
@Column(name = "created_by")
private Long createdBy;
@@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment {
this.comment = comment;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}

View File

@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCal
@Param("date") LocalDate date
);
@Query("""
select day
from AcademicCalendarDay day
join fetch day.academicCalendar
join fetch day.activityType
where day.academicCalendar.id in :calendarIds
and day.date between :startDate and :endDate
order by day.academicCalendar.id, day.courseNumber, day.date
""")
List<AcademicCalendarDay> findForScheduleBatch(
@Param("calendarIds") Collection<Long> calendarIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
boolean existsByActivityTypeId(Long activityTypeId);
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
@Query("""
select case when count(day) > 0 then true else false end
from AcademicCalendarDay day
where day.academicCalendar.id = :calendarId
and (day.date < :startDate or day.date > :endDate)
""")
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Modifying
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
void deleteByCalendarId(@Param("calendarId") Long calendarId);

View File

@@ -1,7 +1,9 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendar;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
where calendar.id = :id
""")
Optional<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select calendar
from AcademicCalendar calendar
join fetch calendar.academicYear
join fetch calendar.speciality
join fetch calendar.specialtyProfile
join fetch calendar.studyForm
where calendar.id = :id
""")
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
}

View File

@@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository<Academi
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
void deleteByAcademicCalendar_Id(Long calendarId);
boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber);
}

View File

@@ -1,11 +1,44 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicYear;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Optional;
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
Optional<AcademicYear> findByTitle(String title);
boolean existsByTitle(String title);
boolean existsByTitleAndIdNot(String title, Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select year from AcademicYear year where year.id = :id")
Optional<AcademicYear> findByIdForUpdate(@Param("id") Long id);
@Query("""
select case when count(year) > 0 then true else false end
from AcademicYear year
where year.startDate <= :endDate
and year.endDate >= :startDate
""")
boolean existsOverlapping(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Query("""
select case when count(year) > 0 then true else false end
from AcademicYear year
where year.id <> :excludedId
and year.startDate <= :endDate
and year.endDate >= :startDate
""")
boolean existsOverlappingExcluding(@Param("excludedId") Long excludedId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}

View File

@@ -0,0 +1,31 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthLoginAttemptAudit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
public interface AuthLoginAttemptAuditRepository extends JpaRepository<AuthLoginAttemptAudit, Long> {
@Modifying
@Transactional
@Query(value = """
WITH cleanup_candidates AS (
SELECT id
FROM auth_login_attempt_audit
WHERE occurred_at < :cutoff
ORDER BY occurred_at, id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
DELETE FROM auth_login_attempt_audit audit
USING cleanup_candidates candidate
WHERE audit.id = candidate.id
""", nativeQuery = true)
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
@Param("batchSize") int batchSize);
}

View File

@@ -0,0 +1,61 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthLoginRateLimit;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
public interface AuthLoginRateLimitRepository extends JpaRepository<AuthLoginRateLimit, Long> {
@Modifying(flushAutomatically = true)
@Query(value = """
INSERT INTO auth_login_rate_limits (
tenant, username_normalized, client_ip, failure_count,
window_started_at, updated_at
) VALUES (:tenant, :username, :clientIp, 0, :now, :now)
ON CONFLICT (tenant, username_normalized, client_ip) DO NOTHING
""", nativeQuery = true)
int ensureExists(@Param("tenant") String tenant,
@Param("username") String username,
@Param("clientIp") String clientIp,
@Param("now") Instant now);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT limit
FROM AuthLoginRateLimit limit
WHERE limit.tenant = :tenant
AND limit.usernameNormalized = :username
AND limit.clientIp = :clientIp
""")
Optional<AuthLoginRateLimit> findForUpdate(@Param("tenant") String tenant,
@Param("username") String username,
@Param("clientIp") String clientIp);
@Modifying
@Transactional
@Query(value = """
WITH cleanup_candidates AS (
SELECT id
FROM auth_login_rate_limits
WHERE updated_at < :cutoff
AND (blocked_until IS NULL OR blocked_until <= :now)
ORDER BY updated_at, id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
DELETE FROM auth_login_rate_limits rate_limit
USING cleanup_candidates candidate
WHERE rate_limit.id = candidate.id
""", nativeQuery = true)
int deleteStaleBatch(@Param("cutoff") Instant cutoff,
@Param("now") Instant now,
@Param("batchSize") int batchSize);
}

View File

@@ -4,9 +4,12 @@ import com.magistr.app.model.AuthRefreshToken;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
@@ -21,4 +24,27 @@ public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshTok
WHERE token.tokenHash = :tokenHash
""")
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
/**
* Удаляет ограниченную пачку старых audit-записей. SKIP LOCKED позволяет нескольким
* backend-pod безопасно разбирать непересекающиеся пачки одной tenant-БД.
*/
@Modifying
@Transactional
@Query(value = """
WITH cleanup_candidates AS (
SELECT id
FROM auth_refresh_tokens
WHERE expires_at < :cutoff
OR (revoked_at IS NOT NULL AND revoked_at < :cutoff)
ORDER BY LEAST(expires_at, COALESCE(revoked_at, expires_at)), id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
DELETE FROM auth_refresh_tokens token
USING cleanup_candidates candidate
WHERE token.id = candidate.id
""", nativeQuery = true)
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
@Param("batchSize") int batchSize);
}

View File

@@ -1,9 +1,14 @@
package com.magistr.app.repository;
import com.magistr.app.model.StudentGroup;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
@@ -14,4 +19,15 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
List<StudentGroup> findByStatusNot(String status);
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select studentGroup
from StudentGroup studentGroup
join fetch studentGroup.educationForm
join fetch studentGroup.speciality
join fetch studentGroup.specialtyProfile
where studentGroup.id = :id
""")
Optional<StudentGroup> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.Collection;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
@@ -60,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
@Param("semesterId") Long semesterId
);
@Query("""
select distinct r
from ScheduleRule r
join r.groups matchingGroup
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where matchingGroup.id in :groupIds
and sem.id in :semesterIds
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdsAndSemesterIds(
@Param("groupIds") Collection<Long> groupIds,
@Param("semesterIds") Collection<Long> semesterIds
);
@Query("""
select distinct r
from ScheduleRule r
join r.slots teacherSlot
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id in :semesterIds
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterIds(
@Param("teacherId") Long teacherId,
@Param("semesterIds") Collection<Long> semesterIds
);
@Query("""
select distinct r
from ScheduleRule r

View File

@@ -9,4 +9,6 @@ public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSl
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
boolean existsByTimeSlotId(Long timeSlotId);
}

View File

@@ -17,10 +17,51 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
@Query("""
select semester
from Semester semester
join fetch semester.academicYear
where semester.startDate <= :endDate
and semester.endDate >= :startDate
order by semester.startDate, semester.id
""")
List<Semester> findOverlappingForSchedule(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
boolean existsByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
boolean existsByAcademicYearIdAndSemesterTypeAndIdNot(Long academicYearId,
SemesterType semesterType,
Long id);
@Query("""
select case when count(semester) > 0 then true else false end
from Semester semester
where semester.academicYear.id = :academicYearId
and semester.startDate <= :endDate
and semester.endDate >= :startDate
""")
boolean existsOverlapping(@Param("academicYearId") Long academicYearId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Query("""
select case when count(semester) > 0 then true else false end
from Semester semester
where semester.academicYear.id = :academicYearId
and semester.id <> :excludedId
and semester.startDate <= :endDate
and semester.endDate >= :startDate
""")
boolean existsOverlappingExcluding(@Param("academicYearId") Long academicYearId,
@Param("excludedId") Long excludedId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select semester from Semester semester where semester.id = :id")
Optional<Semester> findByIdForUpdate(@Param("id") Long id);

View File

@@ -1,12 +1,15 @@
package com.magistr.app.repository;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.Collection;
public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<StudentGroupCalendarAssignment, Long> {
@@ -51,4 +54,52 @@ public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<
@Param("groupId") Long groupId,
@Param("academicYearId") Long academicYearId
);
@Query("""
select assignment
from StudentGroupCalendarAssignment assignment
join fetch assignment.studentGroup
join fetch assignment.academicYear
join fetch assignment.academicCalendar
where assignment.studentGroup.id in :groupIds
and assignment.academicYear.id in :academicYearIds
""")
List<StudentGroupCalendarAssignment> findForScheduleBatch(
@Param("groupIds") Collection<Long> groupIds,
@Param("academicYearIds") Collection<Long> academicYearIds
);
@Query("""
select assignment
from StudentGroupCalendarAssignment assignment
join fetch assignment.studentGroup studentGroup
join fetch studentGroup.educationForm
join fetch studentGroup.speciality
join fetch studentGroup.specialtyProfile
join fetch assignment.academicYear
join fetch assignment.academicCalendar calendar
join fetch calendar.speciality
join fetch calendar.specialtyProfile
join fetch calendar.studyForm
where calendar.id = :calendarId
order by assignment.id
""")
List<StudentGroupCalendarAssignment> findByCalendarIdWithDetails(@Param("calendarId") Long calendarId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select assignment
from StudentGroupCalendarAssignment assignment
join fetch assignment.academicYear
join fetch assignment.academicCalendar calendar
join fetch calendar.speciality
join fetch calendar.specialtyProfile
join fetch calendar.studyForm
where assignment.studentGroup.id = :groupId
and assignment.academicYear.id = :academicYearId
""")
Optional<StudentGroupCalendarAssignment> findByGroupIdAndAcademicYearIdForUpdate(
@Param("groupId") Long groupId,
@Param("academicYearId") Long academicYearId
);
}

View File

@@ -7,7 +7,7 @@ import java.util.List;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
Optional<Subject> findByNameIgnoreCase(String name);
List<Subject> findByDepartmentId(Long departmentId);

View File

@@ -1,13 +1,16 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherDepartmentAssignment;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
@@ -60,4 +63,46 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.department
where a.teacher.id = :teacherId
and a.primaryAssignment = true
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
order by a.validFrom desc, a.id desc
""")
Optional<TeacherDepartmentAssignment> findPrimaryAtDate(
@Param("teacherId") Long teacherId,
@Param("date") LocalDate date
);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.department
where a.teacher.id = :teacherId
and a.primaryAssignment = true
order by a.validFrom, a.id
""")
List<TeacherDepartmentAssignment> findPrimaryHistoryForUpdate(@Param("teacherId") Long teacherId);
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.teacher
join fetch a.department
where a.teacher.id in :teacherIds
and a.validFrom <= :endDate
and (a.validTo is null or a.validTo >= :startDate)
order by a.teacher.id, a.validFrom, a.id
""")
List<TeacherDepartmentAssignment> findForTeachersBetween(
@Param("teacherIds") Set<Long> teacherIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
}

View File

@@ -2,6 +2,8 @@ package com.magistr.app.repository;
import com.magistr.app.model.TimeSlotDateAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
@@ -15,5 +17,15 @@ public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlot
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
@Query("""
select assignment
from TimeSlotDateAssignment assignment
join fetch assignment.timeSlotScope
where assignment.date between :startDate and :endDate
order by assignment.date
""")
List<TimeSlotDateAssignment> findForScheduleRange(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
boolean existsByTimeSlotScopeId(Long scopeId);
}

View File

@@ -1,7 +1,13 @@
package com.magistr.app.repository;
import com.magistr.app.model.TimeSlot;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalTime;
import java.util.List;
import java.util.Optional;
@@ -14,5 +20,45 @@ public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
@Query("""
select slot
from TimeSlot slot
join fetch slot.timeSlotScope
order by slot.timeSlotScope.displayOrder, slot.orderNumber, slot.startTime
""")
List<TimeSlot> findAllForSchedule();
boolean existsByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
boolean existsByTimeSlotScopeIdAndOrderNumberAndIdNot(Long scopeId, Integer orderNumber, Long id);
boolean existsByTimeSlotScopeId(Long scopeId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select slot from TimeSlot slot where slot.id = :id")
Optional<TimeSlot> findByIdForUpdate(@Param("id") Long id);
@Query("""
select case when count(slot) > 0 then true else false end
from TimeSlot slot
where slot.timeSlotScope.id = :scopeId
and slot.startTime < :endTime
and slot.endTime > :startTime
""")
boolean existsOverlapping(@Param("scopeId") Long scopeId,
@Param("startTime") LocalTime startTime,
@Param("endTime") LocalTime endTime);
@Query("""
select case when count(slot) > 0 then true else false end
from TimeSlot slot
where slot.timeSlotScope.id = :scopeId
and slot.id <> :excludedId
and slot.startTime < :endTime
and slot.endTime > :startTime
""")
boolean existsOverlappingExcluding(@Param("scopeId") Long scopeId,
@Param("excludedId") Long excludedId,
@Param("startTime") LocalTime startTime,
@Param("endTime") LocalTime endTime);
}

View File

@@ -1,7 +1,11 @@
package com.magistr.app.repository;
import com.magistr.app.model.TimeSlotScope;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -15,4 +19,10 @@ public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Lo
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
List<TimeSlotScope> findByApplyModeOrderByDisplayOrderAsc(String applyMode);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select scope from TimeSlotScope scope where scope.id = :id")
Optional<TimeSlotScope> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -2,7 +2,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -20,4 +24,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByRoleAndStatusNot(Role role, String status);
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from User user where user.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class AcademicDateService {
@@ -33,6 +35,64 @@ public class AcademicDateService {
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date);
}
public List<Semester> findSemesters(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
return List.of();
}
return semesterRepository.findOverlappingForSchedule(startDate, endDate);
}
/**
* Загружает календарные данные одним набором batch-запросов и возвращает снимок,
* который не обращается к репозиториям во внутренних циклах генератора.
*/
public ScheduleSnapshot createScheduleSnapshot(Collection<StudentGroup> groups,
Collection<Semester> semesters,
LocalDate endDate) {
List<Semester> semesterSnapshot = Optional.ofNullable(semesters)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.sorted(Comparator.comparing(Semester::getStartDate).thenComparing(Semester::getId))
.toList();
LocalDate snapshotStart = semesterSnapshot.stream()
.map(Semester::getStartDate)
.min(LocalDate::compareTo)
.orElse(endDate);
Set<Long> groupIds = Optional.ofNullable(groups)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.map(StudentGroup::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> academicYearIds = semesterSnapshot.stream()
.map(Semester::getAcademicYear)
.filter(Objects::nonNull)
.map(AcademicYear::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty()
? List.of()
: assignmentRepository.findForScheduleBatch(groupIds, academicYearIds);
Set<Long> calendarIds = assignments.stream()
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.filter(Objects::nonNull)
.map(AcademicCalendar::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<AcademicCalendarDay> calendarDays = calendarIds.isEmpty()
|| snapshotStart == null
|| endDate == null
|| endDate.isBefore(snapshotStart)
? List.of()
: calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate);
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate);
}
public int getWeekNumber(Semester semester, LocalDate date) {
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
if (days < 0) {
@@ -86,4 +146,128 @@ public class AcademicDateService {
}
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent();
}
public static final class ScheduleSnapshot {
private final Map<LocalDate, Semester> semesterByDate;
private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear;
private final Map<CalendarDayKey, AcademicCalendarActivityType> activityByCalendarCourseAndDate;
private final LocalDate startDate;
private final LocalDate endDate;
private ScheduleSnapshot(Collection<Semester> semesters,
Collection<StudentGroupCalendarAssignment> assignments,
Collection<AcademicCalendarDay> calendarDays,
LocalDate startDate,
LocalDate endDate) {
this.startDate = startDate;
this.endDate = endDate;
this.semesterByDate = indexSemesters(semesters, startDate, endDate);
this.assignmentByGroupAndYear = assignments.stream()
.collect(Collectors.toUnmodifiableMap(
assignment -> new GroupAcademicYearKey(
assignment.getStudentGroup().getId(),
assignment.getAcademicYear().getId()
),
Function.identity()
));
this.activityByCalendarCourseAndDate = calendarDays.stream()
.collect(Collectors.toUnmodifiableMap(
day -> new CalendarDayKey(
day.getAcademicCalendar().getId(),
day.getCourseNumber(),
day.getDate()
),
AcademicCalendarDay::getActivityType
));
}
public Optional<Semester> findSemester(LocalDate date) {
return Optional.ofNullable(semesterByDate.get(date));
}
public boolean hasCalendarAssignment(StudentGroup group, Semester semester) {
return findAssignment(group, semester).isPresent();
}
public Optional<AcademicCalendarActivityType> getActivityType(StudentGroup group,
Semester semester,
LocalDate date) {
int courseNumber = courseNumber(group, date);
if (courseNumber < 1) {
return Optional.empty();
}
return findAssignment(group, semester)
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.map(AcademicCalendar::getId)
.map(calendarId -> activityByCalendarCourseAndDate.get(
new CalendarDayKey(calendarId, courseNumber, date)
));
}
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
return getActivityType(group, semester, date)
.map(AcademicCalendarActivityType::getAllowSchedule)
.orElse(false);
}
public LocalDate startDate() {
return startDate;
}
public LocalDate endDate() {
return endDate;
}
private Optional<StudentGroupCalendarAssignment> findAssignment(StudentGroup group, Semester semester) {
if (group == null
|| group.getId() == null
|| semester == null
|| semester.getAcademicYear() == null
|| semester.getAcademicYear().getId() == null) {
return Optional.empty();
}
return Optional.ofNullable(assignmentByGroupAndYear.get(
new GroupAcademicYearKey(group.getId(), semester.getAcademicYear().getId())
));
}
private static Map<LocalDate, Semester> indexSemesters(Collection<Semester> semesters,
LocalDate startDate,
LocalDate endDate) {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
return Map.of();
}
Map<LocalDate, Semester> result = new HashMap<>();
for (Semester semester : semesters) {
LocalDate effectiveStart = semester.getStartDate().isAfter(startDate)
? semester.getStartDate()
: startDate;
LocalDate effectiveEnd = semester.getEndDate().isBefore(endDate)
? semester.getEndDate()
: endDate;
for (LocalDate date = effectiveStart; !date.isAfter(effectiveEnd); date = date.plusDays(1)) {
Semester previous = result.putIfAbsent(date, semester);
if (previous != null && !Objects.equals(previous.getId(), semester.getId())) {
throw new IllegalStateException("На одну дату назначено несколько семестров");
}
}
}
return Map.copyOf(result);
}
}
private static int courseNumber(StudentGroup group, LocalDate date) {
if (group == null || date == null || group.getYearStartStudy() == null) {
return 0;
}
int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1;
return academicYearStart - group.getYearStartStudy() + 1;
}
private record GroupAcademicYearKey(Long groupId, Long academicYearId) {
}
private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) {
}
}

View File

@@ -0,0 +1,251 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Semester;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.SemesterRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDate;
import java.util.NoSuchElementException;
@Service
public class AcademicPeriodService {
private static final String YEAR_CONFLICT_MESSAGE =
"Название или период учебного года конфликтует с существующим учебным годом";
private static final String SEMESTER_CONFLICT_MESSAGE =
"Тип или период семестра конфликтует с существующим семестром учебного года";
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public AcademicYear createYear(AcademicYearDto request) {
validateYear(request);
String title = request.title().trim();
if (academicYearRepository.existsByTitle(title)
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
AcademicYear year = new AcademicYear();
applyYear(year, title, request.startDate(), request.endDate());
AcademicYear saved = saveYear(year);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public AcademicYear updateYear(Long id, AcademicYearDto request) {
validateYear(request);
AcademicYear year = lockYear(id);
String title = request.title().trim();
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|| academicYearRepository.existsOverlappingExcluding(
id, request.startDate(), request.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
boolean semesterOutsideNewRange = semesterRepository
.findByAcademicYearIdOrderByStartDateAsc(id)
.stream()
.anyMatch(semester -> !contains(
request.startDate(),
request.endDate(),
semester.getStartDate(),
semester.getEndDate()
));
if (semesterOutsideNewRange) {
throw new IllegalArgumentException(
"Новые границы учебного года не включают все его семестры"
);
}
applyYear(year, title, request.startDate(), request.endDate());
AcademicYear saved = saveYear(year);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public void deleteYear(Long id) {
AcademicYear year = lockYear(id);
academicYearRepository.delete(year);
academicYearRepository.flush();
clearScheduleCacheAfterCommit();
}
@Transactional
public Semester createSemester(Long academicYearId, SemesterDto request) {
validateSemester(request);
AcademicYear year = lockYear(academicYearId);
validateSemesterWithinYear(year, request.startDate(), request.endDate());
ensureSemesterAvailable(year.getId(), request, null);
Semester semester = new Semester();
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public Semester updateSemester(Long id, SemesterDto request) {
validateSemester(request);
Semester snapshot = semesterRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
AcademicYear year = lockYear(snapshot.getAcademicYear().getId());
Semester semester = semesterRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
if (!semester.getAcademicYear().getId().equals(year.getId())) {
throw new ScheduleConflictException("Учебный год семестра был изменён конкурентно");
}
validateSemesterWithinYear(year, request.startDate(), request.endDate());
ensureSemesterAvailable(year.getId(), request, id);
applySemester(semester, request);
Semester saved = saveSemester(semester);
clearScheduleCacheAfterCommit();
return saved;
}
private void validateYear(AcademicYearDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные учебного года обязательны");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название учебного года обязательно");
}
validateDateRange(
request.startDate(),
request.endDate(),
"Даты начала и окончания учебного года обязательны",
"Дата окончания учебного года не может быть раньше даты начала"
);
}
private void validateSemester(SemesterDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные семестра обязательны");
}
if (request.semesterType() == null) {
throw new IllegalArgumentException("Тип семестра обязателен");
}
validateDateRange(
request.startDate(),
request.endDate(),
"Даты начала и окончания семестра обязательны",
"Дата окончания семестра не может быть раньше даты начала"
);
}
private void validateDateRange(LocalDate startDate,
LocalDate endDate,
String requiredMessage,
String orderMessage) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException(requiredMessage);
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException(orderMessage);
}
}
private void validateSemesterWithinYear(AcademicYear year,
LocalDate startDate,
LocalDate endDate) {
if (!contains(year.getStartDate(), year.getEndDate(), startDate, endDate)) {
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
}
}
private boolean contains(LocalDate outerStart,
LocalDate outerEnd,
LocalDate innerStart,
LocalDate innerEnd) {
return !innerStart.isBefore(outerStart) && !innerEnd.isAfter(outerEnd);
}
private void ensureSemesterAvailable(Long academicYearId, SemesterDto request, Long currentId) {
boolean duplicateType = currentId == null
? semesterRepository.existsByAcademicYearIdAndSemesterType(
academicYearId, request.semesterType())
: semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
academicYearId, request.semesterType(), currentId);
boolean overlapping = currentId == null
? semesterRepository.existsOverlapping(
academicYearId, request.startDate(), request.endDate())
: semesterRepository.existsOverlappingExcluding(
academicYearId, currentId, request.startDate(), request.endDate());
if (duplicateType || overlapping) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
private AcademicYear lockYear(Long id) {
return academicYearRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Учебный год не найден"));
}
private void applyYear(AcademicYear year,
String title,
LocalDate startDate,
LocalDate endDate) {
year.setTitle(title);
year.setStartDate(startDate);
year.setEndDate(endDate);
}
private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate());
semester.setEndDate(request.endDate());
}
private AcademicYear saveYear(AcademicYear year) {
try {
return academicYearRepository.saveAndFlush(year);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
}
private Semester saveSemester(Semester semester) {
try {
return semesterRepository.saveAndFlush(semester);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
scheduleGeneratorService.clearCache();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
scheduleGeneratorService.clearCache();
}
});
}
}

View File

@@ -0,0 +1,421 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.NoSuchElementException;
@Service
public class AcademicStructureService {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final GroupRepository groupRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
GroupRepository groupRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.groupRepository = groupRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request);
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Календарный график не найден"));
CalendarDimensions candidate = resolveCalendarDimensions(request);
List<StudentGroupCalendarAssignment> assignments =
assignmentRepository.findByCalendarIdWithDetails(id);
boolean incompatibleAssignment = assignments.stream()
.anyMatch(assignment -> !isCompatible(
assignment.getStudentGroup(),
assignment.getAcademicYear(),
candidate.academicYear(),
candidate.speciality(),
candidate.profile(),
candidate.studyForm(),
request.courseCount()
));
if (incompatibleAssignment) {
throw new ScheduleConflictException(
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
);
}
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
id, request.courseCount())) {
throw new ScheduleConflictException(
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
);
}
if (calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
id, request.courseCount() * 2)) {
throw new ScheduleConflictException(
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
);
}
if (calendarDayRepository.existsOutsideDateRange(
id,
candidate.academicYear().getStartDate(),
candidate.academicYear().getEndDate())) {
throw new ScheduleConflictException(
"Нельзя изменить учебный год графика: сохранённая сетка содержит даты за его границами"
);
}
calendar.setTitle(request.title().trim());
applyCalendarDimensions(calendar, candidate, request.courseCount());
AcademicCalendar saved = saveCalendar(calendar);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public StudentGroup updateGroup(Long id, CreateGroupRequest request) {
validateGroupRequest(request);
StudentGroup group = groupRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
GroupDimensions candidate = resolveGroupDimensions(request);
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
.anyMatch(assignment -> !isCompatible(
request.getYearStartStudy(),
candidate.speciality(),
candidate.profile(),
candidate.studyForm(),
assignment.getAcademicYear(),
assignment.getAcademicCalendar()
));
if (incompatibleAssignment) {
throw new ScheduleConflictException(
"Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми"
);
}
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(candidate.studyForm());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(candidate.speciality());
group.setSpecialtyProfile(candidate.profile());
StudentGroup saved = saveGroup(group);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public StudentGroupCalendarAssignment saveAssignment(Long groupId,
GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
throw new IllegalArgumentException("Учебный год и календарный график обязательны");
}
StudentGroup group = groupRepository.findByIdForUpdate(groupId)
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(request.calendarId())
.orElseThrow(() -> new IllegalArgumentException("Календарный график не найден"));
AcademicYear academicYear = academicYearRepository.findByIdForUpdate(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
if (!isCompatible(group, academicYear, calendar)) {
throw new IllegalArgumentException(
"Календарный график не соответствует учебному году, параметрам или курсу группы"
);
}
StudentGroupCalendarAssignment assignment = assignmentRepository
.findByGroupIdAndAcademicYearIdForUpdate(groupId, academicYear.getId())
.orElseGet(StudentGroupCalendarAssignment::new);
assignment.setStudentGroup(group);
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
try {
StudentGroupCalendarAssignment saved = assignmentRepository.saveAndFlush(assignment);
clearScheduleCacheAfterCommit();
return saved;
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Назначение календарного графика было изменено конкурентно"
);
}
}
private void validateCalendarRequest(AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название графика обязательно");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null
|| request.specialtyId() == null
|| request.specialtyProfileId() == null
|| request.studyFormId() == null) {
throw new IllegalArgumentException(
"Учебный год, специальность, профиль и форма обучения обязательны"
);
}
}
private void validateGroupRequest(CreateGroupRequest request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные учебной группы");
}
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Название группы обязательно");
}
if (request.getGroupSize() == null) {
throw new IllegalArgumentException("Численность группы обязательна");
}
if (request.getEducationFormId() == null) {
throw new IllegalArgumentException("Форма обучения обязательна");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
throw new IllegalArgumentException("ID кафедры обязателен");
}
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
throw new IllegalArgumentException("Год начала обучения обязателен");
}
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
throw new IllegalArgumentException("Код специальности обязателен");
}
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
throw new IllegalArgumentException("Профиль обучения обязателен");
}
}
private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) {
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = resolveProfile(request.specialtyProfileId(), speciality);
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
return new CalendarDimensions(academicYear, speciality, profile, studyForm);
}
private GroupDimensions resolveGroupDimensions(CreateGroupRequest request) {
EducationForm studyForm = educationFormRepository.findById(request.getEducationFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = resolveProfile(request.getSpecialtyProfileId(), speciality);
return new GroupDimensions(speciality, profile, studyForm);
}
private SpecialtyProfile resolveProfile(Long profileId, Speciality speciality) {
SpecialtyProfile profile = profileRepository.findById(profileId)
.orElseThrow(() -> new IllegalArgumentException(
"Профиль обучения не найден для выбранной специальности"
));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException(
"Профиль обучения не найден для выбранной специальности"
);
}
return profile;
}
private boolean isCompatible(StudentGroup group,
AcademicYear assignmentYear,
AcademicCalendar calendar) {
return isCompatible(
group,
assignmentYear,
calendar.getAcademicYear(),
calendar.getSpeciality(),
calendar.getSpecialtyProfile(),
calendar.getStudyForm(),
calendar.getCourseCount()
);
}
private boolean isCompatible(StudentGroup group,
AcademicYear assignmentYear,
AcademicYear calendarYear,
Speciality calendarSpeciality,
SpecialtyProfile calendarProfile,
EducationForm calendarStudyForm,
Integer courseCount) {
return isCompatible(
group.getYearStartStudy(),
group.getSpeciality(),
group.getSpecialtyProfile(),
group.getEducationForm(),
assignmentYear,
calendarYear,
calendarSpeciality,
calendarProfile,
calendarStudyForm,
courseCount
);
}
private boolean isCompatible(Integer yearStartStudy,
Speciality groupSpeciality,
SpecialtyProfile groupProfile,
EducationForm groupStudyForm,
AcademicYear assignmentYear,
AcademicCalendar calendar) {
return isCompatible(
yearStartStudy,
groupSpeciality,
groupProfile,
groupStudyForm,
assignmentYear,
calendar.getAcademicYear(),
calendar.getSpeciality(),
calendar.getSpecialtyProfile(),
calendar.getStudyForm(),
calendar.getCourseCount()
);
}
private boolean isCompatible(Integer yearStartStudy,
Speciality groupSpeciality,
SpecialtyProfile groupProfile,
EducationForm groupStudyForm,
AcademicYear assignmentYear,
AcademicYear calendarYear,
Speciality calendarSpeciality,
SpecialtyProfile calendarProfile,
EducationForm calendarStudyForm,
Integer courseCount) {
if (!sameId(assignmentYear, calendarYear)
|| !sameId(groupSpeciality, calendarSpeciality)
|| !sameId(groupProfile, calendarProfile)
|| !sameId(groupStudyForm, calendarStudyForm)) {
return false;
}
int course = CourseAndSemesterCalculator.getActualCourse(
yearStartStudy,
assignmentYear.getStartDate()
);
return course >= 1 && courseCount != null && course <= courseCount;
}
private boolean sameId(AcademicYear first, AcademicYear second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private boolean sameId(Speciality first, Speciality second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private boolean sameId(SpecialtyProfile first, SpecialtyProfile second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private boolean sameId(EducationForm first, EducationForm second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private void applyCalendarDimensions(AcademicCalendar calendar,
CalendarDimensions dimensions,
Integer courseCount) {
calendar.setAcademicYear(dimensions.academicYear());
calendar.setSpeciality(dimensions.speciality());
calendar.setSpecialtyProfile(dimensions.profile());
calendar.setStudyForm(dimensions.studyForm());
calendar.setCourseCount(courseCount);
}
private AcademicCalendar saveCalendar(AcademicCalendar calendar) {
try {
return calendarRepository.saveAndFlush(calendar);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Изменение календарного графика конфликтует с сохранёнными данными"
);
}
}
private StudentGroup saveGroup(StudentGroup group) {
try {
return groupRepository.saveAndFlush(group);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Изменение учебной группы конфликтует с сохранёнными данными"
);
}
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
scheduleGeneratorService.clearCache();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
scheduleGeneratorService.clearCache();
}
});
}
private record CalendarDimensions(AcademicYear academicYear,
Speciality speciality,
SpecialtyProfile profile,
EducationForm studyForm) {
}
private record GroupDimensions(Speciality speciality,
SpecialtyProfile profile,
EducationForm studyForm) {
}
}

View File

@@ -0,0 +1,56 @@
package com.magistr.app.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.zone.ZoneRulesException;
import java.util.Objects;
/** Единый источник бизнес-даты и абсолютного времени приложения. */
@Service
public class BusinessTimeService {
public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Moscow");
private final Clock clock;
private final ZoneId zoneId;
@Autowired
public BusinessTimeService(@Value("${app.time.business-zone:Europe/Moscow}") String zoneId) {
this(Clock.systemUTC(), parseZone(zoneId));
}
public BusinessTimeService(Clock clock, ZoneId zoneId) {
this.clock = Objects.requireNonNull(clock, "Часы приложения обязательны");
this.zoneId = Objects.requireNonNull(zoneId, "Часовой пояс бизнес-даты обязателен");
}
public LocalDate today() {
return LocalDate.ofInstant(clock.instant(), zoneId);
}
public Instant now() {
return clock.instant();
}
public ZoneId zoneId() {
return zoneId;
}
public static BusinessTimeService systemDefault() {
return new BusinessTimeService(Clock.systemUTC(), DEFAULT_ZONE);
}
private static ZoneId parseZone(String value) {
try {
return ZoneId.of(value == null || value.isBlank() ? DEFAULT_ZONE.getId() : value.trim());
} catch (ZoneRulesException exception) {
throw new IllegalStateException("Некорректный часовой пояс бизнес-даты: " + value, exception);
}
}
}

View File

@@ -10,6 +10,7 @@ import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.DayOfWeek;
@@ -33,6 +34,7 @@ public class ScheduleGeneratorService {
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final BusinessTimeService businessTime;
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
@@ -41,6 +43,20 @@ public class ScheduleGeneratorService {
TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
this(scheduleRuleRepository, groupRepository, userRepository, academicDateService,
timeSlotRepository, timeSlotScopeRepository, dateAssignmentRepository,
BusinessTimeService.systemDefault());
}
@Autowired
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
UserRepository userRepository,
AcademicDateService academicDateService,
TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository,
BusinessTimeService businessTime) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.groupRepository = groupRepository;
this.userRepository = userRepository;
@@ -48,45 +64,134 @@ public class ScheduleGeneratorService {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
this.businessTime = businessTime;
}
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
validateRange(startDate, endDate);
StudentGroup group = groupRepository.findById(groupId)
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
return buildScheduleForGroupsInternal(List.of(group), startDate, endDate);
}
public List<RenderedLessonDto> buildScheduleForGroups(Collection<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate) {
validateRange(startDate, endDate);
List<StudentGroup> groupSnapshot = Optional.ofNullable(groups)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
StudentGroup::getId,
group -> group,
(first, second) -> first,
LinkedHashMap::new
))
.values()
.stream()
.toList();
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
}
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate) {
if (groups.isEmpty()) {
return List.of();
}
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
if (semesters.isEmpty()) {
return List.of();
}
Set<Long> semesterIds = semesters.stream()
.map(Semester::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> groupIds = groups.stream()
.map(StudentGroup::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
.createScheduleSnapshot(groups, semesters, endDate);
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester = indexRulesByGroupAndSemester(
rules,
groupIds
);
List<RenderedLessonDto> result = new ArrayList<>();
for (StudentGroup group : groups) {
result.addAll(buildScheduleForGroup(
group,
startDate,
endDate,
dateSnapshot,
timeSlotSnapshot,
rulesByGroupAndSemester
));
}
return result;
}
private List<RenderedLessonDto> buildScheduleForGroup(StudentGroup group,
LocalDate startDate,
LocalDate endDate,
AcademicDateService.ScheduleSnapshot dateSnapshot,
TimeSlotSnapshot timeSlotSnapshot,
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester) {
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> warnedAcademicYears = new HashSet<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
if (semesterOpt.isEmpty()) {
continue;
}
Semester semester = semesterOpt.get();
if (!academicDateService.hasCalendarAssignment(group, semester)
if (!dateSnapshot.hasCalendarAssignment(group, semester)
&& warnedAcademicYears.add(semester.getAcademicYear().getId())) {
logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
group.getName(), semester.getAcademicYear().getTitle());
}
if (!academicDateService.isTheoryDay(group, semester, date)) {
if (!dateSnapshot.isTheoryDay(group, semester, date)) {
continue;
}
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
semester.getId(),
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
List<ScheduleRule> rules = rulesByGroupAndSemester.getOrDefault(
new GroupSemesterKey(group.getId(), semester.getId()),
List.of()
);
if (primedSemesterIds.add(semester.getId())) {
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
primeConsumedHoursBeforeRange(
semester,
rules,
startDate,
group,
null,
consumedHoursProgress,
dateSnapshot,
timeSlotSnapshot
);
}
for (ScheduleRule rule : sortRules(rules)) {
processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
processRuleForDate(
rule,
semester,
date,
group,
null,
consumedHoursProgress,
result,
dateSnapshot,
timeSlotSnapshot
);
}
}
@@ -99,28 +204,68 @@ public class ScheduleGeneratorService {
throw new IllegalArgumentException("Преподаватель не найден");
}
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
if (semesters.isEmpty()) {
return List.of();
}
Set<Long> semesterIds = semesters.stream()
.map(Semester::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ScheduleRule> rules = scheduleRuleRepository.findByTeacherIdAndSemesterIds(teacherId, semesterIds);
List<StudentGroup> groups = rules.stream()
.flatMap(rule -> rule.getGroups().stream())
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
StudentGroup::getId,
group -> group,
(first, second) -> first,
LinkedHashMap::new
))
.values()
.stream()
.toList();
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
.createScheduleSnapshot(groups, semesters, endDate);
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
Map<Long, List<ScheduleRule>> rulesBySemester = rules.stream()
.collect(Collectors.groupingBy(rule -> rule.getSemester().getId()));
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
if (semesterOpt.isEmpty()) {
continue;
}
Semester semester = semesterOpt.get();
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
semester.getId(),
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
);
List<ScheduleRule> semesterRules = rulesBySemester.getOrDefault(semester.getId(), List.of());
if (primedSemesterIds.add(semester.getId())) {
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
primeConsumedHoursBeforeRange(
semester,
semesterRules,
startDate,
null,
teacherId,
consumedHoursProgress,
dateSnapshot,
timeSlotSnapshot
);
}
for (ScheduleRule rule : sortRules(rules)) {
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
for (ScheduleRule rule : sortRules(semesterRules)) {
processRuleForDate(
rule,
semester,
date,
null,
teacherId,
consumedHoursProgress,
result,
dateSnapshot,
timeSlotSnapshot
);
}
}
@@ -161,12 +306,48 @@ public class ScheduleGeneratorService {
.toList();
}
private Map<GroupSemesterKey, List<ScheduleRule>> indexRulesByGroupAndSemester(
Collection<ScheduleRule> rules,
Set<Long> requestedGroupIds
) {
Map<GroupSemesterKey, List<ScheduleRule>> result = new HashMap<>();
for (ScheduleRule rule : rules) {
Long semesterId = rule.getSemester().getId();
for (StudentGroup group : rule.getGroups()) {
if (!requestedGroupIds.contains(group.getId())) {
continue;
}
result.computeIfAbsent(
new GroupSemesterKey(group.getId(), semesterId),
ignored -> new ArrayList<>()
).add(rule);
}
}
result.replaceAll((key, value) -> sortRules(value));
return result;
}
private TimeSlotSnapshot loadTimeSlotSnapshot(LocalDate startDate,
LocalDate endDate,
Collection<ScheduleRule> rules) {
if (rules.isEmpty()) {
return TimeSlotSnapshot.empty();
}
return new TimeSlotSnapshot(
dateAssignmentRepository.findForScheduleRange(startDate, endDate),
timeSlotScopeRepository.findByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_WEEKDAY),
timeSlotRepository.findAllForSchedule()
);
}
private void primeConsumedHoursBeforeRange(Semester semester,
List<ScheduleRule> rules,
LocalDate rangeStart,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress) {
Map<String, Integer> consumedHoursProgress,
AcademicDateService.ScheduleSnapshot dateSnapshot,
TimeSlotSnapshot timeSlotSnapshot) {
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
if (!semester.getStartDate().isBefore(endExclusive)) {
return;
@@ -175,7 +356,17 @@ public class ScheduleGeneratorService {
List<ScheduleRule> sortedRules = sortRules(rules);
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
for (ScheduleRule rule : sortedRules) {
processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
processRuleForDate(
rule,
semester,
date,
targetGroup,
targetTeacherId,
consumedHoursProgress,
null,
dateSnapshot,
timeSlotSnapshot
);
}
}
}
@@ -185,19 +376,21 @@ public class ScheduleGeneratorService {
}
private void processRuleForDate(ScheduleRule rule,
LocalDate date,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress,
List<RenderedLessonDto> result) {
Semester semester,
LocalDate date,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress,
List<RenderedLessonDto> result,
AcademicDateService.ScheduleSnapshot dateSnapshot,
TimeSlotSnapshot timeSlotSnapshot) {
if (rule.getLectureAcademicHours() == null
|| rule.getLaboratoryAcademicHours() == null
|| rule.getPracticeAcademicHours() == null) {
return;
}
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
if (semester == null || !Objects.equals(semester.getId(), rule.getSemester().getId())) {
return;
}
if (!rule.getSubject().isActiveOn(date)) {
@@ -207,8 +400,7 @@ public class ScheduleGeneratorService {
return;
}
Semester semester = semesterOpt.get();
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date, dateSnapshot);
if (eligibleGroups.isEmpty()) {
return;
}
@@ -233,7 +425,7 @@ public class ScheduleGeneratorService {
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
continue;
}
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
if (date.isAfter(businessTime.today()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
continue;
}
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
@@ -283,7 +475,7 @@ public class ScheduleGeneratorService {
.orElse(0);
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
totalHours, consumedHours, remainingAfterLesson));
totalHours, consumedHours, remainingAfterLesson, timeSlotSnapshot));
}
for (ActiveLessonScope scope : activeScopes) {
consumedHoursProgress.put(
@@ -294,16 +486,20 @@ public class ScheduleGeneratorService {
}
}
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
private List<StudentGroup> eligibleGroups(ScheduleRule rule,
StudentGroup targetGroup,
Semester semester,
LocalDate date,
AcademicDateService.ScheduleSnapshot dateSnapshot) {
if (targetGroup != null) {
return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
return targetGroup.isActiveOn(date) && dateSnapshot.isTheoryDay(targetGroup, semester, date)
? List.of(targetGroup)
: List.of();
}
return rule.getGroups().stream()
.filter(group -> group.isActiveOn(date))
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
.filter(group -> dateSnapshot.isTheoryDay(group, semester, date))
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
}
@@ -356,6 +552,12 @@ public class ScheduleGeneratorService {
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
}
private record GroupSemesterKey(Long groupId, Long semesterId) {
}
private record ScopeOrderKey(Long scopeId, Integer orderNumber) {
}
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
ScheduleRuleSlot slot,
LocalDate date,
@@ -365,8 +567,9 @@ public class ScheduleGeneratorService {
List<Subgroup> subgroups,
int lessonTypeAcademicHours,
int consumedBeforeLesson,
int remainingAfterLesson) {
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
int remainingAfterLesson,
TimeSlotSnapshot timeSlotSnapshot) {
TimeSlot timeSlot = timeSlotSnapshot.resolve(slot.getTimeSlot(), date);
List<StudentGroup> sortedGroups = groups.stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
@@ -415,23 +618,55 @@ public class ScheduleGeneratorService {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
return baseSlot;
}
return effectiveScope(date)
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
scope.getId(),
baseSlot.getOrderNumber()))
.orElse(baseSlot);
}
private static final class TimeSlotSnapshot {
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()));
private final Map<LocalDate, Long> dateScopeIds;
private final Map<Integer, Long> weekdayScopeIds;
private final Map<ScopeOrderKey, TimeSlot> slotsByScopeAndOrder;
private TimeSlotSnapshot(Collection<TimeSlotDateAssignment> dateAssignments,
Collection<TimeSlotScope> weekdayScopes,
Collection<TimeSlot> timeSlots) {
this.dateScopeIds = dateAssignments.stream()
.collect(Collectors.toUnmodifiableMap(
TimeSlotDateAssignment::getDate,
assignment -> assignment.getTimeSlotScope().getId()
));
this.weekdayScopeIds = weekdayScopes.stream()
.filter(scope -> scope.getDayOfWeek() != null)
.collect(Collectors.toUnmodifiableMap(
TimeSlotScope::getDayOfWeek,
TimeSlotScope::getId,
(first, second) -> first
));
this.slotsByScopeAndOrder = timeSlots.stream()
.collect(Collectors.toUnmodifiableMap(
slot -> new ScopeOrderKey(
slot.getTimeSlotScope().getId(),
slot.getOrderNumber()
),
slot -> slot
));
}
private static TimeSlotSnapshot empty() {
return new TimeSlotSnapshot(List.of(), List.of(), List.of());
}
private TimeSlot resolve(TimeSlot baseSlot, LocalDate date) {
if (baseSlot == null || baseSlot.getOrderNumber() == null || date == null) {
return baseSlot;
}
Long scopeId = Optional.ofNullable(dateScopeIds.get(date))
.orElseGet(() -> weekdayScopeIds.get(date.getDayOfWeek().getValue()));
if (scopeId == null) {
return baseSlot;
}
return slotsByScopeAndOrder.getOrDefault(
new ScopeOrderKey(scopeId, baseSlot.getOrderNumber()),
baseSlot
);
}
}
public static String dayName(DayOfWeek dayOfWeek) {

View File

@@ -41,10 +41,10 @@ public class ScheduleQueryService {
if (date == null) {
throw new IllegalArgumentException("Дата расписания обязательна");
}
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
List<StudentGroup> groups = groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
.toList();
List<RenderedLessonDto> generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date);
return deduplicate(generatedLessons);
}
@@ -85,16 +85,70 @@ public class ScheduleQueryService {
String parity,
LocalDate startDate,
LocalDate endDate) {
return searchInternal(
groupId,
teacherId,
classroomId,
departmentId,
subjectId,
lessonTypeId,
timeSlotId,
parity,
startDate,
endDate,
true
);
}
/**
* Строит расписание для workload-агрегатов без интерактивного ограничения 50 групп.
* Набор поддерживаемых фильтров намеренно сужен до кафедры и временного слота, чтобы
* этот путь не превратился во второй общий поисковый API.
*/
public List<RenderedLessonDto> searchForAggregation(Long departmentId,
Long timeSlotId,
LocalDate startDate,
LocalDate endDate) {
return searchInternal(
null,
null,
null,
departmentId,
null,
null,
timeSlotId,
null,
startDate,
endDate,
false
);
}
private List<RenderedLessonDto> searchInternal(Long groupId,
Long teacherId,
Long classroomId,
Long departmentId,
Long subjectId,
Long lessonTypeId,
Long timeSlotId,
String parity,
LocalDate startDate,
LocalDate endDate,
boolean enforceInteractiveGroupLimit) {
validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons;
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
if (teacherOnlySearch) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else {
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
List<StudentGroup> groups = resolveGroups(
groupId,
departmentId,
startDate,
endDate,
enforceInteractiveGroupLimit
);
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
}
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate);
@@ -249,7 +303,11 @@ public class ScheduleQueryService {
}
}
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
private List<StudentGroup> resolveGroups(Long groupId,
Long departmentId,
LocalDate startDate,
LocalDate endDate,
boolean enforceInteractiveGroupLimit) {
if (groupId != null) {
return groupRepository.findById(groupId)
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
@@ -265,7 +323,9 @@ public class ScheduleQueryService {
groups = groups.stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList();
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
if (enforceInteractiveGroupLimit
&& departmentId == null
&& groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
}

View File

@@ -0,0 +1,105 @@
package com.magistr.app.service;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@Service
public class SubjectImportService {
private static final int MAX_NAME_LENGTH = 200;
private static final int MAX_CODE_LENGTH = 20;
private final SubjectRepository subjectRepository;
public SubjectImportService(SubjectRepository subjectRepository) {
this.subjectRepository = subjectRepository;
}
@Transactional
public List<Subject> importSubjects(List<CreateSubjectRequest> requests, Long departmentId) {
if (requests == null || requests.isEmpty()) {
throw new IllegalArgumentException("Передайте список дисциплин для загрузки");
}
if (departmentId == null || departmentId <= 0) {
throw new IllegalArgumentException("Кафедра обязательна");
}
List<SubjectCandidate> candidates = normalizeCandidates(requests);
Map<String, Subject> existingByCanonicalName = new LinkedHashMap<>();
for (SubjectCandidate candidate : candidates) {
subjectRepository.findByNameIgnoreCase(candidate.name()).ifPresent(existing -> {
if (!Objects.equals(existing.getDepartmentId(), departmentId)) {
throw new ScheduleConflictException(
"Дисциплина «" + candidate.name() + "» уже принадлежит другой кафедре"
);
}
existingByCanonicalName.put(candidate.canonicalName(), existing);
});
}
List<Subject> subjects = new ArrayList<>(candidates.size());
for (SubjectCandidate candidate : candidates) {
Subject subject = existingByCanonicalName.getOrDefault(
candidate.canonicalName(),
new Subject()
);
subject.setName(candidate.name());
subject.setCode(candidate.code());
subject.setDepartmentId(departmentId);
if (subject.isArchivedRecord()) {
subject.restore();
}
subjects.add(subject);
}
try {
return subjectRepository.saveAllAndFlush(subjects);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Дисциплина с таким названием уже существует"
);
}
}
private List<SubjectCandidate> normalizeCandidates(List<CreateSubjectRequest> requests) {
Map<String, SubjectCandidate> unique = new LinkedHashMap<>();
for (CreateSubjectRequest request : requests) {
if (request == null || request.getName() == null || request.getName().isBlank()) {
continue;
}
String name = request.getName().trim();
String code = trimToNull(request.getCode());
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("Название дисциплины не может быть длиннее 200 символов");
}
if (code != null && code.length() > MAX_CODE_LENGTH) {
throw new IllegalArgumentException("Код дисциплины не может быть длиннее 20 символов");
}
String canonicalName = name.toLowerCase(Locale.ROOT);
unique.put(canonicalName, new SubjectCandidate(canonicalName, name, code));
}
return List.copyOf(unique.values());
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private record SubjectCandidate(String canonicalName, String name, String code) {
}
}

View File

@@ -0,0 +1,234 @@
package com.magistr.app.service;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.model.Department;
import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class TeacherDepartmentService {
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final BusinessTimeService businessTime;
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository) {
this(assignmentRepository, userRepository, departmentRepository,
BusinessTimeService.systemDefault());
}
@Autowired
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
BusinessTimeService businessTime) {
this.assignmentRepository = assignmentRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public List<User> findTeachersForDepartmentAtDate(Long departmentId, LocalDate date) {
if (departmentId == null || date == null) {
return List.of();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
assignmentRepository.findDepartmentTeachersAtDate(departmentId, date).stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(teacher -> teacher.getRole() == Role.TEACHER)
.filter(teacher -> teacher.isActiveOn(date))
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return List.copyOf(teachersById.values());
}
@Transactional(readOnly = true)
public boolean hasAssignmentAtDate(Long teacherId, Long departmentId, LocalDate date) {
return teacherId != null
&& departmentId != null
&& date != null
&& assignmentRepository.existsActiveAssignment(teacherId, departmentId, date);
}
@Transactional(readOnly = true)
public Optional<Long> findPrimaryDepartmentIdAtDate(Long teacherId, LocalDate date) {
if (teacherId == null || date == null) {
return Optional.empty();
}
return assignmentRepository.findPrimaryAtDate(teacherId, date)
.map(TeacherDepartmentAssignment::getDepartment)
.filter(Objects::nonNull)
.map(Department::getId);
}
@Transactional(readOnly = true)
public Map<Long, List<TeacherDepartmentAssignment>> findAssignmentsForTeachersBetween(
Collection<Long> teacherIds,
LocalDate startDate,
LocalDate endDate
) {
if (teacherIds == null || teacherIds.isEmpty() || startDate == null || endDate == null) {
return Map.of();
}
Set<Long> normalizedIds = teacherIds.stream()
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (normalizedIds.isEmpty()) {
return Map.of();
}
return assignmentRepository.findForTeachersBetween(normalizedIds, startDate, endDate).stream()
.collect(Collectors.groupingBy(
assignment -> assignment.getTeacher().getId(),
LinkedHashMap::new,
Collectors.toList()
));
}
public Optional<Long> resolveDepartmentIdAtDate(
Collection<TeacherDepartmentAssignment> assignments,
LocalDate date
) {
if (assignments == null || date == null) {
return Optional.empty();
}
return assignments.stream()
.filter(Objects::nonNull)
.filter(assignment -> isActiveAt(assignment, date))
.filter(assignment -> assignment.getDepartment() != null)
.sorted(Comparator
.comparing(
(TeacherDepartmentAssignment assignment) ->
!Boolean.TRUE.equals(assignment.getPrimaryAssignment())
)
.thenComparing(
TeacherDepartmentAssignment::getValidFrom,
Comparator.nullsLast(Comparator.reverseOrder())
)
.thenComparing(
TeacherDepartmentAssignment::getId,
Comparator.nullsLast(Comparator.reverseOrder())
))
.map(assignment -> assignment.getDepartment().getId())
.findFirst();
}
@Transactional
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
DepartmentTransferRequest request,
LocalDate today,
Long createdBy) {
if (request == null || request.departmentId() == null) {
throw new IllegalArgumentException("Кафедра обязательна");
}
LocalDate operationDate = today == null ? businessTime.today() : today;
LocalDate validFrom = request.validFrom() == null ? operationDate : request.validFrom();
User teacher = userRepository.findByIdForUpdate(teacherId)
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.getRole() != Role.TEACHER) {
throw new IllegalArgumentException("Преподаватель не найден");
}
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя перевести");
}
Department department = departmentRepository.findById(request.departmentId())
.filter(candidate -> !candidate.isArchivedRecord())
.orElseThrow(() -> new IllegalArgumentException("Активная кафедра не найдена"));
List<TeacherDepartmentAssignment> primaryHistory = new ArrayList<>(
assignmentRepository.findPrimaryHistoryForUpdate(teacherId)
);
TeacherDepartmentAssignment sameDate = primaryHistory.stream()
.filter(assignment -> validFrom.equals(assignment.getValidFrom()))
.findFirst()
.orElse(null);
if (sameDate != null) {
sameDate.setDepartment(department);
sameDate.setComment(normalizeComment(request.comment()));
sameDate.setCreatedBy(createdBy);
TeacherDepartmentAssignment saved = saveAssignment(sameDate);
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
return saved;
}
primaryHistory.stream()
.filter(assignment -> isActiveAt(assignment, validFrom))
.findFirst()
.ifPresent(current -> current.setValidTo(validFrom.minusDays(1)));
LocalDate nextValidFrom = primaryHistory.stream()
.map(TeacherDepartmentAssignment::getValidFrom)
.filter(date -> date != null && date.isAfter(validFrom))
.min(LocalDate::compareTo)
.orElse(null);
assignmentRepository.flush();
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setValidTo(nextValidFrom == null ? null : nextValidFrom.minusDays(1));
assignment.setPrimaryAssignment(true);
assignment.setComment(normalizeComment(request.comment()));
assignment.setCreatedBy(createdBy);
TeacherDepartmentAssignment saved = saveAssignment(assignment);
primaryHistory.add(saved);
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
return saved;
}
private void synchronizeLegacyDepartment(User teacher,
List<TeacherDepartmentAssignment> primaryHistory,
LocalDate date) {
resolveDepartmentIdAtDate(primaryHistory, date).ifPresent(departmentId -> {
if (!Objects.equals(teacher.getDepartmentId(), departmentId)) {
teacher.setDepartmentId(departmentId);
userRepository.save(teacher);
}
});
}
private TeacherDepartmentAssignment saveAssignment(TeacherDepartmentAssignment assignment) {
try {
return assignmentRepository.saveAndFlush(assignment);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Период основной кафедры пересекается с другим назначением преподавателя"
);
}
}
private boolean isActiveAt(TeacherDepartmentAssignment assignment, LocalDate date) {
return assignment.getValidFrom() != null
&& !assignment.getValidFrom().isAfter(date)
&& (assignment.getValidTo() == null || !assignment.getValidTo().isBefore(date));
}
private String normalizeComment(String comment) {
return comment == null || comment.isBlank() ? null : comment.trim();
}
}

View File

@@ -0,0 +1,161 @@
package com.magistr.app.service;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.stream.Stream;
@Service
public class TimeSlotService {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final String SLOT_CONFLICT_MESSAGE =
"Номер пары или интервал времени уже занят в выбранной сетке";
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
public TimeSlotService(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
}
@Transactional
public TimeSlot create(TimeSlotDto request) {
validate(request);
TimeSlotScope targetScope = lockScope(request.scopeId());
ensureUniqueAndNonOverlapping(request, null);
TimeSlot timeSlot = new TimeSlot();
apply(timeSlot, targetScope, request);
return save(timeSlot);
}
@Transactional
public TimeSlot update(Long id, TimeSlotDto request) {
validate(request);
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
Long currentScopeId = timeSlot.getTimeSlotScope().getId();
List<Long> scopeIds = Stream.of(currentScopeId, request.scopeId())
.filter(Objects::nonNull)
.distinct()
.sorted()
.toList();
TimeSlotScope targetScope = null;
for (Long scopeId : scopeIds) {
TimeSlotScope lockedScope = lockScope(scopeId);
if (scopeId.equals(request.scopeId())) {
targetScope = lockedScope;
}
}
if (targetScope == null) {
throw new NoSuchElementException("Сетка времени не найдена");
}
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)
&& !APPLY_MODE_DEFAULT.equals(targetScope.getApplyMode())) {
throw new IllegalArgumentException(
"Слот базовой сетки, используемый в правилах расписания, нельзя перенести в другую сетку"
);
}
ensureUniqueAndNonOverlapping(request, id);
apply(timeSlot, targetScope, request);
return save(timeSlot);
}
@Transactional
public void delete(Long id) {
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)) {
throw new IllegalArgumentException(
"Нельзя удалить слот, который используется в правилах расписания"
);
}
try {
timeSlotRepository.delete(timeSlot);
timeSlotRepository.flush();
} catch (DataIntegrityViolationException exception) {
throw new IllegalArgumentException(
"Нельзя удалить слот, который используется в расписании",
exception
);
}
}
private void validate(TimeSlotDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные временного слота обязательны");
}
if (request.orderNumber() == null || request.orderNumber() <= 0) {
throw new IllegalArgumentException("Номер пары должен быть больше нуля");
}
if (request.scopeId() == null) {
throw new IllegalArgumentException("Выберите сетку времени");
}
if (request.startTime() == null || request.endTime() == null) {
throw new IllegalArgumentException("Время начала и окончания обязательно");
}
if (!request.startTime().isBefore(request.endTime())) {
throw new IllegalArgumentException("Время начала должно быть раньше времени окончания");
}
if (Duration.between(request.startTime(), request.endTime()).toMinutes() < 1) {
throw new IllegalArgumentException("Длительность временного слота должна быть не меньше одной минуты");
}
}
private TimeSlotScope lockScope(Long scopeId) {
return timeSlotScopeRepository.findByIdForUpdate(scopeId)
.orElseThrow(() -> new NoSuchElementException("Сетка времени не найдена"));
}
private void ensureUniqueAndNonOverlapping(TimeSlotDto request, Long currentId) {
boolean duplicateOrder = currentId == null
? timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumber(
request.scopeId(), request.orderNumber())
: timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumberAndIdNot(
request.scopeId(), request.orderNumber(), currentId);
boolean overlapping = currentId == null
? timeSlotRepository.existsOverlapping(
request.scopeId(), request.startTime(), request.endTime())
: timeSlotRepository.existsOverlappingExcluding(
request.scopeId(), currentId, request.startTime(), request.endTime());
if (duplicateOrder || overlapping) {
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
}
}
private void apply(TimeSlot timeSlot, TimeSlotScope scope, TimeSlotDto request) {
timeSlot.setOrderNumber(request.orderNumber());
timeSlot.setTimeSlotScope(scope);
timeSlot.setStartTime(request.startTime());
timeSlot.setEndTime(request.endTime());
timeSlot.setDurationMinutes((int) Duration.between(request.startTime(), request.endTime()).toMinutes());
}
private TimeSlot save(TimeSlot timeSlot) {
try {
return timeSlotRepository.saveAndFlush(timeSlot);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
}
}
}

View File

@@ -4,12 +4,13 @@ import com.magistr.app.model.SemesterType;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.ZoneId;
@Service
public class CourseAndSemesterCalculator {
public static int getActualCourse(Integer yearStartStudy) {
return getActualCourse(yearStartStudy, LocalDate.now());
return getActualCourse(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
}
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
@@ -22,7 +23,7 @@ public class CourseAndSemesterCalculator {
}
public static int getActualSemester(Integer yearStartStudy) {
return getActualSemester(yearStartStudy, LocalDate.now());
return getActualSemester(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
}
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {

View File

@@ -10,6 +10,11 @@ spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
spring.jackson.time-zone=UTC
# Единая зона календарных бизнес-дат. Абсолютные timestamps хранятся в UTC.
app.time.business-zone=${BUSINESS_TIME_ZONE:Europe/Moscow}
# Мультитенантность
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
@@ -33,5 +38,24 @@ app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m}
app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d}
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}
app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false}
app.jwt.refresh-cleanup.enabled=${JWT_REFRESH_CLEANUP_ENABLED:true}
app.jwt.refresh-cleanup.retention=${JWT_REFRESH_CLEANUP_RETENTION:30d}
app.jwt.refresh-cleanup.batch-size=${JWT_REFRESH_CLEANUP_BATCH_SIZE:500}
app.jwt.refresh-cleanup.max-batches-per-tenant=${JWT_REFRESH_CLEANUP_MAX_BATCHES:20}
app.jwt.refresh-cleanup.interval-ms=${JWT_REFRESH_CLEANUP_INTERVAL_MS:3600000}
app.jwt.refresh-cleanup.initial-delay-ms=${JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS:60000}
# Защита входа. X-Forwarded-For принимается только от перечисленных proxy-сетей.
app.login-security.max-failures=${LOGIN_RATE_MAX_FAILURES:5}
app.login-security.attempt-window=${LOGIN_RATE_ATTEMPT_WINDOW:15m}
app.login-security.base-block-duration=${LOGIN_RATE_BASE_BLOCK_DURATION:1m}
app.login-security.max-block-duration=${LOGIN_RATE_MAX_BLOCK_DURATION:15m}
app.login-security.audit-cleanup.enabled=${LOGIN_AUDIT_CLEANUP_ENABLED:true}
app.login-security.audit-cleanup.retention=${LOGIN_AUDIT_RETENTION:90d}
app.login-security.audit-cleanup.batch-size=${LOGIN_AUDIT_CLEANUP_BATCH_SIZE:500}
app.login-security.audit-cleanup.max-batches-per-tenant=${LOGIN_AUDIT_CLEANUP_MAX_BATCHES:20}
app.login-security.audit-cleanup.interval-ms=${LOGIN_AUDIT_CLEANUP_INTERVAL_MS:86400000}
app.login-security.audit-cleanup.initial-delay-ms=${LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS:120000}
app.http.trusted-proxy-cidrs=${TRUSTED_PROXY_CIDRS:}
#logging.level.root=DEBUG

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
ALTER TABLE subgroups
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
ON subgroups (group_id, lower(name))
WHERE status <> 'ARCHIVED';

View File

@@ -1,94 +0,0 @@
DO $$
DECLARE
cancel_violations BIGINT;
move_violations BIGINT;
replace_violations BIGINT;
format_violations BIGINT;
BEGIN
SELECT count(*)
INTO cancel_violations
FROM schedule_overrides
WHERE action = 'CANCEL'
AND (new_time_slot_id IS NOT NULL
OR new_classroom_id IS NOT NULL
OR new_teacher_id IS NOT NULL
OR new_lesson_format IS NOT NULL);
SELECT count(*)
INTO move_violations
FROM schedule_overrides
WHERE action = 'MOVE'
AND new_time_slot_id IS NULL
AND new_classroom_id IS NULL;
SELECT count(*)
INTO replace_violations
FROM schedule_overrides
WHERE action = 'REPLACE'
AND new_teacher_id IS NULL
AND new_classroom_id IS NULL
AND new_lesson_format IS NULL;
SELECT count(*)
INTO format_violations
FROM schedule_overrides
WHERE new_lesson_format IS NOT NULL
AND new_lesson_format NOT IN ('Очно', 'Онлайн');
IF cancel_violations > 0
OR move_violations > 0
OR replace_violations > 0
OR format_violations > 0 THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно применить инварианты точечных изменений: CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте данные tenant-БД и повторите миграцию.',
cancel_violations,
move_violations,
replace_violations,
format_violations
);
END IF;
END $$;
ALTER TABLE schedule_overrides
ADD CONSTRAINT chk_schedule_overrides_cancel_payload
CHECK (
action <> 'CANCEL'
OR (
new_time_slot_id IS NULL
AND new_classroom_id IS NULL
AND new_teacher_id IS NULL
AND new_lesson_format IS NULL
)
) NOT VALID,
ADD CONSTRAINT chk_schedule_overrides_move_payload
CHECK (
action <> 'MOVE'
OR new_time_slot_id IS NOT NULL
OR new_classroom_id IS NOT NULL
) NOT VALID,
ADD CONSTRAINT chk_schedule_overrides_replace_payload
CHECK (
action <> 'REPLACE'
OR new_teacher_id IS NOT NULL
OR new_classroom_id IS NOT NULL
OR new_lesson_format IS NOT NULL
) NOT VALID,
ADD CONSTRAINT chk_schedule_overrides_format
CHECK (
new_lesson_format IS NULL
OR new_lesson_format IN ('Очно', 'Онлайн')
) NOT VALID;
ALTER TABLE schedule_overrides
VALIDATE CONSTRAINT chk_schedule_overrides_cancel_payload;
ALTER TABLE schedule_overrides
VALIDATE CONSTRAINT chk_schedule_overrides_move_payload;
ALTER TABLE schedule_overrides
VALIDATE CONSTRAINT chk_schedule_overrides_replace_payload;
ALTER TABLE schedule_overrides
VALIDATE CONSTRAINT chk_schedule_overrides_format;

View File

@@ -1,97 +0,0 @@
DO $$
DECLARE
lecture_violations BIGINT;
laboratory_violations BIGINT;
practice_violations BIGINT;
BEGIN
SELECT count(*)
INTO lecture_violations
FROM schedule_rules
WHERE mod(lecture_academic_hours, 2) <> 0;
SELECT count(*)
INTO laboratory_violations
FROM schedule_rules
WHERE mod(laboratory_academic_hours, 2) <> 0;
SELECT count(*)
INTO practice_violations
FROM schedule_rules
WHERE mod(practice_academic_hours, 2) <> 0;
IF lecture_violations > 0
OR laboratory_violations > 0
OR practice_violations > 0 THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно применить инвариант чётности академических часов правил расписания: лекции=%s, лабораторные=%s, практики=%s. Исправьте данные tenant-БД и повторите миграцию.',
lecture_violations,
laboratory_violations,
practice_violations
);
END IF;
END $$;
DO $$
DECLARE
duplicate_slot_groups BIGINT;
BEGIN
SELECT count(*)
INTO duplicate_slot_groups
FROM (
SELECT
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
FROM schedule_rule_slots
GROUP BY
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
HAVING count(*) > 1
) duplicates;
IF duplicate_slot_groups > 0 THEN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = format(
'Невозможно применить инвариант уникальности слотов правил расписания: найдено групп точных дублей=%s. Исправьте данные tenant-БД и повторите миграцию.',
duplicate_slot_groups
);
END IF;
END $$;
ALTER TABLE schedule_rules
ADD CONSTRAINT chk_schedule_rules_academic_hours_even
CHECK (
mod(lecture_academic_hours, 2) = 0
AND mod(laboratory_academic_hours, 2) = 0
AND mod(practice_academic_hours, 2) = 0
) NOT VALID;
ALTER TABLE schedule_rules
VALIDATE CONSTRAINT chk_schedule_rules_academic_hours_even;
ALTER TABLE schedule_rule_slots
ADD CONSTRAINT uq_schedule_rule_slots_exact_payload
UNIQUE (
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
);

View File

@@ -0,0 +1,51 @@
package com.magistr.app.config.auth;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ClientIpResolverTest {
@Test
void ignoresForwardedHeaderFromUntrustedClient() {
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16");
MockHttpServletRequest request = request("203.0.113.7", "192.0.2.10");
assertThat(resolver.resolve(request)).isEqualTo("203.0.113.7");
}
@Test
void selectsFirstUntrustedAddressFromRightBehindTrustedProxyChain() {
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16, 172.16.0.0/12");
MockHttpServletRequest request = request(
"10.42.1.15",
"192.0.2.99, 198.51.100.23, 172.18.0.4"
);
assertThat(resolver.resolve(request)).isEqualTo("198.51.100.23");
}
@Test
void rejectsEntireForwardedHeaderWhenItContainsInvalidAddress() {
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16");
MockHttpServletRequest request = request("10.42.1.15", "198.51.100.23, attacker.example");
assertThat(resolver.resolve(request)).isEqualTo("10.42.1.15");
}
@Test
void rejectsInvalidTrustedProxyConfigurationAtStartup() {
assertThatThrownBy(() -> new ClientIpResolver("10.42.0.0/99"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Некорректная маска");
}
private MockHttpServletRequest request(String remoteAddress, String forwardedFor) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr(remoteAddress);
request.addHeader("X-Forwarded-For", forwardedFor);
return request;
}
}

View File

@@ -0,0 +1,200 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(
classes = LoginRateLimitConcurrencyIntegrationTest.TestApplication.class,
properties = "management.endpoint.health.validate-group-membership=false"
)
class LoginRateLimitConcurrencyIntegrationTest {
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
@Autowired
private AuthLoginRateLimitRepository rateLimitRepository;
@Autowired
private AuthLoginAttemptAuditRepository auditRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
private ExecutorService executor;
@BeforeEach
void clearLoginSecurityTables() {
auditRepository.deleteAll();
rateLimitRepository.deleteAll();
}
@AfterEach
void stopExecutor() throws InterruptedException {
if (executor != null) {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
@Test
void twoServiceInstancesShareAtomicPostgreSqlCounter() throws Exception {
LoginSecurityProperties properties = new LoginSecurityProperties();
properties.setMaxFailures(2);
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4);
LoginRateLimitService firstPod = service(properties, encoder);
LoginRateLimitService secondPod = service(properties, encoder);
String username = "missing-" + UUID.randomUUID();
String clientIp = "198.51.100.42";
executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<LoginRateLimitService.AuthenticationDecision>> futures = List.of(
executor.submit(() -> authenticateAfterSignal(firstPod, username, clientIp, ready, start)),
executor.submit(() -> authenticateAfterSignal(secondPod, username, clientIp, ready, start))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<LoginRateLimitService.AuthenticationDecision.Status> statuses = List.of(
futures.get(0).get(10, TimeUnit.SECONDS).status(),
futures.get(1).get(10, TimeUnit.SECONDS).status()
);
assertThat(statuses).containsExactlyInAnyOrder(
LoginRateLimitService.AuthenticationDecision.Status.REJECTED,
LoginRateLimitService.AuthenticationDecision.Status.BLOCKED
);
Integer failureCount = jdbcTemplate.queryForObject("""
SELECT failure_count
FROM auth_login_rate_limits
WHERE tenant = ? AND username_normalized = ? AND client_ip = ?
""", Integer.class, "tenant", username, clientIp);
assertThat(failureCount).isEqualTo(2);
assertThat(auditRepository.count()).isEqualTo(2);
}
@Test
void cleanupDeletesOnlyExpiredAuditAndInactiveCounters() {
Instant now = NOW;
Instant cutoff = now.minus(90, java.time.temporal.ChronoUnit.DAYS);
jdbcTemplate.update("""
INSERT INTO auth_login_attempt_audit
(tenant, username_normalized, client_ip, outcome, occurred_at)
VALUES
('tenant', 'old', '198.51.100.1', 'FAILURE', ?),
('tenant', 'fresh', '198.51.100.2', 'FAILURE', ?)
""", timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)));
jdbcTemplate.update("""
INSERT INTO auth_login_rate_limits
(tenant, username_normalized, client_ip, failure_count,
window_started_at, blocked_until, updated_at)
VALUES
('tenant', 'old', '198.51.100.1', 1, ?, NULL, ?),
('tenant', 'blocked', '198.51.100.2', 5, ?, ?, ?),
('tenant', 'fresh', '198.51.100.3', 1, ?, NULL, ?)
""",
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.plusSeconds(300)),
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)));
assertThat(auditRepository.deleteCleanupBatch(cutoff, 100)).isEqualTo(1);
assertThat(rateLimitRepository.deleteStaleBatch(cutoff, now, 100)).isEqualTo(1);
assertThat(auditRepository.count()).isEqualTo(1);
assertThat(rateLimitRepository.count()).isEqualTo(2);
}
private Timestamp timestamp(Instant value) {
return Timestamp.from(value);
}
private LoginRateLimitService.AuthenticationDecision authenticateAfterSignal(
LoginRateLimitService service,
String username,
String clientIp,
CountDownLatch ready,
CountDownLatch start
) throws InterruptedException {
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Не удалось синхронно запустить попытки входа");
}
return transactionTemplate.execute(status ->
service.authenticate("tenant", username, "неверный-пароль", clientIp)
);
}
private LoginRateLimitService service(LoginSecurityProperties properties,
BCryptPasswordEncoder encoder) {
return new LoginRateLimitService(
rateLimitRepository,
auditRepository,
userRepository,
encoder,
properties,
Clock.fixed(NOW, ZoneOffset.UTC)
);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = AuthLoginRateLimit.class)
@EnableJpaRepositories(basePackageClasses = AuthLoginRateLimitRepository.class)
static class TestApplication {
}
}

View File

@@ -0,0 +1,149 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LoginRateLimitServiceTest {
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
private AuthLoginRateLimitRepository rateLimitRepository;
private AuthLoginAttemptAuditRepository auditRepository;
private UserRepository userRepository;
private BCryptPasswordEncoder passwordEncoder;
private LoginSecurityProperties properties;
@BeforeEach
void setUp() {
rateLimitRepository = mock(AuthLoginRateLimitRepository.class);
auditRepository = mock(AuthLoginAttemptAuditRepository.class);
userRepository = mock(UserRepository.class);
passwordEncoder = new BCryptPasswordEncoder(4);
properties = new LoginSecurityProperties();
}
@Test
void fifthFailureBlocksKeyAndWritesAuditWithoutPassword() {
AuthLoginRateLimit state = state(4, NOW, null);
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
.thenReturn(Optional.of(state));
when(userRepository.findByUsername("Admin")).thenReturn(Optional.empty());
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "Admin", "секретный-пароль", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
assertThat(decision.retryAfterSeconds()).isEqualTo(60);
assertThat(state.getFailureCount()).isEqualTo(5);
assertThat(state.getBlockedUntil()).isEqualTo(NOW.plusSeconds(60));
ArgumentCaptor<AuthLoginAttemptAudit> auditCaptor =
ArgumentCaptor.forClass(AuthLoginAttemptAudit.class);
verify(auditRepository).save(auditCaptor.capture());
assertThat(auditCaptor.getValue().getOutcome()).isEqualTo("BLOCKED");
assertThat(auditCaptor.getValue().getRetryAfterSeconds()).isEqualTo(60);
assertThat(auditCaptor.getValue().getUsernameNormalized()).isEqualTo("admin");
}
@Test
void activeBlockDoesNotQueryUserAndReturnsRemainingDelay() {
AuthLoginRateLimit state = state(5, NOW, NOW.plusSeconds(37));
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
.thenReturn(Optional.of(state));
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "admin", "любой", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
assertThat(decision.retryAfterSeconds()).isEqualTo(37);
verify(userRepository, never()).findByUsername(any());
}
@Test
void archivedUserIsRejectedLikeUnknownUser() {
AuthLoginRateLimit state = state(0, NOW, null);
when(rateLimitRepository.findForUpdate("tenant", "archived", "198.51.100.5"))
.thenReturn(Optional.of(state));
User user = new User();
user.setPassword(passwordEncoder.encode("верный-пароль"));
user.archive("Тест");
when(userRepository.findByUsername("archived")).thenReturn(Optional.of(user));
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "archived", "верный-пароль", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.REJECTED);
assertThat(state.getFailureCount()).isEqualTo(1);
}
@Test
void successfulLoginRemovesPreviousFailureState() {
AuthLoginRateLimit state = state(2, NOW, null);
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
.thenReturn(Optional.of(state));
User user = new User();
user.setPassword(passwordEncoder.encode("верный-пароль"));
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "admin", "верный-пароль", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.AUTHENTICATED);
assertThat(decision.user()).isSameAs(user);
verify(rateLimitRepository).delete(state);
verify(auditRepository, never()).save(any());
}
private LoginRateLimitService service() {
return new LoginRateLimitService(
rateLimitRepository,
auditRepository,
userRepository,
passwordEncoder,
properties,
Clock.fixed(NOW, ZoneOffset.UTC)
);
}
private AuthLoginRateLimit state(int failureCount,
Instant windowStartedAt,
Instant blockedUntil) {
AuthLoginRateLimit state = new AuthLoginRateLimit();
state.setFailureCount(failureCount);
state.setWindowStartedAt(windowStartedAt);
state.setBlockedUntil(blockedUntil);
return state;
}
}

View File

@@ -0,0 +1,195 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.*;
@DisplayName("Tenant-aware очистка refresh-сессий")
class RefreshTokenCleanupJobTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Moscow");
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-18T09:00:00Z"), ZONE);
private static final Instant NOW = CLOCK.instant();
private static final Duration RETENTION = Duration.ofDays(30);
private ExecutorService executor;
@AfterEach
void cleanup() throws InterruptedException {
TenantContext.clear();
if (executor != null) {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
@Test
@DisplayName("два pod удаляют старые строки идемпотентно и сохраняют active/fresh audit")
void twoPodsKeepActiveAndFreshAuditRows() throws Exception {
TenantRoutingDataSource routingDataSource = routing("alpha", "beta");
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
TokenStore store = new TokenStore("alpha", "beta");
when(repository.deleteCleanupBatch(any(Instant.class), anyInt()))
.thenAnswer(invocation -> store.deleteBatch(
TenantContext.getCurrentTenant(),
invocation.getArgument(0),
invocation.getArgument(1)
));
RefreshTokenCleanupJob firstPod = job(routingDataSource, repository, true);
RefreshTokenCleanupJob secondPod = job(routingDataSource, repository, true);
executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
Future<RefreshTokenCleanupJob.CleanupSummary> first = executor.submit(() -> {
start.await(5, TimeUnit.SECONDS);
return firstPod.cleanupNow();
});
Future<RefreshTokenCleanupJob.CleanupSummary> second = executor.submit(() -> {
start.await(5, TimeUnit.SECONDS);
return secondPod.cleanupNow();
});
start.countDown();
var firstSummary = first.get(5, TimeUnit.SECONDS);
var secondSummary = second.get(5, TimeUnit.SECONDS);
assertThat(firstSummary.deletedCount() + secondSummary.deletedCount()).isEqualTo(4);
assertThat(firstSummary.failedTenantCount() + secondSummary.failedTenantCount()).isZero();
assertThat(store.labels("alpha")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
assertThat(store.labels("beta")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
var repeated = firstPod.cleanupNow();
assertThat(repeated.deletedCount()).isZero();
assertThat(store.labels("alpha")).hasSize(3);
assertThat(store.labels("beta")).hasSize(3);
}
@Test
@DisplayName("повторный локальный запуск пропускается, пока текущий проход не завершён")
void overlappingRunInsidePodIsSkipped() throws Exception {
TenantRoutingDataSource routingDataSource = routing("alpha");
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
CountDownLatch enteredRepository = new CountDownLatch(1);
CountDownLatch releaseRepository = new CountDownLatch(1);
when(repository.deleteCleanupBatch(any(Instant.class), anyInt())).thenAnswer(invocation -> {
enteredRepository.countDown();
if (!releaseRepository.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Тест не разрешил завершить очистку");
}
return 0;
});
RefreshTokenCleanupJob job = job(routingDataSource, repository, true);
executor = Executors.newSingleThreadExecutor();
Future<RefreshTokenCleanupJob.CleanupSummary> running = executor.submit(job::cleanupNow);
assertThat(enteredRepository.await(5, TimeUnit.SECONDS)).isTrue();
var overlapping = job.cleanupNow();
assertThat(overlapping.skippedBecauseAlreadyRunning()).isTrue();
releaseRepository.countDown();
assertThat(running.get(5, TimeUnit.SECONDS).skippedBecauseAlreadyRunning()).isFalse();
}
@Test
@DisplayName("отключённое расписание не обращается к tenant-БД")
void disabledScheduleDoesNothing() {
TenantRoutingDataSource routingDataSource = routing("alpha");
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
RefreshTokenCleanupJob job = job(routingDataSource, repository, false);
job.cleanupScheduled();
verifyNoInteractions(repository);
}
private RefreshTokenCleanupJob job(TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
boolean enabled) {
return new RefreshTokenCleanupJob(
routingDataSource,
repository,
enabled,
RETENTION,
2,
10,
CLOCK
);
}
private TenantRoutingDataSource routing(String... tenants) {
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
Map<String, TenantConfig> configs = new LinkedHashMap<>();
for (String tenant : tenants) {
configs.put(tenant, new TenantConfig(tenant, tenant, "jdbc:test:" + tenant, "user", "password"));
}
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.copyOf(configs));
return routingDataSource;
}
private static final class TokenStore {
private final Map<String, List<TokenRow>> rowsByTenant = new LinkedHashMap<>();
private TokenStore(String... tenants) {
for (String tenant : tenants) {
rowsByTenant.put(tenant, new ArrayList<>(List.of(
new TokenRow("old-expired", minusDays(NOW, 31), null),
new TokenRow("old-revoked", plusDays(NOW, 7), minusDays(NOW, 31)),
new TokenRow("fresh-expired", minusDays(NOW, 1), null),
new TokenRow("fresh-revoked", plusDays(NOW, 7), minusDays(NOW, 1)),
new TokenRow("active", plusDays(NOW, 7), null)
)));
}
}
private synchronized int deleteBatch(String tenant, Instant cutoff, int batchSize) {
assertThat(tenant).isNotBlank();
List<TokenRow> rows = rowsByTenant.get(tenant);
List<TokenRow> candidates = rows.stream()
.filter(row -> row.expiresAt().isBefore(cutoff)
|| row.revokedAt() != null && row.revokedAt().isBefore(cutoff))
.limit(batchSize)
.toList();
rows.removeAll(candidates);
return candidates.size();
}
private synchronized List<String> labels(String tenant) {
return rowsByTenant.get(tenant).stream().map(TokenRow::label).toList();
}
}
private static Instant minusDays(Instant value, long days) {
return value.minus(days, java.time.temporal.ChronoUnit.DAYS);
}
private static Instant plusDays(Instant value, long days) {
return value.plus(days, java.time.temporal.ChronoUnit.DAYS);
}
private record TokenRow(String label, Instant expiresAt, Instant revokedAt) {
}
}

View File

@@ -28,6 +28,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -131,6 +133,82 @@ class RefreshTokenServiceConcurrencyIntegrationTest {
assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1);
}
@Test
void concurrentCleanupKeepsActiveAndFreshAuditRows() throws Exception {
User user = createTestUser();
Instant now = Instant.now();
Instant cutoff = now.minus(30, ChronoUnit.DAYS);
AuthRefreshToken oldExpired = token(user, "old-expired", now.minus(31, ChronoUnit.DAYS), null);
AuthRefreshToken oldRevoked = token(user, "old-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(31, ChronoUnit.DAYS));
AuthRefreshToken freshExpired = token(user, "fresh-expired", now.minus(1, ChronoUnit.DAYS), null);
AuthRefreshToken freshRevoked = token(user, "fresh-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(1, ChronoUnit.DAYS));
AuthRefreshToken active = token(user, "active", now.plus(7, ChronoUnit.DAYS), null);
tokenRepository.saveAllAndFlush(List.of(
oldExpired,
oldRevoked,
freshExpired,
freshRevoked,
active
));
List<String> relevantHashes = List.of(
oldExpired.getTokenHash(),
oldRevoked.getTokenHash(),
freshExpired.getTokenHash(),
freshRevoked.getTokenHash(),
active.getTokenHash()
);
executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<Integer>> futures = List.of(
executor.submit(() -> cleanupAfterSignal(cutoff, ready, start)),
executor.submit(() -> cleanupAfterSignal(cutoff, ready, start))
);
awaitReady(ready);
start.countDown();
int deleted = futures.get(0).get(10, TimeUnit.SECONDS)
+ futures.get(1).get(10, TimeUnit.SECONDS);
assertThat(deleted).isEqualTo(2);
List<AuthRefreshToken> remaining = tokenRepository.findAll().stream()
.filter(token -> relevantHashes.contains(token.getTokenHash()))
.toList();
assertThat(remaining)
.extracting(AuthRefreshToken::getTokenHash)
.containsExactlyInAnyOrder(
freshExpired.getTokenHash(),
freshRevoked.getTokenHash(),
active.getTokenHash()
);
assertThat(tokenRepository.deleteCleanupBatch(cutoff, 100)).isZero();
}
private int cleanupAfterSignal(Instant cutoff,
CountDownLatch ready,
CountDownLatch start) throws InterruptedException {
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Не удалось синхронно запустить очистку refresh-сессий");
}
return tokenRepository.deleteCleanupBatch(cutoff, 100);
}
private AuthRefreshToken token(User user,
String marker,
Instant expiresAt,
Instant revokedAt) {
AuthRefreshToken token = new AuthRefreshToken();
token.setUser(user);
token.setTenant(TENANT);
token.setTokenHash(service.hashToken(marker + "-" + UUID.randomUUID()));
token.setIssuedAt(Instant.now().minus(40, ChronoUnit.DAYS));
token.setExpiresAt(expiresAt);
token.setRevokedAt(revokedAt);
return token;
}
private Optional<RefreshTokenRotation> rotateAfterSignal(
String rawToken,
CountDownLatch ready,

View File

@@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@@ -35,7 +36,7 @@ class RefreshTokenServiceTest {
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
assertThat(stored.getTokenHash()).hasSize(64);
assertThat(stored.getTenant()).isEqualTo("magistr");
assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now());
assertThat(stored.getExpiresAt()).isAfter(Instant.now());
}
@Test
@@ -96,8 +97,8 @@ class RefreshTokenServiceTest {
token.setUser(user());
token.setTenant("magistr");
token.setTokenHash(hash);
token.setIssuedAt(LocalDateTime.now().minusMinutes(1));
token.setExpiresAt(LocalDateTime.now().plusDays(1));
token.setIssuedAt(Instant.now().minus(1, ChronoUnit.MINUTES));
token.setExpiresAt(Instant.now().plus(1, ChronoUnit.DAYS));
return token;
}

View File

@@ -2,20 +2,30 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.config.auth.ClientIpResolver;
import com.magistr.app.config.auth.LoginRateLimitService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.model.Role;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AuthControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
TenantContext.clear();
}
@Test
@@ -35,4 +45,35 @@ class AuthControllerTest {
assertThat(body).containsKey("departmentId");
assertThat(body.get("departmentId")).isNull();
}
@Test
void loginReturnsTooManyRequestsAndRetryAfterWhenKeyIsBlocked() {
LoginRateLimitService rateLimitService = mock(LoginRateLimitService.class);
ClientIpResolver clientIpResolver = mock(ClientIpResolver.class);
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
LoginRequest loginRequest = new LoginRequest();
loginRequest.setUsername("admin");
loginRequest.setPassword("неверный-пароль");
TenantContext.setCurrentTenant("tenant");
when(clientIpResolver.resolve(servletRequest)).thenReturn("198.51.100.7");
when(rateLimitService.authenticate(
"tenant", "admin", "неверный-пароль", "198.51.100.7"
)).thenReturn(new LoginRateLimitService.AuthenticationDecision(
LoginRateLimitService.AuthenticationDecision.Status.BLOCKED,
null,
73
));
AuthController controller = new AuthController(
rateLimitService, clientIpResolver, null, null, null
);
ResponseEntity<LoginResponse> response = controller.login(loginRequest, servletRequest);
assertThat(response.getStatusCode().value()).isEqualTo(429);
assertThat(response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("73");
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().isSuccess()).isFalse();
assertThat(response.getBody().getMessage())
.isEqualTo("Слишком много попыток входа. Повторите позже");
}
}

View File

@@ -16,6 +16,8 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.SubjectImportService;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -51,7 +53,9 @@ class DepartmentWorkspaceControllerTest {
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
mock(ScheduleQueryService.class),
mock(TeacherDepartmentService.class),
mock(SubjectImportService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -61,14 +65,13 @@ class DepartmentWorkspaceControllerTest {
}
@Test
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
void teachersUseDatedAssignmentsWithoutLegacyFallback() {
UserRepository userRepository = mock(UserRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
when(teacherDepartmentService.findTeachersForDepartmentAtDate(1L, LocalDate.now()))
.thenReturn(List.of(directTeacher, assignedTeacher));
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
@@ -77,13 +80,15 @@ class DepartmentWorkspaceControllerTest {
mock(DepartmentRepository.class),
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
mock(ScheduleQueryService.class),
teacherDepartmentService,
mock(SubjectImportService.class)
);
List<UserResponse> teachers = controller.getTeachers(1L);
assertThat(teachers).extracting(UserResponse::getFullName)
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
.containsExactlyInAnyOrder("Петров Препод Петрович", "Препод Тест Тестович");
}
@Test
@@ -103,7 +108,9 @@ class DepartmentWorkspaceControllerTest {
departmentRepository,
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
mock(ScheduleQueryService.class),
mock(TeacherDepartmentService.class),
mock(SubjectImportService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));

View File

@@ -0,0 +1,119 @@
package com.magistr.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EducationOfficeRoleMatrixTest {
private static final String TENANT = "tenant-role-matrix";
private static final String TOKEN = "education-office-token";
private MockMvc mockMvc;
@BeforeEach
void setUp() {
TenantContext.clear();
TenantContext.setCurrentTenant(TENANT);
JwtTokenService jwtTokenService = mock(JwtTokenService.class);
when(jwtTokenService.authenticate(TOKEN, TENANT)).thenReturn(Optional.of(
new AuthenticatedUser(10L, "учебный.отдел", Role.EDUCATION_OFFICE, null)
));
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
when(specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of());
when(specialtiesRepository.existsById(1L)).thenReturn(true);
when(profileRepository.findBySpecialityIdOrderByNameAsc(1L)).thenReturn(List.of());
SpecialityController specialityController = new SpecialityController(
specialtiesRepository,
profileRepository
);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
GroupRepository groupRepository = mock(GroupRepository.class);
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
when(educationFormRepository.findAllByOrderByNameAsc()).thenReturn(List.of());
when(educationFormRepository.findByName("Очная")).thenReturn(Optional.empty());
when(educationFormRepository.save(any(EducationForm.class))).thenAnswer(invocation -> {
EducationForm form = invocation.getArgument(0);
form.setId(20L);
return form;
});
when(educationFormRepository.existsById(20L)).thenReturn(true);
when(groupRepository.findByEducationFormId(20L)).thenReturn(List.of());
when(calendarRepository.countByStudyFormId(20L)).thenReturn(0L);
EducationFormController educationFormController = new EducationFormController(
educationFormRepository,
groupRepository,
calendarRepository
);
mockMvc = MockMvcBuilders
.standaloneSetup(specialityController, educationFormController)
.addInterceptors(new AuthorizationInterceptor(jwtTokenService, new ObjectMapper()))
.build();
}
@AfterEach
void clearContext() {
TenantContext.clear();
}
@Test
void educationOfficeInitializesCalendarAndManagesEducationFormsWithoutForbidden() throws Exception {
mockMvc.perform(get("/api/specialties").header("Authorization", bearer()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/specialties/1/profiles").header("Authorization", bearer()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/education-forms").header("Authorization", bearer()))
.andExpect(status().isOk());
mockMvc.perform(post("/api/education-forms")
.header("Authorization", bearer())
.contentType("application/json")
.content("{\"name\":\"Очная\"}"))
.andExpect(status().isOk());
mockMvc.perform(delete("/api/education-forms/20").header("Authorization", bearer()))
.andExpect(status().isOk());
}
@Test
void educationOfficeStillCannotModifySpecialties() throws Exception {
mockMvc.perform(post("/api/specialties")
.header("Authorization", bearer())
.contentType("application/json")
.content("{\"specialityName\":\"Тест\",\"specialityCode\":\"01\"}"))
.andExpect(status().isForbidden());
}
private String bearer() {
return "Bearer " + TOKEN;
}
}

View File

@@ -0,0 +1,111 @@
package com.magistr.app.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.mock.web.MockHttpServletRequest;
import java.sql.SQLException;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Безопасное преобразование нарушений целостности БД")
class GlobalExceptionHandlerDataIntegrityTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
@DisplayName("известный CHECK возвращает русский 400 без JDBC details")
void knownCheckConstraintReturnsSafeBadRequest() {
String technicalMessage = "ERROR: new row violates check constraint "
+ "\"chk_time_slots_time_range\" Detail: failing row contains secret-value";
var response = handler.handleDataIntegrityViolation(
violation(technicalMessage, "23514"),
request("POST", "/api/admin/time-slots")
);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getBody())
.containsEntry("error", "Некорректный запрос")
.containsEntry("message", "Время начала пары должно быть раньше времени окончания");
assertNoTechnicalDetails(response.getBody());
}
@Test
@DisplayName("известный UNIQUE возвращает русский 409 без имени ограничения")
void knownUniqueConstraintReturnsSafeConflict() {
String technicalMessage = "duplicate key value violates unique constraint "
+ "\"uq_subjects_name_ci\" Detail: Key (lower(name))=(secret-value) already exists";
var response = handler.handleDataIntegrityViolation(
violation(technicalMessage, "23505"),
request("POST", "/api/subjects")
);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody())
.containsEntry("error", "Конфликт")
.containsEntry("message", "Дисциплина с таким названием уже существует");
assertNoTechnicalDetails(response.getBody());
}
@Test
@DisplayName("неизвестный constraint получает общий 409 без сырого исключения")
void unknownConstraintReturnsGenericSafeConflict() {
String technicalMessage = "duplicate key violates constraint \"tenant_private_constraint\" "
+ "jdbc:postgresql://db/private?password=secret-value";
var response = handler.handleDataIntegrityViolation(
violation(technicalMessage, "23505"),
request("PUT", "/api/private")
);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody())
.containsEntry("error", "Конфликт")
.containsEntry("message", "Такая запись уже существует");
assertNoTechnicalDetails(response.getBody());
}
@Test
@DisplayName("неизвестная integrity-ошибка не превращается в 500 и не раскрывает cause")
void unknownIntegrityFailureReturnsGenericConflict() {
var exception = new DataIntegrityViolationException(
"could not execute statement; jdbc:postgresql://db/private",
new RuntimeException("password=secret-value")
);
var response = handler.handleDataIntegrityViolation(
exception,
request("DELETE", "/api/private/1")
);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody())
.containsEntry("message", "Операция нарушает ограничения целостности данных");
assertNoTechnicalDetails(response.getBody());
}
private DataIntegrityViolationException violation(String message, String sqlState) {
return new DataIntegrityViolationException(message, new SQLException(message, sqlState));
}
private MockHttpServletRequest request(String method, String path) {
MockHttpServletRequest request = new MockHttpServletRequest(method, path);
request.setRequestURI(path);
return request;
}
private void assertNoTechnicalDetails(Map<String, Object> body) {
assertThat(body).isNotNull();
String rendered = body.toString().toLowerCase();
assertThat(rendered)
.doesNotContain("constraint")
.doesNotContain("jdbc:")
.doesNotContain("password")
.doesNotContain("secret-value")
.doesNotContain("sqlstate");
}
}

View File

@@ -0,0 +1,78 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeacherSubjectControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void departmentCanManageTeacherThroughAdditionalActiveAssignment() {
TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
UserRepository userRepository = mock(UserRepository.class);
SubjectRepository subjectRepository = mock(SubjectRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
User teacher = teacher();
Subject subject = new Subject();
subject.setId(20L);
subject.setName("Высшая математика");
subject.setDepartmentId(1L);
when(userRepository.findById(teacher.getId())).thenReturn(Optional.of(teacher));
when(subjectRepository.findById(subject.getId())).thenReturn(Optional.of(subject));
when(teacherDepartmentService.hasAssignmentAtDate(
teacher.getId(),
1L,
LocalDate.now()
)).thenReturn(true);
TeacherSubjectController controller = new TeacherSubjectController(
teacherSubjectRepository,
userRepository,
subjectRepository,
teacherDepartmentService
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 1L));
var response = controller.create(Map.of("userId", teacher.getId(), "subjectId", subject.getId()));
assertThat(response.getStatusCode().value()).isEqualTo(200);
ArgumentCaptor<TeacherSubject> captor = ArgumentCaptor.forClass(TeacherSubject.class);
verify(teacherSubjectRepository).save(captor.capture());
assertThat(captor.getValue().getUserId()).isEqualTo(teacher.getId());
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
}
private User teacher() {
User teacher = new User();
teacher.setId(10L);
teacher.setUsername("teacher");
teacher.setRole(Role.TEACHER);
teacher.setFullName("Петров Пётр Петрович");
teacher.setJobTitle("Доцент");
teacher.setDepartmentId(2L);
return teacher;
}
}

View File

@@ -8,6 +8,7 @@ import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -24,16 +25,19 @@ class UserControllerTest {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
UserController controller = new UserController(
userRepository,
null,
departmentRepository,
assignmentRepository
assignmentRepository,
teacherDepartmentService
);
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
when(teacherDepartmentService.findTeachersForDepartmentAtDate(
org.mockito.ArgumentMatchers.eq(2L),
org.mockito.ArgumentMatchers.any()
))
.thenReturn(List.of(teacher));
when(departmentRepository.findById(2L))
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));

View File

@@ -1,24 +1,87 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
class WorkloadControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void teacherWorkloadUsesDepartmentAtEachLessonDate() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
LocalDate startDate = LocalDate.of(2026, 1, 1);
LocalDate endDate = LocalDate.of(2026, 3, 31);
RenderedLessonDto oldLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 1, 15), 1L);
RenderedLessonDto newLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 3, 15), 2L);
List<TeacherDepartmentAssignment> assignments = List.of(
assignment(10L, 1L, LocalDate.of(2025, 9, 1), LocalDate.of(2026, 2, 28)),
assignment(10L, 2L, LocalDate.of(2026, 3, 1), null)
);
when(scheduleQueryService.searchForAggregation(any(), any(), eq(startDate), eq(endDate)))
.thenReturn(List.of(oldLesson, newLesson));
when(teacherDepartmentService.findAssignmentsForTeachersBetween(
eq(java.util.Set.of(10L)),
eq(startDate),
eq(endDate)
)).thenReturn(Map.of(10L, assignments));
when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, oldLesson.date()))
.thenReturn(Optional.of(1L));
when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, newLesson.date()))
.thenReturn(Optional.of(2L));
when(departmentRepository.findById(1L))
.thenReturn(Optional.of(new Department(1L, "Кафедра ИБ", 1L)));
when(departmentRepository.findById(2L))
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
departmentRepository,
teacherDepartmentService
);
authenticate(Role.ADMIN, null);
List<WorkloadSummaryDto> result = controller.teacherWorkload(startDate, endDate, null);
assertThat(result).hasSize(2);
assertThat(result).extracting(WorkloadSummaryDto::departmentId)
.containsExactly(1L, 2L);
assertThat(result).extracting(WorkloadSummaryDto::lessonCount)
.containsOnly(1L);
}
@Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
@@ -26,19 +89,97 @@ class WorkloadControllerTest {
WorkloadController controller = new WorkloadController(
scheduleQueryService,
classroomRepository,
mock(UserRepository.class),
mock(DepartmentRepository.class)
mock(DepartmentRepository.class),
mock(TeacherDepartmentService.class)
);
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
when(scheduleQueryService.searchForAggregation(any(), eq(1L), any(), any()))
.thenReturn(List.of());
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
authenticate(Role.SCHEDULE_VIEWER, null);
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
assertThat(result).extracting("id").containsExactly(3L);
}
@Test
void departmentRoleUsesOwnDepartmentForEveryWorkloadEndpoint() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
classroomRepository,
departmentRepository,
teacherDepartmentService
);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
LocalDate date = LocalDate.of(2026, 5, 15);
when(scheduleQueryService.searchForAggregation(any(), any(), any(), any()))
.thenReturn(List.of());
when(departmentRepository.findAll()).thenReturn(List.of());
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of());
authenticate(Role.DEPARTMENT, 7L);
controller.teacherWorkload(startDate, endDate, null);
controller.classroomWorkload(startDate, endDate, 7L);
controller.departmentWorkload(startDate, endDate, null);
controller.timeSlotWorkload(startDate, endDate, 7L);
controller.freeClassrooms(date, 3L);
verify(scheduleQueryService, times(4))
.searchForAggregation(eq(7L), isNull(), eq(startDate), eq(endDate));
verify(scheduleQueryService)
.searchForAggregation(eq(7L), eq(3L), eq(date), eq(date));
}
@Test
void departmentRoleCannotRequestAnotherDepartment() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentService.class)
);
authenticate(Role.DEPARTMENT, 7L);
assertThatThrownBy(() -> controller.teacherWorkload(
LocalDate.of(2026, 5, 1),
LocalDate.of(2026, 5, 31),
8L
))
.isInstanceOfSatisfying(ResponseStatusException.class, exception -> {
assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(exception.getReason()).isEqualTo("Нельзя просматривать нагрузку другой кафедры");
});
verifyNoInteractions(scheduleQueryService);
}
@Test
void privilegedRoleKeepsGlobalScope() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentService.class)
);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
when(scheduleQueryService.searchForAggregation(any(), any(), any(), any()))
.thenReturn(List.of());
authenticate(Role.EDUCATION_OFFICE, null);
controller.timeSlotWorkload(startDate, endDate, null);
verify(scheduleQueryService)
.searchForAggregation(isNull(), isNull(), eq(startDate), eq(endDate));
}
private Classroom classroom(Long id, Boolean available) {
Classroom classroom = new Classroom();
classroom.setId(id);
@@ -47,4 +188,36 @@ class WorkloadControllerTest {
classroom.setIsAvailable(available);
return classroom;
}
private RenderedLessonDto lesson(Long teacherId,
String teacherName,
LocalDate date,
Long timeSlotId) {
RenderedLessonDto lesson = mock(RenderedLessonDto.class);
when(lesson.teacherId()).thenReturn(teacherId);
when(lesson.teacherName()).thenReturn(teacherName);
when(lesson.date()).thenReturn(date);
when(lesson.timeSlotId()).thenReturn(timeSlotId);
return lesson;
}
private TeacherDepartmentAssignment assignment(Long teacherId,
Long departmentId,
LocalDate validFrom,
LocalDate validTo) {
User teacher = new User();
teacher.setId(teacherId);
Department department = new Department(departmentId, "Кафедра " + departmentId, departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setValidTo(validTo);
assignment.setPrimaryAssignment(true);
return assignment;
}
private void authenticate(Role role, Long departmentId) {
AuthContext.setCurrentUser(new AuthenticatedUser(100L, "test", role, departmentId));
}
}

View File

@@ -0,0 +1,292 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Инварианты учебных периодов базовой схемы")
class AcademicPeriodInvariantMigrationIntegrationTest {
private static final String YEAR_OVERLAP_CONSTRAINT = "ex_academic_years_no_overlap";
private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")
void migrationEnforcesAcademicPeriodInvariants() throws SQLException {
Flyway flyway = flyway("1");
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
long academicYearId = insertAcademicYear(
connection,
"2026-2027",
LocalDate.of(2026, 7, 1),
LocalDate.of(2027, 6, 30)
);
long autumnId = insertSemester(
connection,
academicYearId,
"autumn",
LocalDate.of(2026, 7, 1),
LocalDate.of(2027, 1, 31)
);
assertSqlState(
() -> insertAcademicYear(
connection,
"2027-overlap",
LocalDate.of(2027, 6, 30),
LocalDate.of(2028, 6, 30)
),
"23P01",
YEAR_OVERLAP_CONSTRAINT
);
assertSqlState(
() -> insertSemester(
connection,
academicYearId,
"spring",
LocalDate.of(2026, 6, 30),
LocalDate.of(2027, 6, 30)
),
"23514",
"Семестр должен полностью находиться в границах учебного года"
);
long springId = insertSemester(
connection,
academicYearId,
"spring",
LocalDate.of(2027, 2, 1),
LocalDate.of(2027, 6, 30)
);
assertSqlState(
() -> updateDate(
connection,
"UPDATE semesters SET start_date = ? WHERE id = ?",
LocalDate.of(2027, 1, 31),
springId
),
"23P01",
SEMESTER_OVERLAP_CONSTRAINT
);
assertSqlState(
() -> updateDate(
connection,
"UPDATE academic_years SET start_date = ? WHERE id = ?",
LocalDate.of(2026, 7, 2),
academicYearId
),
"23514",
"Новые границы учебного года не включают все его семестры"
);
assertThat(autumnId).isPositive();
assertThat(constraintCount(connection, "academic_years", YEAR_OVERLAP_CONSTRAINT)).isOne();
assertThat(constraintCount(connection, "semesters", SEMESTER_OVERLAP_CONSTRAINT)).isOne();
}
}
@Test
@DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся учебных годов")
void migrationProtectsConcurrentAcademicYearWrites() throws Exception {
Flyway flyway = flyway("1");
flyway.clean();
flyway.migrate();
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentYearInsert(
"2090-2091-a",
LocalDate.of(2090, 9, 1),
LocalDate.of(2091, 6, 30),
ready,
start
)),
executor.submit(() -> concurrentYearInsert(
"2091-2092-b",
LocalDate.of(2091, 6, 30),
LocalDate.of(2092, 6, 30),
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
if ("23P01".equals(outcome.sqlState())) {
assertThat(outcome.message()).contains(YEAR_OVERLAP_CONSTRAINT);
}
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private InsertOutcome concurrentYearInsert(String title,
LocalDate startDate,
LocalDate endDate,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertAcademicYear(connection, title, startDate, endDate);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private Flyway flyway(String targetVersion) {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion(targetVersion))
.cleanDisabled(false)
.load();
}
private long insertAcademicYear(Connection connection,
String title,
LocalDate startDate,
LocalDate endDate) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES (?, ?, ?)
RETURNING id
""")) {
statement.setString(1, title);
statement.setDate(2, Date.valueOf(startDate));
statement.setDate(3, Date.valueOf(endDate));
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private long insertSemester(Connection connection,
long academicYearId,
String semesterType,
LocalDate startDate,
LocalDate endDate) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO semesters (
academic_year_id,
semester_type,
start_date,
end_date
) VALUES (?, ?, ?, ?)
RETURNING id
""")) {
statement.setLong(1, academicYearId);
statement.setString(2, semesterType);
statement.setDate(3, Date.valueOf(startDate));
statement.setDate(4, Date.valueOf(endDate));
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void updateDate(Connection connection, String sql, LocalDate value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setDate(1, Date.valueOf(value));
statement.setLong(2, id);
statement.executeUpdate();
}
}
private long constraintCount(Connection connection,
String tableName,
String constraintName) throws SQLException {
return queryLong(
connection,
"SELECT count(*) FROM pg_constraint WHERE conrelid = (?::TEXT)::regclass AND conname = ?",
tableName,
constraintName
);
}
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
statement.setObject(index + 1, parameters[index]);
}
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue();
return resultSet.getLong(1);
}
}
}
private void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -0,0 +1,393 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Инварианты зависимостей календарных графиков базовой схемы")
class AcademicStructureInvariantIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы")
void baselineProtectsCalendarDependencies() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
long groupId = queryLong(
connection,
"SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"
);
long assignmentId = queryLong(
connection,
"SELECT id FROM student_group_calendar_assignments WHERE group_id = ?",
groupId
);
long calendarId = queryLong(
connection,
"SELECT calendar_id FROM student_group_calendar_assignments WHERE id = ?",
assignmentId
);
long otherFormId = queryLong(
connection,
"SELECT id FROM education_forms WHERE name = 'Специалитет'"
);
assertThat(incompatibleAssignmentCount(connection)).isZero();
assertSqlState(
() -> updateLong(
connection,
"UPDATE academic_calendars SET study_form_id = ? WHERE id = ?",
otherFormId,
calendarId
),
"23514",
"сохранённые назначения групп станут несовместимыми"
);
assertSqlState(
() -> updateLong(
connection,
"UPDATE student_groups SET education_form_id = ? WHERE id = ?",
otherFormId,
groupId
),
"23514",
"сохранённые назначения календарных графиков станут несовместимыми"
);
assertSqlState(
() -> updateInt(
connection,
"UPDATE academic_calendars SET course_count = ? WHERE id = ?",
3,
calendarId
),
"23514",
"сохранённая сетка выходит за новые измерения"
);
assertSqlState(
() -> insertOutOfRangeCalendarDay(connection, calendarId),
"23514",
"Строка сетки выходит за измерения календарного графика"
);
assertSqlState(
() -> insertOutOfRangeCalendarSubject(connection, calendarId),
"23514",
"Номер семестра дисциплины выходит за измерения календарного графика"
);
assertThat(queryLong(
connection,
"SELECT count(*) FROM student_group_calendar_assignments WHERE id = ?",
assignmentId
)).isOne();
assertThat(queryLong(
connection,
"SELECT course_count FROM academic_calendars WHERE id = ?",
calendarId
)).isEqualTo(4);
assertThat(incompatibleAssignmentCount(connection)).isZero();
}
}
@Test
@DisplayName("V1 не допускает гонку между назначением графика и изменением параметров группы")
void baselineProtectsConcurrentGroupAndAssignmentWrites() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long groupId;
long calendarId;
long academicYearId;
long otherFormId;
try (Connection connection = POSTGRES.createConnection("")) {
groupId = insertCompatibleGroup(connection);
calendarId = queryLong(connection, """
SELECT calendar.id
FROM academic_calendars calendar
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN specialties specialty ON specialty.id = calendar.specialty_id
JOIN education_forms study_form ON study_form.id = calendar.study_form_id
WHERE academic_year.title = '2025-2026'
AND specialty.specialty_code = '09.03.01'
AND study_form.name = 'Бакалавриат'
ORDER BY calendar.id
LIMIT 1
""");
academicYearId = queryLong(
connection,
"SELECT id FROM academic_years WHERE title = '2025-2026'"
);
otherFormId = queryLong(
connection,
"SELECT id FROM education_forms WHERE name = 'Специалитет'"
);
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
long finalGroupId = groupId;
long finalCalendarId = calendarId;
long finalAcademicYearId = academicYearId;
long finalOtherFormId = otherFormId;
List<Future<WriteOutcome>> futures = List.of(
executor.submit(() -> concurrentGroupUpdate(
finalGroupId,
finalOtherFormId,
ready,
start
)),
executor.submit(() -> concurrentAssignmentInsert(
finalGroupId,
finalAcademicYearId,
finalCalendarId,
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<WriteOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(WriteOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isEqualTo("23514");
assertThat(outcome.message()).containsAnyOf(
"Календарный график не соответствует",
"сохранённые назначения календарных графиков станут несовместимыми"
);
});
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(incompatibleAssignmentCount(connection)).isZero();
assertThat(queryLong(
connection,
"SELECT count(*) FROM student_group_calendar_assignments WHERE group_id = ?",
groupId
)).isLessThanOrEqualTo(1);
}
} finally {
start.countDown();
executor.shutdownNow();
}
}
private WriteOutcome concurrentGroupUpdate(long groupId,
long educationFormId,
CountDownLatch ready,
CountDownLatch start) {
return concurrentWrite(
"UPDATE student_groups SET education_form_id = ? WHERE id = ?",
List.of(educationFormId, groupId),
ready,
start
);
}
private WriteOutcome concurrentAssignmentInsert(long groupId,
long academicYearId,
long calendarId,
CountDownLatch ready,
CountDownLatch start) {
return concurrentWrite(
"""
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
VALUES (?, ?, ?)
""",
List.of(groupId, academicYearId, calendarId),
ready,
start
);
}
private WriteOutcome concurrentWrite(String sql,
List<Long> parameters,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new WriteOutcome(false, null, "Не получен общий сигнал запуска");
}
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.size(); index++) {
statement.setLong(index + 1, parameters.get(index));
}
statement.executeUpdate();
connection.commit();
return new WriteOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new WriteOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new WriteOutcome(false, null, exception.getMessage());
}
}
private Flyway baselineFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
private long insertCompatibleGroup(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO student_groups (
name,
group_size,
education_form_id,
department_id,
specialty_id,
specialty_profile_id,
year_start_study
)
SELECT
'КОНКУРЕНТНАЯ-ГРУППА',
20,
study_form.id,
department.id,
specialty.id,
profile.id,
2025
FROM education_forms study_form
CROSS JOIN departments department
CROSS JOIN specialties specialty
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
WHERE study_form.name = 'Бакалавриат'
AND department.code = 1
AND specialty.specialty_code = '09.03.01'
AND profile.name = 'Без профиля'
RETURNING id
""")) {
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days (
calendar_id,
course_number,
date,
week_number,
day_of_week,
activity_type_id
) VALUES (?, 5, DATE '2025-09-01', 1, 1, (
SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'
))
""")) {
statement.setLong(1, calendarId);
statement.executeUpdate();
}
}
private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id)
VALUES (?, 9, (SELECT min(id) FROM subjects))
""")) {
statement.setLong(1, calendarId);
statement.executeUpdate();
}
}
private long incompatibleAssignmentCount(Connection connection) throws SQLException {
return queryLong(connection, """
SELECT count(*)
FROM student_group_calendar_assignments assignment
JOIN student_groups student_group ON student_group.id = assignment.group_id
JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
WHERE assignment.academic_year_id <> calendar.academic_year_id
OR student_group.specialty_id <> calendar.specialty_id
OR student_group.specialty_profile_id <> calendar.specialty_profile_id
OR student_group.education_form_id <> calendar.study_form_id
""");
}
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private void updateInt(Connection connection, String sql, int value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
statement.setObject(index + 1, parameters[index]);
}
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue();
return resultSet.getLong(1);
}
}
}
private void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record WriteOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -14,31 +14,21 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Миграция инвариантов точечных изменений расписания")
@DisplayName("Инварианты точечных изменений расписания базовой схемы")
class ScheduleOverrideMigrationIntegrationTest {
private static final String[] OVERRIDE_CONSTRAINTS = {
"chk_schedule_overrides_cancel_payload",
"chk_schedule_overrides_move_payload",
"chk_schedule_overrides_replace_payload",
"chk_schedule_overrides_format"
};
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные")
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
Flyway flyway = targetThreeFlyway();
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
@@ -46,8 +36,8 @@ class ScheduleOverrideMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V3")
.isEqualTo("3");
.as("Последней успешно применённой миграцией должна быть V1")
.isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
@@ -106,81 +96,11 @@ class ScheduleOverrideMigrationIntegrationTest {
}
}
@Test
@DisplayName("V3 останавливается на legacy-данных до добавления ограничений")
void migrationFailsPreflightWithoutPartialConstraints() throws SQLException {
Flyway targetTwo = targetTwoFlyway();
targetTwo.clean();
targetTwo.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
insertOverride(
connection,
seedIds,
LocalDate.of(2026, 5, 4),
"REPLACE",
null,
null,
null
);
}
Flyway targetThree = targetThreeFlyway();
Throwable migrationFailure = catchThrowable(targetThree::migrate);
assertThat(migrationFailure)
.as("V3 должна остановить миграцию при пустом legacy-изменении REPLACE")
.isNotNull();
assertThat(collectMessages(migrationFailure))
.as("Ошибка preflight должна объяснять проблему на русском языке")
.contains("Невозможно применить инварианты точечных изменений")
.contains("REPLACE=1")
.contains("Исправьте данные tenant-БД и повторите миграцию");
assertThat(targetThree.info().current())
.as("После отката V3 должна остаться текущая успешная версия")
.isNotNull();
assertThat(targetThree.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна остаться V2")
.isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(
connection,
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '3'"
))
.as("V3 не должна отмечаться как успешно применённая")
.isZero();
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid = 'schedule_overrides'::regclass
AND conname::text = ANY (?)
""",
connection.createArrayOf("text", OVERRIDE_CONSTRAINTS)
))
.as("Ни одно ограничение V3 не должно остаться после неуспешного preflight")
.isZero();
}
}
private Flyway targetThreeFlyway() {
private Flyway baselineFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("3"))
.cleanDisabled(false)
.load();
}
private Flyway targetTwoFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("2"))
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
@@ -276,19 +196,6 @@ class ScheduleOverrideMigrationIntegrationTest {
}
}
private String collectMessages(Throwable failure) {
StringBuilder messages = new StringBuilder();
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Throwable current = failure;
while (current != null && visited.add(current)) {
if (current.getMessage() != null) {
messages.append(current.getMessage()).append('\n');
}
current = current.getCause();
}
return messages.toString();
}
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
}
}

View File

@@ -12,15 +12,12 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Миграция чётности академических часов правил расписания")
@DisplayName("Инварианты правил расписания базовой схемы")
class ScheduleRuleHoursMigrationIntegrationTest {
private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
@@ -30,9 +27,9 @@ class ScheduleRuleHoursMigrationIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
void migrationEnforcesEvenAcademicHours() throws SQLException {
Flyway flyway = targetFourFlyway();
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
@@ -40,8 +37,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V4")
.isEqualTo("4");
.as("Последней успешно применённой миграцией должна быть V1")
.isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
@@ -63,7 +60,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
""",
HOURS_CONSTRAINT
))
.as("Ограничение чётности V4 должно быть провалидировано")
.as("Ограничение чётности V1 должно быть провалидировано")
.isOne();
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
@@ -73,113 +70,11 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
@Test
@DisplayName("V4 останавливается на legacy-правиле с нечётными часами до добавления ограничения")
void migrationFailsPreflightWithoutPartialConstraint() throws SQLException {
Flyway targetThree = targetThreeFlyway();
targetThree.clean();
targetThree.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
insertScheduleRule(connection, seedIds, 1, 3, 5);
}
Flyway targetFour = targetFourFlyway();
Throwable migrationFailure = catchThrowable(targetFour::migrate);
assertThat(migrationFailure)
.as("V4 должна остановить миграцию при нечётных часах legacy-правила")
.isNotNull();
assertThat(collectMessages(migrationFailure))
.as("Ошибка preflight должна объяснять проблему на русском языке")
.contains("Невозможно применить инвариант чётности академических часов правил расписания")
.contains("лекции=1")
.contains("лабораторные=1")
.contains("практики=1")
.contains("Исправьте данные tenant-БД и повторите миграцию");
assertThat(targetFour.info().current())
.as("После отката V4 должна остаться текущая успешная версия")
.isNotNull();
assertThat(targetFour.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна остаться V3")
.isEqualTo("3");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(
connection,
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
))
.as("V4 не должна отмечаться как успешно применённая")
.isZero();
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid = 'schedule_rules'::regclass
AND conname = ?
""",
HOURS_CONSTRAINT
))
.as("Ограничение V4 не должно частично остаться после неуспешного preflight")
.isZero();
}
}
@Test
@DisplayName("V4 останавливается на legacy-дубле слота до добавления ограничений")
void migrationFailsOnLegacyExactSlotDuplicate() throws SQLException {
Flyway targetThree = targetThreeFlyway();
targetThree.clean();
targetThree.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
duplicateExistingSlot(connection);
}
Flyway targetFour = targetFourFlyway();
Throwable migrationFailure = catchThrowable(targetFour::migrate);
assertThat(migrationFailure)
.as("V4 должна остановить миграцию при точном legacy-дубле слота")
.isNotNull();
assertThat(collectMessages(migrationFailure))
.as("Ошибка preflight должна объяснять проблему уникальности на русском языке")
.contains("Невозможно применить инвариант уникальности слотов правил расписания")
.contains("групп точных дублей=1")
.contains("Исправьте данные tenant-БД и повторите миграцию");
assertThat(targetFour.info().current())
.as("После отката V4 должна остаться текущая успешная версия")
.isNotNull();
assertThat(targetFour.info().current().getVersion().getVersion()).isEqualTo("3");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(
connection,
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
)).isZero();
assertConstraintAbsent(connection, HOURS_CONSTRAINT);
assertConstraintAbsent(connection, EXACT_SLOT_CONSTRAINT);
}
}
private Flyway targetFourFlyway() {
private Flyway baselineFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("4"))
.cleanDisabled(false)
.load();
}
private Flyway targetThreeFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("3"))
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
@@ -291,20 +186,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
private void assertConstraintAbsent(Connection connection, String constraintName) throws SQLException {
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid IN ('schedule_rules'::regclass, 'schedule_rule_slots'::regclass)
AND conname = ?
""",
constraintName
)).as("Ограничение %s не должно частично остаться после неуспешной V4", constraintName)
.isZero();
}
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
@@ -319,19 +200,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
private String collectMessages(Throwable failure) {
StringBuilder messages = new StringBuilder();
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Throwable current = failure;
while (current != null && visited.add(current)) {
if (current.getMessage() != null) {
messages.append(current.getMessage()).append('\n');
}
current = current.getCause();
}
return messages.toString();
}
private record SeedIds(long subjectId, long semesterId) {
}
}

View File

@@ -0,0 +1,146 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Уникальность владения дисциплинами базовой схемы")
class SubjectOwnershipInvariantIntegrationTest {
private static final String UNIQUE_INDEX = "uq_subjects_name_ci";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра")
void baselineEnforcesCaseInsensitiveGlobalSubjectName() throws SQLException {
migrateBaseline();
try (Connection connection = POSTGRES.createConnection("")) {
long anotherDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
Throwable failure = catchThrowable(() -> insertSubject(
connection,
"информатика",
anotherDepartmentId
));
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo("23505");
assertThat(sqlFailure.getMessage()).contains(UNIQUE_INDEX);
}
}
@Test
@DisplayName("V1 конкурентно принимает только одну дисциплину с одинаковым названием")
void baselineProtectsConcurrentSubjectCreation() throws Exception {
migrateBaseline();
long firstDepartmentId;
long secondDepartmentId;
try (Connection connection = POSTGRES.createConnection("")) {
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentInsert("Теория систем", firstDepartmentId, ready, start)),
executor.submit(() -> concurrentInsert("теория систем", secondDepartmentId, ready, start))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isEqualTo("23505");
assertThat(outcome.message()).contains(UNIQUE_INDEX);
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private void migrateBaseline() {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
flyway.clean();
flyway.migrate();
}
private InsertOutcome concurrentInsert(String name,
long departmentId,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertSubject(connection, name, departmentId);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private void insertSubject(Connection connection, String name, long departmentId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO subjects (name, department_id)
VALUES (?, ?)
""")) {
statement.setString(1, name);
statement.setLong(2, departmentId);
statement.executeUpdate();
}
}
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
var resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -0,0 +1,257 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Исторические назначения преподавателей базовой схемы")
class TeacherDepartmentInvariantIntegrationTest {
private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
void baselineEnforcesTeacherDepartmentTimeline() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
long teacherId = seedTeacherId(connection);
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));
insertAssignment(
connection,
teacherId,
secondDepartmentId,
LocalDate.of(2026, 8, 1),
null,
true
);
insertAssignment(
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 1),
null,
false
);
assertSqlState(
() -> insertAssignment(
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 1),
true
),
"23P01",
OVERLAP_CONSTRAINT
);
assertThat(queryLong(
connection,
"SELECT count(*) FROM pg_constraint WHERE conname = ?",
OVERLAP_CONSTRAINT
)).isOne();
}
}
@Test
@DisplayName("V1 отклоняет одну из двух конкурентных пересекающихся основных кафедр")
void baselineProtectsConcurrentPrimaryAssignments() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long teacherId;
long firstDepartmentId;
long secondDepartmentId;
try (Connection connection = POSTGRES.createConnection("")) {
teacherId = seedTeacherId(connection);
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentInsert(
teacherId,
firstDepartmentId,
LocalDate.of(2090, 1, 1),
LocalDate.of(2090, 12, 31),
ready,
start
)),
executor.submit(() -> concurrentInsert(
teacherId,
secondDepartmentId,
LocalDate.of(2090, 6, 1),
LocalDate.of(2091, 5, 31),
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
if ("23P01".equals(outcome.sqlState())) {
assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
}
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private InsertOutcome concurrentInsert(long teacherId,
long departmentId,
LocalDate validFrom,
LocalDate validTo,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertAssignment(connection, teacherId, departmentId, validFrom, validTo, true);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private Flyway baselineFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
private long seedTeacherId(Connection connection) throws SQLException {
return queryLong(
connection,
"SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"
);
}
private void updateOpenPrimaryEnd(Connection connection,
long teacherId,
LocalDate validTo) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
UPDATE teacher_department_assignments
SET valid_to = ?
WHERE teacher_id = ?
AND is_primary = TRUE
AND valid_to IS NULL
""")) {
statement.setDate(1, Date.valueOf(validTo));
statement.setLong(2, teacherId);
assertThat(statement.executeUpdate()).isEqualTo(1);
}
}
private void insertAssignment(Connection connection,
long teacherId,
long departmentId,
LocalDate validFrom,
LocalDate validTo,
boolean primary) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO teacher_department_assignments (
teacher_id,
department_id,
valid_from,
valid_to,
is_primary
) VALUES (?, ?, ?, ?, ?)
""")) {
statement.setLong(1, teacherId);
statement.setLong(2, departmentId);
statement.setDate(3, Date.valueOf(validFrom));
statement.setObject(4, validTo == null ? null : Date.valueOf(validTo));
statement.setBoolean(5, primary);
statement.executeUpdate();
}
}
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
statement.setObject(index + 1, parameters[index]);
}
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -0,0 +1,316 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Инварианты временных слотов базовой схемы")
class TimeSlotInvariantMigrationIntegrationTest {
private static final String DURATION_CONSTRAINT = "chk_time_slots_duration_matches_range";
private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
void migrationEnforcesAllTimeSlotInvariants() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
long manualScopeId = insertManualScope(connection, "migration_checks");
long firstSlotId = insertTimeSlot(connection, manualScopeId, 101, "08:00", "09:30", 90);
insertTimeSlot(connection, manualScopeId, 102, "09:30", "11:00", 90);
assertSqlState(
() -> insertTimeSlot(connection, manualScopeId, 103, "09:00", "10:00", 60),
"23P01",
OVERLAP_CONSTRAINT
);
assertSqlState(
() -> insertTimeSlot(connection, manualScopeId, 104, "11:10", "12:10", 30),
"23514",
DURATION_CONSTRAINT
);
long usedBaseSlotId = queryLong(
connection,
"SELECT time_slot_id FROM schedule_rule_slots ORDER BY id LIMIT 1"
);
assertSqlState(
() -> updateLong(
connection,
"UPDATE time_slots SET time_slot_scope_id = ? WHERE id = ?",
manualScopeId,
usedBaseSlotId
),
"23514",
"нельзя переносить из базовой сетки"
);
assertSqlState(
() -> copyRuleSlotWithTimeSlot(connection, firstSlotId),
"23514",
"только слоты базовой сетки"
);
long defaultScopeId = queryLong(
connection,
"SELECT id FROM time_slot_scopes WHERE apply_mode = 'DEFAULT'"
);
assertSqlState(
() -> updateString(
connection,
"UPDATE time_slot_scopes SET apply_mode = ? WHERE id = ?",
"MANUAL",
defaultScopeId
),
"23514",
"нельзя сделать небазовой"
);
assertThat(constraintCount(connection, DURATION_CONSTRAINT)).isOne();
assertThat(constraintCount(connection, OVERLAP_CONSTRAINT)).isOne();
}
}
@Test
@DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся интервалов")
void migrationProtectsConcurrentWrites() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long manualScopeId;
try (Connection connection = POSTGRES.createConnection("")) {
manualScopeId = insertManualScope(connection, "concurrent_slots");
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentInsert(
manualScopeId, 301, "12:00", "13:30", ready, start)),
executor.submit(() -> concurrentInsert(
manualScopeId, 302, "13:00", "14:30", ready, start))
);
assertThat(ready.await(5, TimeUnit.SECONDS))
.as("Обе конкурентные транзакции должны подготовиться")
.isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
if ("23P01".equals(outcome.sqlState())) {
assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
}
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private InsertOutcome concurrentInsert(long scopeId,
int orderNumber,
String startTime,
String endTime,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertTimeSlot(connection, scopeId, orderNumber, startTime, endTime, 90);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private Flyway baselineFlyway() {
return flyway("1");
}
private Flyway flyway(String targetVersion) {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion(targetVersion))
.cleanDisabled(false)
.load();
}
private long insertManualScope(Connection connection, String code) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO time_slot_scopes (
code,
name,
apply_mode,
system_scope,
display_order
) VALUES (?, ?, 'MANUAL', FALSE, 900)
RETURNING id
""")) {
statement.setString(1, code);
statement.setString(2, "Тестовая ручная сетка");
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private long insertTimeSlot(Connection connection,
long scopeId,
int orderNumber,
String startTime,
String endTime,
int durationMinutes) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO time_slots (
time_slot_scope_id,
order_number,
start_time,
end_time,
duration_minutes
) VALUES (?, ?, ?::TIME, ?::TIME, ?)
RETURNING id
""")) {
statement.setLong(1, scopeId);
statement.setInt(2, orderNumber);
statement.setString(3, startTime);
statement.setString(4, endTime);
statement.setInt(5, durationMinutes);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void copyRuleSlotWithTimeSlot(Connection connection, long timeSlotId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schedule_rule_slots (
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
)
SELECT
schedule_rule_id,
day_of_week,
parity,
?,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
FROM schedule_rule_slots
ORDER BY id
LIMIT 1
""")) {
statement.setLong(1, timeSlotId);
statement.executeUpdate();
}
}
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private void updateString(Connection connection, String sql, String value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private long constraintCount(Connection connection, String constraintName) throws SQLException {
return queryLong(
connection,
"SELECT count(*) FROM pg_constraint WHERE conrelid = 'time_slots'::regclass AND conname = ?",
constraintName
);
}
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
statement.setObject(index + 1, parameters[index]);
}
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue();
return resultSet.getLong(1);
}
}
}
private void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.model;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
@@ -37,4 +38,17 @@ class LifecycleEntityTest {
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
}
@Test
void archiveUsesExplicitBusinessDateAndUtcMoment() {
Subject subject = new Subject();
LocalDate businessDate = LocalDate.of(2026, 1, 2);
Instant archiveMoment = Instant.parse("2026-01-01T21:00:00Z");
subject.archive("Тестовая архивация", businessDate, archiveMoment);
assertThat(subject.getActiveTo()).isEqualTo(businessDate);
assertThat(subject.getArchivedAt()).isEqualTo(archiveMoment);
assertThat(subject.getArchiveReason()).isEqualTo("Тестовая архивация");
}
}

View File

@@ -0,0 +1,245 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.SemesterRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AcademicPeriodServiceTest {
private static final long YEAR_ID = 10L;
private static final long SEMESTER_ID = 20L;
private static final LocalDate YEAR_START = LocalDate.of(2026, 9, 1);
private static final LocalDate YEAR_END = LocalDate.of(2027, 6, 30);
@Mock
private AcademicYearRepository academicYearRepository;
@Mock
private SemesterRepository semesterRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private AcademicPeriodService service;
private AcademicYear year;
@BeforeEach
void setUp() {
service = new AcademicPeriodService(
academicYearRepository,
semesterRepository,
scheduleGeneratorService
);
year = year();
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(semesterRepository.saveAndFlush(any(Semester.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void createsNonOverlappingYearWithTrimmedTitle() {
AcademicYear saved = service.createYear(yearRequest(
" 2026-2027 ",
YEAR_START,
YEAR_END
));
assertThat(saved.getTitle()).isEqualTo("2026-2027");
assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsDuplicateTitleOrOverlappingYear() {
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
assertThatThrownBy(() -> service.createYear(yearRequest(
"2026-2027",
YEAR_START,
YEAR_END
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом");
verify(academicYearRepository, never()).saveAndFlush(any());
}
@Test
void rejectsYearUpdateThatWouldExcludeExistingSemester() {
Semester autumn = semester(
SEMESTER_ID,
SemesterType.autumn,
YEAR_START,
LocalDate.of(2027, 1, 31)
);
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
.thenReturn(List.of(autumn));
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest(
"2026-2027",
YEAR_START.plusDays(1),
YEAR_END
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Новые границы учебного года не включают все его семестры");
verify(academicYearRepository, never()).saveAndFlush(any());
}
@Test
void rejectsSemesterOutsideAcademicYear() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest(
SemesterType.autumn,
YEAR_START.minusDays(1),
LocalDate.of(2027, 1, 31)
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Семестр должен полностью находиться в границах учебного года");
verify(semesterRepository, never()).saveAndFlush(any());
}
@Test
void rejectsDuplicateTypeOrOverlappingSemester() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.existsOverlapping(
YEAR_ID,
YEAR_START,
LocalDate.of(2027, 2, 1)
)).thenReturn(true);
assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest(
SemesterType.autumn,
YEAR_START,
LocalDate.of(2027, 2, 1)
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года");
verify(semesterRepository, never()).saveAndFlush(any());
}
@Test
void acceptsAdjacentSemesters() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
Semester saved = service.createSemester(YEAR_ID, semesterRequest(
SemesterType.spring,
LocalDate.of(2027, 2, 1),
YEAR_END
));
assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1));
assertThat(saved.getAcademicYear()).isSameAs(year);
verify(scheduleGeneratorService).clearCache();
}
@Test
void validatesDuplicateSemesterTypeDuringUpdate() {
Semester existing = semester(
SEMESTER_ID,
SemesterType.spring,
LocalDate.of(2027, 2, 1),
YEAR_END
);
when(semesterRepository.findById(SEMESTER_ID)).thenReturn(Optional.of(existing));
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(existing));
when(semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
YEAR_ID,
SemesterType.autumn,
SEMESTER_ID
)).thenReturn(true);
assertThatThrownBy(() -> service.updateSemester(SEMESTER_ID, semesterRequest(
SemesterType.autumn,
LocalDate.of(2027, 2, 1),
YEAR_END
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года");
verify(semesterRepository, never()).saveAndFlush(any());
}
@Test
void mapsDatabaseRaceToSafeConflict() {
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenThrow(new DataIntegrityViolationException("ex_academic_years_no_overlap"));
assertThatThrownBy(() -> service.createYear(yearRequest(
"2026-2027",
YEAR_START,
YEAR_END
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом")
.hasNoCause();
}
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
return new AcademicYearDto(null, title, startDate, endDate, List.of());
}
private SemesterDto semesterRequest(SemesterType semesterType,
LocalDate startDate,
LocalDate endDate) {
return new SemesterDto(
null,
YEAR_ID,
"2026-2027",
semesterType,
startDate,
endDate
);
}
private AcademicYear year() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(YEAR_ID);
academicYear.setTitle("2026-2027");
academicYear.setStartDate(YEAR_START);
academicYear.setEndDate(YEAR_END);
return academicYear;
}
private Semester semester(Long id,
SemesterType semesterType,
LocalDate startDate,
LocalDate endDate) {
Semester semester = new Semester();
semester.setId(id);
semester.setAcademicYear(year);
semester.setSemesterType(semesterType);
semester.setStartDate(startDate);
semester.setEndDate(endDate);
return semester;
}
}

View File

@@ -0,0 +1,335 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AcademicStructureServiceTest {
private static final long YEAR_ID = 10L;
private static final long SPECIALITY_ID = 20L;
private static final long PROFILE_ID = 30L;
private static final long FORM_ID = 40L;
private static final long OTHER_FORM_ID = 41L;
private static final long CALENDAR_ID = 50L;
private static final long GROUP_ID = 60L;
@Mock
private AcademicCalendarRepository calendarRepository;
@Mock
private AcademicCalendarDayRepository calendarDayRepository;
@Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock
private AcademicYearRepository academicYearRepository;
@Mock
private SpecialtiesRepository specialtiesRepository;
@Mock
private SpecialtyProfileRepository profileRepository;
@Mock
private EducationFormRepository educationFormRepository;
@Mock
private GroupRepository groupRepository;
@Mock
private StudentGroupCalendarAssignmentRepository assignmentRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private AcademicStructureService service;
private AcademicYear academicYear;
private Speciality speciality;
private SpecialtyProfile profile;
private EducationForm studyForm;
private EducationForm otherStudyForm;
private AcademicCalendar calendar;
private StudentGroup group;
@BeforeEach
void setUp() {
service = new AcademicStructureService(
calendarRepository,
calendarDayRepository,
calendarSubjectRepository,
academicYearRepository,
specialtiesRepository,
profileRepository,
educationFormRepository,
groupRepository,
assignmentRepository,
scheduleGeneratorService
);
academicYear = academicYear();
speciality = speciality();
profile = profile(speciality);
studyForm = studyForm(FORM_ID, "Бакалавриат");
otherStudyForm = studyForm(OTHER_FORM_ID, "Магистратура");
calendar = calendar();
group = group(2025);
when(calendarRepository.findByIdWithDetailsForUpdate(CALENDAR_ID))
.thenReturn(Optional.of(calendar));
when(academicYearRepository.findById(YEAR_ID)).thenReturn(Optional.of(academicYear));
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(academicYear));
when(specialtiesRepository.findById(SPECIALITY_ID)).thenReturn(Optional.of(speciality));
when(profileRepository.findById(PROFILE_ID)).thenReturn(Optional.of(profile));
when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm));
when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm));
when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group));
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))
.thenReturn(List.of(assignment(group, calendar)));
assertThatThrownBy(() -> service.updateCalendar(
CALENDAR_ID,
calendarRequest(4, OTHER_FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя изменить график: сохранённые назначения групп станут несовместимыми");
assertThat(calendar.getStudyForm()).isSameAs(studyForm);
verify(calendarRepository, never()).saveAndFlush(any());
}
@Test
void rejectsCourseCountBelowExistingGrid() {
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
CALENDAR_ID, 3)).thenReturn(true);
assertThatThrownBy(() -> service.updateCalendar(
CALENDAR_ID,
calendarRequest(3, FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя уменьшить количество курсов: в сетке есть данные старших курсов");
assertThat(calendar.getCourseCount()).isEqualTo(4);
}
@Test
void rejectsCourseCountBelowExistingSubjects() {
when(calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
CALENDAR_ID, 6)).thenReturn(true);
assertThatThrownBy(() -> service.updateCalendar(
CALENDAR_ID,
calendarRequest(3, FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров");
}
@Test
void rejectsGroupDimensionChangeAndKeepsAssignment() {
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
.thenReturn(List.of(assignment(group, calendar)));
assertThatThrownBy(() -> service.updateGroup(
GROUP_ID,
groupRequest(2025, OTHER_FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми");
assertThat(group.getEducationForm()).isSameAs(studyForm);
verify(groupRepository, never()).saveAndFlush(any());
verify(assignmentRepository, never()).delete(any());
}
@Test
void acceptsCompatibleGroupUpdateAndClearsCache() {
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
.thenReturn(List.of(assignment(group, calendar)));
StudentGroup saved = service.updateGroup(GROUP_ID, groupRequest(2025, FORM_ID));
assertThat(saved.getName()).isEqualTo("ИВТ-26");
assertThat(saved.getEducationForm()).isSameAs(studyForm);
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() {
group.setYearStartStudy(2020);
assertThatThrownBy(() -> service.saveAssignment(
GROUP_ID,
assignmentRequest()
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Календарный график не соответствует учебному году, параметрам или курсу группы");
verify(assignmentRepository, never()).saveAndFlush(any());
}
@Test
void savesCompatibleAssignmentWithoutDeletingExistingData() {
when(assignmentRepository.findByGroupIdAndAcademicYearIdForUpdate(GROUP_ID, YEAR_ID))
.thenReturn(Optional.empty());
StudentGroupCalendarAssignment saved = service.saveAssignment(
GROUP_ID,
assignmentRequest()
);
assertThat(saved.getStudentGroup()).isSameAs(group);
assertThat(saved.getAcademicCalendar()).isSameAs(calendar);
verify(assignmentRepository, never()).delete(any());
verify(scheduleGeneratorService).clearCache();
}
private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) {
return new AcademicCalendarDto(
CALENDAR_ID,
"График 2026",
YEAR_ID,
null,
null,
null,
SPECIALITY_ID,
null,
null,
PROFILE_ID,
null,
studyFormId,
null,
courseCount
);
}
private CreateGroupRequest groupRequest(int yearStartStudy, long studyFormId) {
CreateGroupRequest request = new CreateGroupRequest();
request.setName("ИВТ-26");
request.setGroupSize(25L);
request.setEducationFormId(studyFormId);
request.setDepartmentId(1L);
request.setYearStartStudy(yearStartStudy);
request.setSpecialtyId(SPECIALITY_ID);
request.setSpecialtyProfileId(PROFILE_ID);
return request;
}
private GroupCalendarAssignmentDto assignmentRequest() {
return new GroupCalendarAssignmentDto(
null,
GROUP_ID,
null,
YEAR_ID,
null,
CALENDAR_ID,
null,
List.of()
);
}
private AcademicYear academicYear() {
AcademicYear year = new AcademicYear();
year.setId(YEAR_ID);
year.setTitle("2025-2026");
year.setStartDate(LocalDate.of(2025, 9, 1));
year.setEndDate(LocalDate.of(2026, 6, 30));
return year;
}
private Speciality speciality() {
Speciality value = new Speciality();
value.setId(SPECIALITY_ID);
value.setSpecialityCode("09.03.01");
value.setSpecialityName("Информатика");
return value;
}
private SpecialtyProfile profile(Speciality owner) {
SpecialtyProfile value = new SpecialtyProfile();
value.setId(PROFILE_ID);
value.setName("Без профиля");
value.setSpeciality(owner);
return value;
}
private EducationForm studyForm(long id, String name) {
EducationForm value = new EducationForm();
value.setId(id);
value.setName(name);
return value;
}
private AcademicCalendar calendar() {
AcademicCalendar value = new AcademicCalendar();
value.setId(CALENDAR_ID);
value.setTitle("Исходный график");
value.setAcademicYear(academicYear);
value.setSpeciality(speciality);
value.setSpecialtyProfile(profile);
value.setStudyForm(studyForm);
value.setCourseCount(4);
return value;
}
private StudentGroup group(int yearStartStudy) {
StudentGroup value = new StudentGroup();
value.setId(GROUP_ID);
value.setName("ИВТ-25");
value.setGroupSize(25L);
value.setEducationForm(studyForm);
value.setDepartmentId(1L);
value.setYearStartStudy(yearStartStudy);
value.setSpeciality(speciality);
value.setSpecialtyProfile(profile);
return value;
}
private StudentGroupCalendarAssignment assignment(StudentGroup assignedGroup,
AcademicCalendar assignedCalendar) {
StudentGroupCalendarAssignment value = new StudentGroupCalendarAssignment();
value.setId(70L);
value.setStudentGroup(assignedGroup);
value.setAcademicYear(academicYear);
value.setAcademicCalendar(assignedCalendar);
return value;
}
}

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