баг-фикс 30/34
This commit is contained in:
40
.env.example
Normal file
40
.env.example
Normal 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
|
||||||
124
.gitea/workflows/docker-build.yaml
Executable file → Normal file
124
.gitea/workflows/docker-build.yaml
Executable file → Normal file
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Docker Images
|
name: Проверка, сборка и развёртывание Magistr
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,32 +7,79 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: magistr-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.zuev.company # Замените на реальный домен вашего Gitea
|
REGISTRY: gitea.zuev.company
|
||||||
BACKEND_IMAGE: zuev/magistr-backend
|
BACKEND_IMAGE: zuev/magistr-backend
|
||||||
FRONTEND_IMAGE: zuev/magistr-frontend
|
FRONTEND_IMAGE: zuev/magistr-frontend
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-backend:
|
checks:
|
||||||
|
name: Обязательные проверки
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Получить исходный код
|
||||||
uses: actions/checkout@v4
|
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
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
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
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
|
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
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -41,26 +88,38 @@ jobs:
|
|||||||
labels: |
|
labels: |
|
||||||
${{ steps.meta.outputs.labels }}
|
${{ steps.meta.outputs.labels }}
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
||||||
build-and-push-frontend:
|
build-and-push-frontend:
|
||||||
|
name: Собрать frontend image
|
||||||
|
needs: checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
outputs:
|
||||||
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Получить исходный код
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Войти в Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.ZUEV_TOKEN }}
|
password: ${{ secrets.ZUEV_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Сформировать immutable tags и labels
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
|
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
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -69,29 +128,38 @@ jobs:
|
|||||||
labels: |
|
labels: |
|
||||||
${{ steps.meta.outputs.labels }}
|
${{ steps.meta.outputs.labels }}
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
||||||
deploy-to-k8s:
|
deploy-to-k8s:
|
||||||
|
name: Развернуть проверенные digests
|
||||||
needs: [build-and-push-backend, build-and-push-frontend]
|
needs: [build-and-push-backend, build-and-push-frontend]
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Create kubeconfig
|
- name: Получить deploy-скрипт
|
||||||
run: |
|
uses: actions/checkout@v4
|
||||||
mkdir -p ~/.kube
|
|
||||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
|
||||||
chmod 600 ~/.kube/config
|
|
||||||
|
|
||||||
- name: Install kubectl
|
- name: Создать kubeconfig
|
||||||
|
env:
|
||||||
|
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }}
|
||||||
run: |
|
run: |
|
||||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
install -d -m 700 "$HOME/.kube"
|
||||||
chmod +x kubectl
|
printf '%s' "$KUBECONFIG_DATA" | base64 --decode > "$HOME/.kube/config"
|
||||||
mv kubectl /usr/local/bin/
|
chmod 600 "$HOME/.kube/config"
|
||||||
|
|
||||||
- name: Trigger Kubernetes Rollout
|
- name: Установить проверенный kubectl
|
||||||
run: |
|
run: |
|
||||||
# Перезапускаем поды, чтобы они скачали свежий :main образ
|
curl --fail --show-error --silent --location --retry 3 \
|
||||||
kubectl rollout restart deployment backend frontend -n magistr
|
--output kubectl \
|
||||||
|
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||||
# Ждём успешного обновления (5 минут на backend из-за Spring Boot)
|
echo "${KUBECTL_SHA256} kubectl" | sha256sum --check --strict
|
||||||
kubectl rollout status deployment/frontend -n magistr --timeout=120s
|
sudo install -m 0755 kubectl /usr/local/bin/kubectl
|
||||||
kubectl rollout status deployment/backend -n magistr --timeout=300s
|
|
||||||
|
|
||||||
|
- name: Применить digests и проверить rollout
|
||||||
|
run: bash scripts/deploy-images.sh "$BACKEND_REF" "$FRONTEND_REF"
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
# Журнал исправления проблем
|
# Журнал исправления проблем
|
||||||
|
|
||||||
Обновлено: 2026-07-17 (Europe/Moscow).
|
Обновлено: 2026-07-19 (Europe/Moscow).
|
||||||
|
|
||||||
## Область и неизменяемые данные
|
## Область и неизменяемые данные
|
||||||
|
|
||||||
- В работе ровно 33 проблемы: `1` и `3–34`.
|
- В работе ровно 33 проблемы: `1` и `3–34`.
|
||||||
- Проблема № 2 исключена: демонстрационные учётные записи, seed-данные и документация по ним не изменяются.
|
- Проблема № 2 исключена: демонстрационные учётные записи, seed-данные и документация по ним не изменяются.
|
||||||
- `BUG_REPORT.md` не редактируется.
|
- `BUG_REPORT.md` не редактируется.
|
||||||
- Существующие Flyway-миграции не редактируются.
|
- По прямому разрешению владельца проекта миграции V2–V7 удалены, а их итоговая схема
|
||||||
|
объединена в `V1__init.sql`: проект находится на этапе разработки, клиентских tenant-БД нет.
|
||||||
|
Для этой редакции требуется полный сброс локальной БД.
|
||||||
|
|
||||||
Исходное рабочее дерево перед началом реализации было чистым (`git status --short` без вывода).
|
Исходное рабочее дерево перед началом реализации было чистым (`git status --short` без вывода).
|
||||||
|
|
||||||
Контрольные суммы исходных миграций:
|
Контрольные суммы исходных миграций до разрешённой консолидации (историческая справка):
|
||||||
|
|
||||||
| Файл | SHA-256 |
|
| Файл | SHA-256 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `V1__init.sql` | `404f65f270dd87f4db996495fc970456c4fd0f762e7c32638199dffaa812ea7b` |
|
| `V1__init.sql` | `404f65f270dd87f4db996495fc970456c4fd0f762e7c32638199dffaa812ea7b` |
|
||||||
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
|
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
|
||||||
|
|
||||||
|
Текущая единая baseline-миграция: `V1__init.sql`, SHA-256
|
||||||
|
`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`.
|
||||||
|
Упоминания V2–V7 в исторических записях ниже описывают последовательность разработки до
|
||||||
|
консолидации и не означают наличие этих файлов в текущем дереве.
|
||||||
|
|
||||||
## Исходная линия проверок
|
## Исходная линия проверок
|
||||||
|
|
||||||
| Проверка | Результат до изменений |
|
| Проверка | Результат до изменений |
|
||||||
@@ -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 — успешно. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. |
|
| 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-хеши V1–V4 совпадают. 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-файлов — успешно. |
|
| 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 30–300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. |
|
| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30–300 секунд и 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. |
|
| 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. |
|
| 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 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
|
| 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. | Отклонять несовместимое изменение транзакционно с `409`; контролировать `courseCount`. | Assignment сохранён после отказа; старшие строки не остаются. |
|
| 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 и фильтруют архивные записи по текущему состоянию. | Date-aware resolver кафедры и единое использование истории. | Будущий перевод, прошлый период, архивный преподаватель, совместительство. |
|
| 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` для роли `DEPARTMENT`. | Принудительный scope из `AuthContext`; глобальный доступ только разрешённым ролям. | Межкафедральный запрос кафедры не раскрывает данные. |
|
| 19 | исправлено и проверено | Workload принимал произвольный `departmentId` или глобальный scope для роли `DEPARTMENT`. | Все пять workload-сценариев вычисляют scope через `AuthContext`: кафедра получает только свою выборку, чужой ID даёт русский `403`; глобальный scope сохранён уполномоченным ролям. | 5 controller tests; полный backend 219/0/0/0; compose и diff-check успешны. |
|
||||||
| 20 | не начато | Агрегаты переиспользуют интерактивный широкий поиск с лимитом 50 групп. | Специализированная batch/aggregate генерация без UI-лимита. | 51 и 100 активных групп. |
|
| 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 | не начато | Импорт ищет дисциплину по глобальному имени и меняет её владельца. | `409` для чужой записи, идемпотентность своей; scoped uniqueness при миграции. | Повторный свой импорт и совпадение чужой кафедры. |
|
| 21 | исправлено и проверено | Импорт находил дисциплину по глобальному имени и без проверки менял её `department_id`. | Транзакционный `SubjectImportService` сохраняет глобальное владение: свой повтор обновляет тот же ID, чужой даёт `409`; V1 содержит case-insensitive уникальность названия. | 4 unit; полный backend 225/0/0/0; чистая V1, регистровый дубль и конкурентная вставка проверены на PostgreSQL 16.3. |
|
||||||
| 22 | не начато | Видимые EDUCATION_OFFICE экраны вызывают backend endpoints с ADMIN-only правами. | Единая role matrix для backend/frontend; согласовать read/write capabilities. | Интеграционный сценарий календаря и форм обучения без штатного `403`. |
|
| 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 на каждой комбинации. | Батч-загрузка и request-scoped lookup maps. | Query-count test на 120 датах и десятках групп. |
|
| 23 | исправлено и проверено | Генератор искал semester внутри дат и правил, а assignment/calendar day и эффективную сетку времени — внутри комбинаций дат и групп. | `AcademicDateService.ScheduleSnapshot`, batch-репозитории и `buildScheduleForGroups()` загружают данные один раз на запрос; lookup-карты используются во всех внутренних циклах. | 120 дат, 30 групп и 20 правил: ровно 7 repository reads, 30 корректных занятий; полный backend 228/0/0/0. |
|
||||||
| 24 | не начато | Истёкшие/отозванные refresh rows не удаляются. | Tenant-aware scheduled cleanup с retention и безопасностью двух pod. | Active/fresh rows сохраняются; cleanup идемпотентен и конкурентно безопасен. |
|
| 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; контроллеры возвращают клиенту `getMessage()`. | Централизованный sanitizer/mapping `400`/`409`, убрать raw exception body. | Известный/неизвестный constraint без JDBC details. |
|
| 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 и не имеет named volume. | Самодостаточная сеть/reverse proxy, согласованные env и volume. | Чистый `docker compose up -d --build`, HTTP `/` и `/api`. |
|
| 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 собирает/деплоит без обязательных тестов и для tag перезапускает mutable `main`. | Test gates, concurrency, immutable tag/digest rollout, rollback check. | Workflow lint/static inspection и shell tests. |
|
| 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; IP безусловно доверяет forwarded header. | PostgreSQL-backed tenant+trusted-IP+username limiter, lockout/audit, `429`/`Retry-After`. | Multi-instance integration tests, отсутствие user enumeration. |
|
| 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:00–03:00 и первые дни месяца. |
|
| 29 | не начато | Business dates зависят от timezone процесса; frontend date-only строится через UTC. | Инъекция `Clock`/`Europe/Moscow`, UTC timestamps, локальное форматирование date-only, TZ контейнеров. | Boundary tests 00:00–03:00 и первые дни месяца. |
|
||||||
| 30 | не начато | Образы/скачиваемые инструменты используют mutable tags/latest без checksum/SBOM/scan. | Pin versions+digests, SHA verification, SBOM и image scan gates. | Static scan production refs и CI configuration. |
|
| 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. |
|
| 31 | не начато | Group create/update допускает неположительные значения и уменьшение ниже активных подгрупп. | Общий validator, locking, новые CHECK constraints. | Create/update/concurrency и migration tests. |
|
||||||
| 32 | не начато | `between(start,end) > 120` разрешает 121 включительную дату. | Проверять `between + 1 <= 120` в обоих сервисах. | Границы 120/121. |
|
| 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. |
|
| 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. | Русификация и безопасные сообщения на границе API/UI. | Поиск английских production сообщений и regression tests. |
|
| 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` | Успешно. |
|
| `git diff --check` | Успешно. |
|
||||||
| Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. |
|
| Защищённые файлы | `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 V1–V4 не изменялись; 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 | По прямому разрешению владельца V2–V7 удалены, их итоговая схема включена в 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()` и
|
1. построить graphify-контекст всех `LocalDate.now()`/`LocalDateTime.now()` и frontend
|
||||||
inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации;
|
`toISOString()` в бизнес-операциях;
|
||||||
2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly
|
2. внедрить единый `Clock`/`ZoneId` Europe/Moscow без изменения абсолютных timestamp;
|
||||||
refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload;
|
3. зафиксировать `TZ`/`user.timezone` в Compose и Kubernetes;
|
||||||
3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без
|
4. проверить границы московской полуночи и date-only форматирование без UTC-сдвига.
|
||||||
`unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки;
|
|
||||||
4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP
|
|
||||||
перед AutoUpdateDocs и изменением статуса № 12.
|
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ COPY pom.xml .
|
|||||||
RUN mvn dependency:go-offline -B
|
RUN mvn dependency:go-offline -B
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN mvn package -DskipTests -B
|
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
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken;
|
|||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -25,17 +27,24 @@ public class RefreshTokenService {
|
|||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
private final AuthRefreshTokenRepository repository;
|
private final AuthRefreshTokenRepository repository;
|
||||||
private final JwtProperties properties;
|
private final JwtProperties properties;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
||||||
|
this(repository, properties, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
||||||
String refreshToken = generateRawToken();
|
String refreshToken = generateRawToken();
|
||||||
AuthRefreshToken token = new AuthRefreshToken();
|
AuthRefreshToken token = new AuthRefreshToken();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
Instant now = clock.instant();
|
||||||
token.setUser(user);
|
token.setUser(user);
|
||||||
token.setTenant(tenant);
|
token.setTenant(tenant);
|
||||||
token.setTokenHash(hashToken(refreshToken));
|
token.setTokenHash(hashToken(refreshToken));
|
||||||
@@ -53,7 +62,7 @@ public class RefreshTokenService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
Instant now = clock.instant();
|
||||||
String currentHash = hashToken(rawToken);
|
String currentHash = hashToken(rawToken);
|
||||||
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
|
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
|
||||||
if (storedOpt.isEmpty()) {
|
if (storedOpt.isEmpty()) {
|
||||||
@@ -101,7 +110,7 @@ public class RefreshTokenService {
|
|||||||
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
||||||
.filter(token -> token.getRevokedAt() == null)
|
.filter(token -> token.getRevokedAt() == null)
|
||||||
.ifPresent(token -> {
|
.ifPresent(token -> {
|
||||||
token.setRevokedAt(LocalDateTime.now());
|
token.setRevokedAt(clock.instant());
|
||||||
repository.save(token);
|
repository.save(token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public class TenantDataSourceConfig {
|
|||||||
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||||
);
|
);
|
||||||
tenants.add(defaultTenant);
|
tenants.add(defaultTenant);
|
||||||
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
|
||||||
}
|
}
|
||||||
|
|
||||||
readinessRegistry.clearConfigurationFailure();
|
readinessRegistry.clearConfigurationFailure();
|
||||||
@@ -79,10 +79,10 @@ public class TenantDataSourceConfig {
|
|||||||
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
|
||||||
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||||
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||||
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
|
||||||
log.warn("Добавьте тенант через POST /api/database/tenants");
|
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||||
|
|
||||||
TenantConfig h2Fallback = new TenantConfig(
|
TenantConfig h2Fallback = new TenantConfig(
|
||||||
@@ -155,7 +155,7 @@ public class TenantDataSourceConfig {
|
|||||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||||
return new TenantConfigLoadResult(List.copyOf(list), false);
|
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
|
||||||
e.getClass().getSimpleName());
|
e.getClass().getSimpleName());
|
||||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||||
return new TenantConfigLoadResult(List.of(), true);
|
return new TenantConfigLoadResult(List.of(), true);
|
||||||
@@ -189,15 +189,15 @@ public class TenantDataSourceConfig {
|
|||||||
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||||
}
|
}
|
||||||
closeDataSource(previous == null ? null : previous.dataSource());
|
closeDataSource(previous == null ? null : previous.dataSource());
|
||||||
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception startupFailure) {
|
} catch (Exception startupFailure) {
|
||||||
if (requiredForReadiness) {
|
if (requiredForReadiness) {
|
||||||
readinessRegistry.markMigrationFailed(tenant);
|
readinessRegistry.markMigrationFailed(tenant);
|
||||||
}
|
}
|
||||||
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
|
||||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||||
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (!activated) {
|
if (!activated) {
|
||||||
@@ -211,9 +211,9 @@ public class TenantDataSourceConfig {
|
|||||||
try {
|
try {
|
||||||
hikariDataSource.close();
|
hikariDataSource.close();
|
||||||
} catch (RuntimeException closeFailure) {
|
} catch (RuntimeException closeFailure) {
|
||||||
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
|
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
|
||||||
closeFailure.getClass().getSimpleName());
|
closeFailure.getClass().getSimpleName());
|
||||||
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
|
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor {
|
|||||||
TenantContext.setCurrentTenant(tenant);
|
TenantContext.setCurrentTenant(tenant);
|
||||||
MDC.put("tenant.id", tenant);
|
MDC.put("tenant.id", tenant);
|
||||||
Span.current().setAttribute("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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, существует ли тенант
|
// Проверяем, существует ли тенант
|
||||||
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
|
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.setStatus(404);
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
|
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
|
||||||
@@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor {
|
|||||||
TenantContext.setCurrentTenant(tenant);
|
TenantContext.setCurrentTenant(tenant);
|
||||||
MDC.put("tenant.id", tenant);
|
MDC.put("tenant.id", tenant);
|
||||||
Span.current().setAttribute("tenant.id", tenant);
|
Span.current().setAttribute("tenant.id", tenant);
|
||||||
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
|
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
|||||||
try (Connection connection = tenantDataSource.get().getConnection()) {
|
try (Connection connection = tenantDataSource.get().getConnection()) {
|
||||||
return connection.isValid(5);
|
return connection.isValid(5);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
|
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
|
||||||
normalizeDomain(domain), e.getClass().getSimpleName());
|
normalizeDomain(domain), e.getClass().getSimpleName());
|
||||||
log.debug("Технические детали проверки подключения тенанта", e);
|
log.debug("Технические детали проверки подключения тенанта", e);
|
||||||
return false;
|
return false;
|
||||||
@@ -165,7 +165,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
|||||||
}
|
}
|
||||||
return "Подключение не валидно";
|
return "Подключение не валидно";
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
|
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
|
||||||
e.getClass().getSimpleName());
|
e.getClass().getSimpleName());
|
||||||
log.debug("Технические детали проверки внешнего подключения", e);
|
log.debug("Технические детали проверки внешнего подключения", e);
|
||||||
return "Не удалось подключиться к базе данных";
|
return "Не удалось подключиться к базе данных";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
|||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -41,6 +42,7 @@ public class TenantDatabaseHealthMonitor {
|
|||||||
private final ExecutorService executor;
|
private final ExecutorService executor;
|
||||||
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
|
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
public TenantDatabaseHealthMonitor(
|
public TenantDatabaseHealthMonitor(
|
||||||
TenantReadinessRegistry registry,
|
TenantReadinessRegistry registry,
|
||||||
TenantRoutingDataSource routingDataSource,
|
TenantRoutingDataSource routingDataSource,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
|||||||
import com.magistr.app.dto.*;
|
import com.magistr.app.dto.*;
|
||||||
import com.magistr.app.model.*;
|
import com.magistr.app.model.*;
|
||||||
import com.magistr.app.repository.*;
|
import com.magistr.app.repository.*;
|
||||||
|
import com.magistr.app.service.AcademicPeriodService;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -22,17 +23,20 @@ public class AcademicCalendarAdminController {
|
|||||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
private final AcademicPeriodService academicPeriodService;
|
||||||
|
|
||||||
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
||||||
SemesterRepository semesterRepository,
|
SemesterRepository semesterRepository,
|
||||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||||
AcademicCalendarDayRepository calendarDayRepository,
|
AcademicCalendarDayRepository calendarDayRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService) {
|
ScheduleGeneratorService scheduleGeneratorService,
|
||||||
|
AcademicPeriodService academicPeriodService) {
|
||||||
this.academicYearRepository = academicYearRepository;
|
this.academicYearRepository = academicYearRepository;
|
||||||
this.semesterRepository = semesterRepository;
|
this.semesterRepository = semesterRepository;
|
||||||
this.activityTypeRepository = activityTypeRepository;
|
this.activityTypeRepository = activityTypeRepository;
|
||||||
this.calendarDayRepository = calendarDayRepository;
|
this.calendarDayRepository = calendarDayRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
|
this.academicPeriodService = academicPeriodService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/years")
|
@GetMapping("/years")
|
||||||
@@ -45,43 +49,17 @@ public class AcademicCalendarAdminController {
|
|||||||
|
|
||||||
@PostMapping("/years")
|
@PostMapping("/years")
|
||||||
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
||||||
String validationError = validateYear(request);
|
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/years/{id}")
|
@PutMapping("/years/{id}")
|
||||||
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
||||||
AcademicYear year = academicYearRepository.findById(id).orElse(null);
|
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/years/{id}")
|
@DeleteMapping("/years/{id}")
|
||||||
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
||||||
if (!academicYearRepository.existsById(id)) {
|
academicPeriodService.deleteYear(id);
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
academicYearRepository.deleteById(id);
|
|
||||||
scheduleGeneratorService.clearCache();
|
|
||||||
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,39 +72,12 @@ public class AcademicCalendarAdminController {
|
|||||||
|
|
||||||
@PostMapping("/years/{academicYearId}/semesters")
|
@PostMapping("/years/{academicYearId}/semesters")
|
||||||
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
||||||
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
|
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/semesters/{id}")
|
@PutMapping("/semesters/{id}")
|
||||||
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
||||||
Semester semester = semesterRepository.findById(id).orElse(null);
|
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/activity-types")
|
@GetMapping("/activity-types")
|
||||||
@@ -185,32 +136,6 @@ public class AcademicCalendarAdminController {
|
|||||||
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
|
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) {
|
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
return "Передайте код активности";
|
return "Передайте код активности";
|
||||||
@@ -224,18 +149,6 @@ public class AcademicCalendarAdminController {
|
|||||||
return null;
|
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) {
|
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
|
||||||
activityType.setCode(request.code().trim());
|
activityType.setCode(request.code().trim());
|
||||||
activityType.setName(request.name().trim());
|
activityType.setName(request.name().trim());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.magistr.app.dto.*;
|
|||||||
import com.magistr.app.model.*;
|
import com.magistr.app.model.*;
|
||||||
import com.magistr.app.repository.*;
|
import com.magistr.app.repository.*;
|
||||||
import com.magistr.app.service.AcademicCalendarGridService;
|
import com.magistr.app.service.AcademicCalendarGridService;
|
||||||
|
import com.magistr.app.service.AcademicStructureService;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -32,6 +33,7 @@ public class AcademicCalendarController {
|
|||||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||||
private final AcademicCalendarGridService calendarGridService;
|
private final AcademicCalendarGridService calendarGridService;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
private final AcademicStructureService academicStructureService;
|
||||||
|
|
||||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||||
AcademicCalendarDayRepository calendarDayRepository,
|
AcademicCalendarDayRepository calendarDayRepository,
|
||||||
@@ -42,7 +44,8 @@ public class AcademicCalendarController {
|
|||||||
SubjectRepository subjectRepository,
|
SubjectRepository subjectRepository,
|
||||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||||
AcademicCalendarGridService calendarGridService,
|
AcademicCalendarGridService calendarGridService,
|
||||||
ScheduleGeneratorService scheduleGeneratorService) {
|
ScheduleGeneratorService scheduleGeneratorService,
|
||||||
|
AcademicStructureService academicStructureService) {
|
||||||
this.calendarRepository = calendarRepository;
|
this.calendarRepository = calendarRepository;
|
||||||
this.calendarDayRepository = calendarDayRepository;
|
this.calendarDayRepository = calendarDayRepository;
|
||||||
this.academicYearRepository = academicYearRepository;
|
this.academicYearRepository = academicYearRepository;
|
||||||
@@ -53,6 +56,7 @@ public class AcademicCalendarController {
|
|||||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||||
this.calendarGridService = calendarGridService;
|
this.calendarGridService = calendarGridService;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
|
this.academicStructureService = academicStructureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -85,17 +89,7 @@ public class AcademicCalendarController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
|
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
|
||||||
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
|
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@@ -103,14 +97,9 @@ public class AcademicCalendarController {
|
|||||||
if (!calendarRepository.existsById(id)) {
|
if (!calendarRepository.existsById(id)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
try {
|
calendarRepository.deleteById(id);
|
||||||
calendarRepository.deleteById(id);
|
scheduleGeneratorService.clearCache();
|
||||||
scheduleGeneratorService.clearCache();
|
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/grid")
|
@GetMapping("/{id}/grid")
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ package com.magistr.app.controller;
|
|||||||
import com.magistr.app.config.auth.AuthContext;
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.config.auth.JwtProperties;
|
import com.magistr.app.config.auth.JwtProperties;
|
||||||
import com.magistr.app.config.auth.JwtTokenService;
|
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.RefreshTokenRotation;
|
||||||
import com.magistr.app.config.auth.RefreshTokenService;
|
import com.magistr.app.config.auth.RefreshTokenService;
|
||||||
import com.magistr.app.config.tenant.TenantContext;
|
import com.magistr.app.config.tenant.TenantContext;
|
||||||
import com.magistr.app.dto.LoginRequest;
|
import com.magistr.app.dto.LoginRequest;
|
||||||
import com.magistr.app.dto.LoginResponse;
|
import com.magistr.app.dto.LoginResponse;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
import com.magistr.app.repository.UserRepository;
|
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseCookie;
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -27,8 +28,8 @@ import java.util.Optional;
|
|||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final LoginRateLimitService loginRateLimitService;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final ClientIpResolver clientIpResolver;
|
||||||
private final JwtTokenService jwtTokenService;
|
private final JwtTokenService jwtTokenService;
|
||||||
private final RefreshTokenService refreshTokenService;
|
private final RefreshTokenService refreshTokenService;
|
||||||
private final JwtProperties jwtProperties;
|
private final JwtProperties jwtProperties;
|
||||||
@@ -42,13 +43,13 @@ public class AuthController {
|
|||||||
"STUDENT", "/student/"
|
"STUDENT", "/student/"
|
||||||
);
|
);
|
||||||
|
|
||||||
public AuthController(UserRepository userRepository,
|
public AuthController(LoginRateLimitService loginRateLimitService,
|
||||||
BCryptPasswordEncoder passwordEncoder,
|
ClientIpResolver clientIpResolver,
|
||||||
JwtTokenService jwtTokenService,
|
JwtTokenService jwtTokenService,
|
||||||
RefreshTokenService refreshTokenService,
|
RefreshTokenService refreshTokenService,
|
||||||
JwtProperties jwtProperties) {
|
JwtProperties jwtProperties) {
|
||||||
this.userRepository = userRepository;
|
this.loginRateLimitService = loginRateLimitService;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.clientIpResolver = clientIpResolver;
|
||||||
this.jwtTokenService = jwtTokenService;
|
this.jwtTokenService = jwtTokenService;
|
||||||
this.refreshTokenService = refreshTokenService;
|
this.refreshTokenService = refreshTokenService;
|
||||||
this.jwtProperties = jwtProperties;
|
this.jwtProperties = jwtProperties;
|
||||||
@@ -56,28 +57,46 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
||||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
|
||||||
|
TenantContext.getCurrentTenant(),
|
||||||
if (userOpt.isEmpty() ||
|
request.getUsername(),
|
||||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
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
|
return ResponseEntity
|
||||||
.status(401)
|
.status(401)
|
||||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
.body(new LoginResponse(
|
||||||
|
false,
|
||||||
|
"Неверное имя пользователя или пароль",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = userOpt.get();
|
User user = decision.user();
|
||||||
if (user.isArchivedRecord()) {
|
|
||||||
return ResponseEntity
|
|
||||||
.status(401)
|
|
||||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
|
||||||
}
|
|
||||||
String tenant = TenantContext.getCurrentTenant();
|
String tenant = TenantContext.getCurrentTenant();
|
||||||
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
||||||
String refreshToken = refreshTokenService.createSession(
|
String refreshToken = refreshTokenService.createSession(
|
||||||
user,
|
user,
|
||||||
tenant,
|
tenant,
|
||||||
servletRequest.getHeader("User-Agent"),
|
servletRequest.getHeader("User-Agent"),
|
||||||
clientIp(servletRequest)
|
clientIpResolver.resolve(servletRequest)
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
@@ -96,7 +115,7 @@ public class AuthController {
|
|||||||
refreshToken.get(),
|
refreshToken.get(),
|
||||||
TenantContext.getCurrentTenant(),
|
TenantContext.getCurrentTenant(),
|
||||||
request.getHeader("User-Agent"),
|
request.getHeader("User-Agent"),
|
||||||
clientIp(request)
|
clientIpResolver.resolve(request)
|
||||||
);
|
);
|
||||||
if (rotation.isEmpty()) {
|
if (rotation.isEmpty()) {
|
||||||
return unauthorizedWithClearedCookie();
|
return unauthorizedWithClearedCookie();
|
||||||
@@ -192,11 +211,4 @@ public class AuthController {
|
|||||||
return Optional.empty();
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import com.magistr.app.model.Role;
|
|||||||
import com.magistr.app.repository.DepartmentRepository;
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -93,9 +92,8 @@ public class DepartmentController {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
|
logger.error("Ошибка при создании кафедры", e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
throw e;
|
||||||
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +146,8 @@ public class DepartmentController {
|
|||||||
department.getDepartmentCode()
|
department.getDepartmentCode()
|
||||||
));
|
));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
|
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
throw e;
|
||||||
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
|
|||||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import com.magistr.app.service.ScheduleQueryService;
|
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.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -40,6 +44,9 @@ public class DepartmentWorkspaceController {
|
|||||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||||
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
||||||
private final ScheduleQueryService scheduleQueryService;
|
private final ScheduleQueryService scheduleQueryService;
|
||||||
|
private final TeacherDepartmentService teacherDepartmentService;
|
||||||
|
private final SubjectImportService subjectImportService;
|
||||||
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||||
SubjectCommentRepository subjectCommentRepository,
|
SubjectCommentRepository subjectCommentRepository,
|
||||||
@@ -47,7 +54,26 @@ public class DepartmentWorkspaceController {
|
|||||||
DepartmentRepository departmentRepository,
|
DepartmentRepository departmentRepository,
|
||||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
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.subjectRepository = subjectRepository;
|
||||||
this.subjectCommentRepository = subjectCommentRepository;
|
this.subjectCommentRepository = subjectCommentRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -55,6 +81,9 @@ public class DepartmentWorkspaceController {
|
|||||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||||
this.scheduleQueryService = scheduleQueryService;
|
this.scheduleQueryService = scheduleQueryService;
|
||||||
|
this.teacherDepartmentService = teacherDepartmentService;
|
||||||
|
this.subjectImportService = subjectImportService;
|
||||||
|
this.businessTime = businessTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/subjects")
|
@GetMapping("/subjects")
|
||||||
@@ -77,19 +106,7 @@ public class DepartmentWorkspaceController {
|
|||||||
if (departmentId == null) {
|
if (departmentId == null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||||
}
|
}
|
||||||
List<Subject> saved = requests.stream()
|
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
|
||||||
.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();
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"message", "Дисциплины загружены",
|
"message", "Дисциплины загружены",
|
||||||
"count", saved.size()
|
"count", saved.size()
|
||||||
@@ -118,6 +135,7 @@ public class DepartmentWorkspaceController {
|
|||||||
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
|
||||||
}
|
}
|
||||||
SubjectComment comment = new SubjectComment();
|
SubjectComment comment = new SubjectComment();
|
||||||
|
comment.setCreatedAt(businessTime.now());
|
||||||
comment.setSubject(subject);
|
comment.setSubject(subject);
|
||||||
comment.setComment(commentText.trim());
|
comment.setComment(commentText.trim());
|
||||||
Long currentUserId = AuthContext.currentUserId();
|
Long currentUserId = AuthContext.currentUserId();
|
||||||
@@ -132,21 +150,19 @@ public class DepartmentWorkspaceController {
|
|||||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
if (effectiveDepartmentId == null) {
|
if (effectiveDepartmentId == null) {
|
||||||
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
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();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
return teacherDepartmentService
|
||||||
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
|
||||||
.stream()
|
.map(user -> toUserResponse(user, effectiveDepartmentId))
|
||||||
.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)
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +183,7 @@ public class DepartmentWorkspaceController {
|
|||||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
|
||||||
}
|
}
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = businessTime.today();
|
||||||
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
|
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
|
||||||
}
|
}
|
||||||
@@ -225,6 +241,8 @@ public class DepartmentWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
|
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
|
||||||
|
teacherRequest.setCreatedAt(businessTime.now());
|
||||||
|
teacherRequest.setUpdatedAt(businessTime.now());
|
||||||
teacherRequest.setDepartment(department);
|
teacherRequest.setDepartment(department);
|
||||||
teacherRequest.setUsername(username);
|
teacherRequest.setUsername(username);
|
||||||
teacherRequest.setFullName(fullName);
|
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(
|
UserResponse response = new UserResponse(
|
||||||
user.getId(),
|
user.getId(),
|
||||||
user.getUsername(),
|
user.getUsername(),
|
||||||
user.getRole().name(),
|
user.getRole().name(),
|
||||||
user.getFullName(),
|
user.getFullName(),
|
||||||
user.getJobTitle(),
|
user.getJobTitle(),
|
||||||
user.getDepartmentId()
|
departmentId
|
||||||
);
|
);
|
||||||
response.setStatus(user.getStatus());
|
response.setStatus(user.getStatus());
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/education-forms")
|
@RequestMapping("/api/education-forms")
|
||||||
@RequireRoles({Role.ADMIN})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public class EducationFormController {
|
public class EducationFormController {
|
||||||
|
|
||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.magistr.app.service.ScheduleConflictException;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
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.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.NoSuchElementException;
|
import java.util.NoSuchElementException;
|
||||||
@@ -20,6 +21,8 @@ import java.util.NoSuchElementException;
|
|||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
private final DatabaseConstraintViolationMapper constraintViolationMapper =
|
||||||
|
new DatabaseConstraintViolationMapper();
|
||||||
|
|
||||||
@ExceptionHandler(ScheduleConflictException.class)
|
@ExceptionHandler(ScheduleConflictException.class)
|
||||||
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
|
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
|
||||||
@@ -58,6 +61,23 @@ public class GlobalExceptionHandler {
|
|||||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
@@ -67,7 +87,7 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
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("status", status.value());
|
||||||
body.put("error", reason(status));
|
body.put("error", reason(status));
|
||||||
body.put("message", message);
|
body.put("message", message);
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
|||||||
import com.magistr.app.dto.CreateGroupRequest;
|
import com.magistr.app.dto.CreateGroupRequest;
|
||||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||||
import com.magistr.app.dto.GroupResponse;
|
import com.magistr.app.dto.GroupResponse;
|
||||||
import com.magistr.app.model.AcademicCalendar;
|
|
||||||
import com.magistr.app.model.AcademicCalendarSubject;
|
import com.magistr.app.model.AcademicCalendarSubject;
|
||||||
import com.magistr.app.model.AcademicYear;
|
|
||||||
import com.magistr.app.model.EducationForm;
|
import com.magistr.app.model.EducationForm;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Speciality;
|
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.StudentGroupCalendarAssignment;
|
||||||
import com.magistr.app.model.StudentGroupStudyState;
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
import com.magistr.app.repository.*;
|
import com.magistr.app.repository.*;
|
||||||
|
import com.magistr.app.service.AcademicStructureService;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
import com.magistr.app.service.StudentGroupLifecycleService;
|
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||||
|
import com.magistr.app.service.BusinessTimeService;
|
||||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -45,33 +46,49 @@ public class GroupController {
|
|||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
private final SpecialtiesRepository specialtiesRepository;
|
private final SpecialtiesRepository specialtiesRepository;
|
||||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||||
private final AcademicYearRepository academicYearRepository;
|
|
||||||
private final AcademicCalendarRepository academicCalendarRepository;
|
|
||||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
private final StudentGroupLifecycleService groupLifecycleService;
|
private final StudentGroupLifecycleService groupLifecycleService;
|
||||||
|
private final AcademicStructureService academicStructureService;
|
||||||
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public GroupController(GroupRepository groupRepository,
|
public GroupController(GroupRepository groupRepository,
|
||||||
EducationFormRepository educationFormRepository,
|
EducationFormRepository educationFormRepository,
|
||||||
SpecialtiesRepository specialtiesRepository,
|
SpecialtiesRepository specialtiesRepository,
|
||||||
SpecialtyProfileRepository specialtyProfileRepository,
|
SpecialtyProfileRepository specialtyProfileRepository,
|
||||||
AcademicYearRepository academicYearRepository,
|
|
||||||
AcademicCalendarRepository academicCalendarRepository,
|
|
||||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService,
|
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.groupRepository = groupRepository;
|
||||||
this.educationFormRepository = educationFormRepository;
|
this.educationFormRepository = educationFormRepository;
|
||||||
this.specialtiesRepository = specialtiesRepository;
|
this.specialtiesRepository = specialtiesRepository;
|
||||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||||
this.academicYearRepository = academicYearRepository;
|
|
||||||
this.academicCalendarRepository = academicCalendarRepository;
|
|
||||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||||
this.assignmentRepository = assignmentRepository;
|
this.assignmentRepository = assignmentRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
this.groupLifecycleService = groupLifecycleService;
|
this.groupLifecycleService = groupLifecycleService;
|
||||||
|
this.academicStructureService = academicStructureService;
|
||||||
|
this.businessTime = businessTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -82,7 +99,7 @@ public class GroupController {
|
|||||||
List<StudentGroup> groups = includeArchived
|
List<StudentGroup> groups = includeArchived
|
||||||
? groupRepository.findAll()
|
? groupRepository.findAll()
|
||||||
: groupRepository.findAll().stream()
|
: groupRepository.findAll().stream()
|
||||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<GroupResponse> response = groups.stream()
|
List<GroupResponse> response = groups.stream()
|
||||||
@@ -101,7 +118,7 @@ public class GroupController {
|
|||||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||||
try {
|
try {
|
||||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if(groups.isEmpty()) {
|
if(groups.isEmpty()) {
|
||||||
@@ -163,10 +180,9 @@ public class GroupController {
|
|||||||
logger.info("Группа успешно создана с ID - {}", group.getId());
|
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||||
|
|
||||||
return ResponseEntity.ok(mapToResponse(group));
|
return ResponseEntity.ok(mapToResponse(group));
|
||||||
} catch (Exception e ) {
|
} catch (Exception e) {
|
||||||
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
logger.error("Ошибка при создании группы", e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
throw e;
|
||||||
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,50 +190,9 @@ public class GroupController {
|
|||||||
@RequireRoles({Role.ADMIN})
|
@RequireRoles({Role.ADMIN})
|
||||||
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
||||||
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
||||||
try {
|
StudentGroup group = academicStructureService.updateGroup(id, request);
|
||||||
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
|
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||||
if (groupOpt.isEmpty()) {
|
return ResponseEntity.ok(mapToResponse(group));
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@@ -229,7 +204,7 @@ public class GroupController {
|
|||||||
logger.info("Группа с ID - {} не найдена", id);
|
logger.info("Группа с ID - {} не найдена", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
group.archive("Группа архивирована");
|
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
|
||||||
groupRepository.save(group);
|
groupRepository.save(group);
|
||||||
logger.info("Группа с ID - {} успешно архивирована", id);
|
logger.info("Группа с ID - {} успешно архивирована", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
||||||
@@ -278,11 +253,11 @@ public class GroupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private GroupResponse mapToResponse(StudentGroup group) {
|
private GroupResponse mapToResponse(StudentGroup group) {
|
||||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
|
LocalDate today = businessTime.today();
|
||||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
|
||||||
|
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
|
||||||
Speciality speciality = group.getSpeciality();
|
Speciality speciality = group.getSpeciality();
|
||||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||||
LocalDate today = LocalDate.now();
|
|
||||||
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||||
return new GroupResponse(
|
return new GroupResponse(
|
||||||
group.getId(),
|
group.getId(),
|
||||||
@@ -323,42 +298,9 @@ public class GroupController {
|
|||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
||||||
@RequestBody GroupCalendarAssignmentDto request) {
|
@RequestBody GroupCalendarAssignmentDto request) {
|
||||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
|
||||||
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);
|
|
||||||
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
||||||
.findByCalendarIdWithSubject(calendar.getId())
|
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toCalendarSubjectDto)
|
.map(this::toCalendarSubjectDto)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository;
|
|||||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -36,6 +35,7 @@ public class SpecialityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
logger.info("Получен запрос на получение списка специальностей");
|
logger.info("Получен запрос на получение списка специальностей");
|
||||||
try {
|
try {
|
||||||
@@ -102,9 +102,8 @@ public class SpecialityController {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
|
logger.error("Ошибка при создании специальности", e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
throw e;
|
||||||
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +157,8 @@ public class SpecialityController {
|
|||||||
speciality.getSpecialityCode()
|
speciality.getSpecialityCode()
|
||||||
));
|
));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
|
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
throw e;
|
||||||
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +192,7 @@ public class SpecialityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/profiles")
|
@GetMapping("/profiles")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public List<SpecialtyProfileDto> getAllProfiles() {
|
public List<SpecialtyProfileDto> getAllProfiles() {
|
||||||
logger.info("Получен запрос на получение всех профилей обучения");
|
logger.info("Получен запрос на получение всех профилей обучения");
|
||||||
return specialtyProfileRepository.findAll().stream()
|
return specialtyProfileRepository.findAll().stream()
|
||||||
@@ -204,6 +203,7 @@ public class SpecialityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{specialtyId}/profiles")
|
@GetMapping("/{specialtyId}/profiles")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
||||||
if (!specialtiesRepository.existsById(specialtyId)) {
|
if (!specialtiesRepository.existsById(specialtyId)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -255,14 +255,8 @@ public class SpecialityController {
|
|||||||
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
try {
|
specialtyProfileRepository.delete(profile);
|
||||||
specialtyProfileRepository.delete(profile);
|
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
|
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.magistr.app.repository.GroupRepository;
|
|||||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||||
import com.magistr.app.repository.SubgroupRepository;
|
import com.magistr.app.repository.SubgroupRepository;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -125,14 +124,10 @@ public class SubgroupController {
|
|||||||
if (deleteValidationError != null) {
|
if (deleteValidationError != null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
|
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
|
||||||
}
|
}
|
||||||
try {
|
subgroup.archive("Подгруппа архивирована");
|
||||||
subgroup.archive("Подгруппа архивирована");
|
subgroupRepository.save(subgroup);
|
||||||
subgroupRepository.save(subgroup);
|
scheduleGeneratorService.clearCache();
|
||||||
scheduleGeneratorService.clearCache();
|
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
|
||||||
} catch (DataIntegrityViolationException e) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String validate(SubgroupDto request) {
|
private String validate(SubgroupDto request) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.HttpStatusCode;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ public class SubjectController {
|
|||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", 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 = "Дисциплина с таким названием уже существует";
|
String errorMessage = "Дисциплина с таким названием уже существует";
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
@@ -119,10 +118,9 @@ public class SubjectController {
|
|||||||
subject.getDepartmentId()
|
subject.getDepartmentId()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (Exception e){
|
} catch (Exception e) {
|
||||||
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
logger.error("Ошибка при создании дисциплины", e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
throw e;
|
||||||
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository;
|
|||||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import com.magistr.app.service.BusinessTimeService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -30,17 +29,20 @@ public class TeacherCreationRequestController {
|
|||||||
private final DepartmentRepository departmentRepository;
|
private final DepartmentRepository departmentRepository;
|
||||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
|
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
DepartmentRepository departmentRepository,
|
DepartmentRepository departmentRepository,
|
||||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||||
BCryptPasswordEncoder passwordEncoder) {
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
|
BusinessTimeService businessTime) {
|
||||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.departmentRepository = departmentRepository;
|
this.departmentRepository = departmentRepository;
|
||||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.businessTime = businessTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -98,7 +100,8 @@ public class TeacherCreationRequestController {
|
|||||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||||
assignment.setTeacher(teacher);
|
assignment.setTeacher(teacher);
|
||||||
assignment.setDepartment(department);
|
assignment.setDepartment(department);
|
||||||
assignment.setValidFrom(LocalDate.now());
|
assignment.setValidFrom(businessTime.today());
|
||||||
|
assignment.setCreatedAt(businessTime.now());
|
||||||
assignment.setPrimaryAssignment(true);
|
assignment.setPrimaryAssignment(true);
|
||||||
assignment.setComment("Создан по заявке кафедры #" + request.getId());
|
assignment.setComment("Создан по заявке кафедры #" + request.getId());
|
||||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||||
@@ -110,7 +113,7 @@ public class TeacherCreationRequestController {
|
|||||||
request.setDepartment(department);
|
request.setDepartment(department);
|
||||||
request.setStatus(TeacherCreationRequestStatus.APPROVED);
|
request.setStatus(TeacherCreationRequestStatus.APPROVED);
|
||||||
request.setReviewedBy(AuthContext.currentUserId());
|
request.setReviewedBy(AuthContext.currentUserId());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(businessTime.now());
|
||||||
request.setReviewComment(trimToNull(review.getReviewComment()));
|
request.setReviewComment(trimToNull(review.getReviewComment()));
|
||||||
request.setCreatedTeacherId(teacher.getId());
|
request.setCreatedTeacherId(teacher.getId());
|
||||||
teacherCreationRequestRepository.save(request);
|
teacherCreationRequestRepository.save(request);
|
||||||
@@ -132,7 +135,7 @@ public class TeacherCreationRequestController {
|
|||||||
}
|
}
|
||||||
request.setStatus(TeacherCreationRequestStatus.REJECTED);
|
request.setStatus(TeacherCreationRequestStatus.REJECTED);
|
||||||
request.setReviewedBy(AuthContext.currentUserId());
|
request.setReviewedBy(AuthContext.currentUserId());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(businessTime.now());
|
||||||
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
|
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
|
||||||
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
|
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import com.magistr.app.model.User;
|
|||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -25,13 +28,28 @@ public class TeacherSubjectController {
|
|||||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final SubjectRepository subjectRepository;
|
private final SubjectRepository subjectRepository;
|
||||||
|
private final TeacherDepartmentService teacherDepartmentService;
|
||||||
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||||
UserRepository userRepository,
|
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.teacherSubjectRepository = teacherSubjectRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.subjectRepository = subjectRepository;
|
this.subjectRepository = subjectRepository;
|
||||||
|
this.teacherDepartmentService = teacherDepartmentService;
|
||||||
|
this.businessTime = businessTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -111,7 +129,11 @@ public class TeacherSubjectController {
|
|||||||
return departmentId != null
|
return departmentId != null
|
||||||
&& teacher != null
|
&& teacher != null
|
||||||
&& subject != null
|
&& subject != null
|
||||||
&& departmentId.equals(teacher.getDepartmentId())
|
&& teacherDepartmentService.hasAssignmentAtDate(
|
||||||
|
teacher.getId(),
|
||||||
|
departmentId,
|
||||||
|
businessTime.today()
|
||||||
|
)
|
||||||
&& departmentId.equals(subject.getDepartmentId());
|
&& departmentId.equals(subject.getDepartmentId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import com.magistr.app.model.Role;
|
|||||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||||
import com.magistr.app.repository.TimeSlotRepository;
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -35,13 +34,16 @@ public class TimeSlotAdminController {
|
|||||||
private final TimeSlotRepository timeSlotRepository;
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
|
private final TimeSlotService timeSlotService;
|
||||||
|
|
||||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||||
|
TimeSlotService timeSlotService) {
|
||||||
this.timeSlotRepository = timeSlotRepository;
|
this.timeSlotRepository = timeSlotRepository;
|
||||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||||
|
this.timeSlotService = timeSlotService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -151,83 +153,18 @@ public class TimeSlotAdminController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||||
String validationError = validate(request);
|
return ResponseEntity.ok(toDto(timeSlotService.create(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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
||||||
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
|
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||||
if (!timeSlotRepository.existsById(id)) {
|
timeSlotService.delete(id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||||
|
|||||||
@@ -14,20 +14,20 @@ import com.magistr.app.model.User;
|
|||||||
import com.magistr.app.repository.DepartmentRepository;
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/users")
|
@RequestMapping("/api/users")
|
||||||
@@ -38,16 +38,33 @@ public class UserController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final DepartmentRepository departmentRepository;
|
private final DepartmentRepository departmentRepository;
|
||||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||||
|
private final TeacherDepartmentService teacherDepartmentService;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public UserController(UserRepository userRepository,
|
public UserController(UserRepository userRepository,
|
||||||
BCryptPasswordEncoder passwordEncoder,
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
DepartmentRepository departmentRepository,
|
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.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.departmentRepository = departmentRepository;
|
this.departmentRepository = departmentRepository;
|
||||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||||
|
this.teacherDepartmentService = teacherDepartmentService;
|
||||||
|
this.businessTime = businessTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -82,7 +99,10 @@ public class UserController {
|
|||||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||||
}
|
}
|
||||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||||
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
|
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
|
||||||
|
departmentId,
|
||||||
|
businessTime.today()
|
||||||
|
);
|
||||||
|
|
||||||
if (users.isEmpty()) {
|
if (users.isEmpty()) {
|
||||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||||
@@ -92,7 +112,7 @@ public class UserController {
|
|||||||
|
|
||||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||||
return ResponseEntity.ok(users.stream()
|
return ResponseEntity.ok(users.stream()
|
||||||
.map(this::toUserResponse)
|
.map(user -> toUserResponse(user, departmentId))
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +181,7 @@ public class UserController {
|
|||||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||||
assignment.setTeacher(user);
|
assignment.setTeacher(user);
|
||||||
assignment.setDepartment(requestedDepartment);
|
assignment.setDepartment(requestedDepartment);
|
||||||
assignment.setValidFrom(LocalDate.now());
|
assignment.setValidFrom(businessTime.today());
|
||||||
assignment.setPrimaryAssignment(true);
|
assignment.setPrimaryAssignment(true);
|
||||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||||
@@ -180,7 +200,7 @@ public class UserController {
|
|||||||
logger.info("Пользователь с ID - {} не найден", id);
|
logger.info("Пользователь с ID - {} не найден", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
user.archive("Пользователь архивирован");
|
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
||||||
@@ -206,7 +226,11 @@ public class UserController {
|
|||||||
var currentUser = AuthContext.getCurrentUser();
|
var currentUser = AuthContext.getCurrentUser();
|
||||||
if (currentUser != null
|
if (currentUser != null
|
||||||
&& currentUser.role() == Role.DEPARTMENT
|
&& currentUser.role() == Role.DEPARTMENT
|
||||||
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
|
&& !teacherDepartmentService.hasAssignmentAtDate(
|
||||||
|
id,
|
||||||
|
currentUser.departmentId(),
|
||||||
|
businessTime.today()
|
||||||
|
)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
|
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
|
||||||
}
|
}
|
||||||
@@ -216,46 +240,13 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/department-transfer")
|
@PostMapping("/{id}/department-transfer")
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
|
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
|
||||||
User teacher = userRepository.findById(id).orElse(null);
|
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
|
||||||
if (teacher == null || teacher.getRole() != Role.TEACHER) {
|
id,
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
request,
|
||||||
}
|
businessTime.today(),
|
||||||
if (teacher.isArchivedRecord()) {
|
AuthContext.currentUserId()
|
||||||
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);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(toAssignmentDto(assignment));
|
return ResponseEntity.ok(toAssignmentDto(assignment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,16 +259,25 @@ public class UserController {
|
|||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||||
}
|
}
|
||||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
LocalDate effectiveDate = date == null ? businessTime.today() : date;
|
||||||
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
|
return ResponseEntity.ok(teacherDepartmentService
|
||||||
.map(this::toUserResponse)
|
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
|
||||||
|
.map(user -> toUserResponse(user, departmentId))
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserResponse toUserResponse(User user) {
|
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
|
? null
|
||||||
: departmentRepository.findById(user.getDepartmentId())
|
: departmentRepository.findById(departmentId)
|
||||||
.map(Department::getDepartmentName)
|
.map(Department::getDepartmentName)
|
||||||
.orElse("Неизвестно");
|
.orElse("Неизвестно");
|
||||||
UserResponse response = new UserResponse(
|
UserResponse response = new UserResponse(
|
||||||
@@ -288,7 +288,7 @@ public class UserController {
|
|||||||
user.getJobTitle(),
|
user.getJobTitle(),
|
||||||
departmentName
|
departmentName
|
||||||
);
|
);
|
||||||
response.setDepartmentId(user.getDepartmentId());
|
response.setDepartmentId(departmentId);
|
||||||
response.setStatus(user.getStatus());
|
response.setStatus(user.getStatus());
|
||||||
return response;
|
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) {
|
private boolean canAccessDepartment(Long departmentId) {
|
||||||
var currentUser = AuthContext.getCurrentUser();
|
var currentUser = AuthContext.getCurrentUser();
|
||||||
return currentUser == null
|
return currentUser == null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.ClassroomResponse;
|
import com.magistr.app.dto.ClassroomResponse;
|
||||||
import com.magistr.app.dto.RenderedLessonDto;
|
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.model.*;
|
||||||
import com.magistr.app.repository.ClassroomRepository;
|
import com.magistr.app.repository.ClassroomRepository;
|
||||||
import com.magistr.app.repository.DepartmentRepository;
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
|
||||||
import com.magistr.app.service.ScheduleQueryService;
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
|
import com.magistr.app.service.TeacherDepartmentService;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -27,17 +30,17 @@ public class WorkloadController {
|
|||||||
|
|
||||||
private final ScheduleQueryService scheduleQueryService;
|
private final ScheduleQueryService scheduleQueryService;
|
||||||
private final ClassroomRepository classroomRepository;
|
private final ClassroomRepository classroomRepository;
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final DepartmentRepository departmentRepository;
|
private final DepartmentRepository departmentRepository;
|
||||||
|
private final TeacherDepartmentService teacherDepartmentService;
|
||||||
|
|
||||||
public WorkloadController(ScheduleQueryService scheduleQueryService,
|
public WorkloadController(ScheduleQueryService scheduleQueryService,
|
||||||
ClassroomRepository classroomRepository,
|
ClassroomRepository classroomRepository,
|
||||||
UserRepository userRepository,
|
DepartmentRepository departmentRepository,
|
||||||
DepartmentRepository departmentRepository) {
|
TeacherDepartmentService teacherDepartmentService) {
|
||||||
this.scheduleQueryService = scheduleQueryService;
|
this.scheduleQueryService = scheduleQueryService;
|
||||||
this.classroomRepository = classroomRepository;
|
this.classroomRepository = classroomRepository;
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.departmentRepository = departmentRepository;
|
this.departmentRepository = departmentRepository;
|
||||||
|
this.teacherDepartmentService = teacherDepartmentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
@@ -46,19 +49,11 @@ public class WorkloadController {
|
|||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||||
@RequestParam(required = false) Long departmentId
|
@RequestParam(required = false) Long departmentId
|
||||||
) {
|
) {
|
||||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
null, null, null, null, startDate, endDate);
|
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
effectiveDepartmentId, null, startDate, endDate
|
||||||
.map(RenderedLessonDto::teacherId)
|
);
|
||||||
.filter(Objects::nonNull)
|
return summarizeTeacherWorkload(lessons, startDate, endDate);
|
||||||
.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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/classrooms")
|
@GetMapping("/classrooms")
|
||||||
@@ -67,8 +62,10 @@ public class WorkloadController {
|
|||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||||
@RequestParam(required = false) Long departmentId
|
@RequestParam(required = false) Long departmentId
|
||||||
) {
|
) {
|
||||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
null, null, null, null, startDate, endDate);
|
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||||
|
effectiveDepartmentId, null, startDate, endDate
|
||||||
|
);
|
||||||
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
|
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 @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||||
@RequestParam(required = false) Long departmentId
|
@RequestParam(required = false) Long departmentId
|
||||||
) {
|
) {
|
||||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
null, null, null, null, startDate, endDate);
|
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||||
|
effectiveDepartmentId, null, startDate, endDate
|
||||||
|
);
|
||||||
Map<Long, String> departments = departmentRepository.findAll().stream()
|
Map<Long, String> departments = departmentRepository.findAll().stream()
|
||||||
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
|
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
|
||||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||||
.map(RenderedLessonDto::teacherId)
|
assignmentsByTeacher(lessons, startDate, endDate);
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toSet()))
|
|
||||||
.stream()
|
|
||||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
|
||||||
|
|
||||||
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
|
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
|
||||||
.collect(Collectors.groupingBy(lesson -> {
|
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
|
||||||
User teacher = teachersById.get(lesson.teacherId());
|
lesson,
|
||||||
return teacher == null ? 0L : teacher.getDepartmentId();
|
assignmentsByTeacher
|
||||||
}));
|
).orElse(0L)));
|
||||||
|
|
||||||
return byDepartment.entrySet().stream()
|
return byDepartment.entrySet().stream()
|
||||||
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
|
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
|
||||||
@@ -101,14 +96,76 @@ public class WorkloadController {
|
|||||||
.toList();
|
.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")
|
@GetMapping("/time-slots")
|
||||||
public List<WorkloadSummaryDto> timeSlotWorkload(
|
public List<WorkloadSummaryDto> timeSlotWorkload(
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||||
@RequestParam(required = false) Long departmentId
|
@RequestParam(required = false) Long departmentId
|
||||||
) {
|
) {
|
||||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
null, null, null, null, startDate, endDate);
|
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||||
|
effectiveDepartmentId, null, startDate, endDate
|
||||||
|
);
|
||||||
return summarize(lessons, RenderedLessonDto::timeSlotId,
|
return summarize(lessons, RenderedLessonDto::timeSlotId,
|
||||||
lesson -> lesson.timeSlotOrder() == null
|
lesson -> lesson.timeSlotOrder() == null
|
||||||
? "Пара"
|
? "Пара"
|
||||||
@@ -121,8 +178,10 @@ public class WorkloadController {
|
|||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||||
@RequestParam Long timeSlotId
|
@RequestParam Long timeSlotId
|
||||||
) {
|
) {
|
||||||
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
|
Long effectiveDepartmentId = effectiveDepartmentId(null);
|
||||||
null, null, timeSlotId, null, date, date)
|
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
|
||||||
|
effectiveDepartmentId, timeSlotId, date, date
|
||||||
|
)
|
||||||
.stream()
|
.stream()
|
||||||
.map(RenderedLessonDto::classroomId)
|
.map(RenderedLessonDto::classroomId)
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
@@ -166,6 +225,9 @@ public class WorkloadController {
|
|||||||
if (departmentId == null) {
|
if (departmentId == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (departmentId == 0L) {
|
||||||
|
return "Кафедра не указана";
|
||||||
|
}
|
||||||
return departmentRepository.findById(departmentId)
|
return departmentRepository.findById(departmentId)
|
||||||
.map(Department::getDepartmentName)
|
.map(Department::getDepartmentName)
|
||||||
.orElse("Кафедра не найдена");
|
.orElse("Кафедра не найдена");
|
||||||
@@ -185,4 +247,35 @@ public class WorkloadController {
|
|||||||
new ArrayList<>(classroom.getEquipments())
|
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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
import com.magistr.app.model.Equipment;
|
import com.magistr.app.model.Equipment;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class ClassroomResponse {
|
public class ClassroomResponse {
|
||||||
@@ -12,7 +12,7 @@ public class ClassroomResponse {
|
|||||||
private Integer floor;
|
private Integer floor;
|
||||||
private Boolean isAvailable;
|
private Boolean isAvailable;
|
||||||
private String status;
|
private String status;
|
||||||
private LocalDateTime archivedAt;
|
private Instant archivedAt;
|
||||||
private String archiveReason;
|
private String archiveReason;
|
||||||
private List<Equipment> equipments;
|
private List<Equipment> equipments;
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ public class ClassroomResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
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) {
|
String archiveReason, List<Equipment> equipments) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -95,11 +95,11 @@ public class ClassroomResponse {
|
|||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getArchivedAt() {
|
public Instant getArchivedAt() {
|
||||||
return archivedAt;
|
return archivedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
public void setArchivedAt(Instant archivedAt) {
|
||||||
this.archivedAt = archivedAt;
|
this.archivedAt = archivedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record ScheduleOverrideDto(
|
public record ScheduleOverrideDto(
|
||||||
Long id,
|
Long id,
|
||||||
@@ -14,6 +14,6 @@ public record ScheduleOverrideDto(
|
|||||||
String newLessonFormat,
|
String newLessonFormat,
|
||||||
String comment,
|
String comment,
|
||||||
Long createdBy,
|
Long createdBy,
|
||||||
LocalDateTime createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record SubjectCommentDto(
|
public record SubjectCommentDto(
|
||||||
Long id,
|
Long id,
|
||||||
@@ -8,6 +8,6 @@ public record SubjectCommentDto(
|
|||||||
Long authorId,
|
Long authorId,
|
||||||
String authorName,
|
String authorName,
|
||||||
String comment,
|
String comment,
|
||||||
LocalDateTime createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record TeacherCreationRequestResponse(
|
public record TeacherCreationRequestResponse(
|
||||||
Long id,
|
Long id,
|
||||||
@@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse(
|
|||||||
String comment,
|
String comment,
|
||||||
String status,
|
String status,
|
||||||
Long requestedBy,
|
Long requestedBy,
|
||||||
LocalDateTime createdAt,
|
Instant createdAt,
|
||||||
Long reviewedBy,
|
Long reviewedBy,
|
||||||
LocalDateTime reviewedAt,
|
Instant reviewedAt,
|
||||||
String reviewComment,
|
String reviewComment,
|
||||||
Long createdTeacherId
|
Long createdTeacherId
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "auth_refresh_tokens")
|
@Table(name = "auth_refresh_tokens")
|
||||||
@@ -31,13 +31,13 @@ public class AuthRefreshToken {
|
|||||||
private String tokenHash;
|
private String tokenHash;
|
||||||
|
|
||||||
@Column(name = "issued_at", nullable = false)
|
@Column(name = "issued_at", nullable = false)
|
||||||
private LocalDateTime issuedAt;
|
private Instant issuedAt;
|
||||||
|
|
||||||
@Column(name = "expires_at", nullable = false)
|
@Column(name = "expires_at", nullable = false)
|
||||||
private LocalDateTime expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
@Column(name = "revoked_at")
|
@Column(name = "revoked_at")
|
||||||
private LocalDateTime revokedAt;
|
private Instant revokedAt;
|
||||||
|
|
||||||
@Column(name = "rotated_to_token_hash", length = 64)
|
@Column(name = "rotated_to_token_hash", length = 64)
|
||||||
private String rotatedToTokenHash;
|
private String rotatedToTokenHash;
|
||||||
@@ -80,27 +80,27 @@ public class AuthRefreshToken {
|
|||||||
this.tokenHash = tokenHash;
|
this.tokenHash = tokenHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getIssuedAt() {
|
public Instant getIssuedAt() {
|
||||||
return issuedAt;
|
return issuedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setIssuedAt(LocalDateTime issuedAt) {
|
public void setIssuedAt(Instant issuedAt) {
|
||||||
this.issuedAt = issuedAt;
|
this.issuedAt = issuedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getExpiresAt() {
|
public Instant getExpiresAt() {
|
||||||
return expiresAt;
|
return expiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
public void setExpiresAt(Instant expiresAt) {
|
||||||
this.expiresAt = expiresAt;
|
this.expiresAt = expiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getRevokedAt() {
|
public Instant getRevokedAt() {
|
||||||
return revokedAt;
|
return revokedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setRevokedAt(LocalDateTime revokedAt) {
|
public void setRevokedAt(Instant revokedAt) {
|
||||||
this.revokedAt = revokedAt;
|
this.revokedAt = revokedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ public class AuthRefreshToken {
|
|||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isActive(LocalDateTime now) {
|
public boolean isActive(Instant now) {
|
||||||
return revokedAt == null && expiresAt.isAfter(now);
|
return revokedAt == null && expiresAt.isAfter(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import jakarta.persistence.Column;
|
|||||||
import jakarta.persistence.MappedSuperclass;
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
@MappedSuperclass
|
@MappedSuperclass
|
||||||
public abstract class LifecycleEntity {
|
public abstract class LifecycleEntity {
|
||||||
@@ -23,7 +24,7 @@ public abstract class LifecycleEntity {
|
|||||||
private LocalDate activeTo;
|
private LocalDate activeTo;
|
||||||
|
|
||||||
@Column(name = "archived_at")
|
@Column(name = "archived_at")
|
||||||
private LocalDateTime archivedAt;
|
private Instant archivedAt;
|
||||||
|
|
||||||
@Column(name = "archive_reason")
|
@Column(name = "archive_reason")
|
||||||
private String archiveReason;
|
private String archiveReason;
|
||||||
@@ -52,11 +53,11 @@ public abstract class LifecycleEntity {
|
|||||||
this.activeTo = activeTo;
|
this.activeTo = activeTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getArchivedAt() {
|
public Instant getArchivedAt() {
|
||||||
return archivedAt;
|
return archivedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
public void setArchivedAt(Instant archivedAt) {
|
||||||
this.archivedAt = archivedAt;
|
this.archivedAt = archivedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +93,13 @@ public abstract class LifecycleEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void archive(String reason) {
|
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;
|
status = STATUS_ARCHIVED;
|
||||||
archivedAt = LocalDateTime.now();
|
archivedAt = archiveMoment;
|
||||||
activeTo = LocalDate.now();
|
activeTo = businessDate;
|
||||||
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
|
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "schedule_overrides")
|
@Table(name = "schedule_overrides")
|
||||||
@@ -45,7 +45,7 @@ public class ScheduleOverride {
|
|||||||
private Long createdBy;
|
private Long createdBy;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt = LocalDateTime.now();
|
private Instant createdAt = Instant.now();
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -123,11 +123,11 @@ public class ScheduleOverride {
|
|||||||
this.createdBy = createdBy;
|
this.createdBy = createdBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public void setCreatedAt(Instant createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(unique = true, nullable = false, length = 200)
|
@Column(nullable = false, length = 200)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "code")
|
@Column(name = "code")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
|||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "subject_comments")
|
@Table(name = "subject_comments")
|
||||||
@@ -24,7 +24,7 @@ public class SubjectComment {
|
|||||||
private String comment;
|
private String comment;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt = LocalDateTime.now();
|
private Instant createdAt = Instant.now();
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -54,11 +54,11 @@ public class SubjectComment {
|
|||||||
this.comment = comment;
|
this.comment = comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public void setCreatedAt(Instant createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
|||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "teacher_creation_requests")
|
@Table(name = "teacher_creation_requests")
|
||||||
@@ -36,16 +36,16 @@ public class TeacherCreationRequest {
|
|||||||
private Long requestedBy;
|
private Long requestedBy;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt = LocalDateTime.now();
|
private Instant createdAt = Instant.now();
|
||||||
|
|
||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
private LocalDateTime updatedAt = LocalDateTime.now();
|
private Instant updatedAt = Instant.now();
|
||||||
|
|
||||||
@Column(name = "reviewed_by")
|
@Column(name = "reviewed_by")
|
||||||
private Long reviewedBy;
|
private Long reviewedBy;
|
||||||
|
|
||||||
@Column(name = "reviewed_at")
|
@Column(name = "reviewed_at")
|
||||||
private LocalDateTime reviewedAt;
|
private Instant reviewedAt;
|
||||||
|
|
||||||
@Column(name = "review_comment", columnDefinition = "TEXT")
|
@Column(name = "review_comment", columnDefinition = "TEXT")
|
||||||
private String reviewComment;
|
private String reviewComment;
|
||||||
@@ -55,7 +55,7 @@ public class TeacherCreationRequest {
|
|||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
public void touch() {
|
public void touch() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -118,19 +118,19 @@ public class TeacherCreationRequest {
|
|||||||
this.requestedBy = requestedBy;
|
this.requestedBy = requestedBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public void setCreatedAt(Instant createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getUpdatedAt() {
|
public Instant getUpdatedAt() {
|
||||||
return updatedAt;
|
return updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
public void setUpdatedAt(Instant updatedAt) {
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +142,11 @@ public class TeacherCreationRequest {
|
|||||||
this.reviewedBy = reviewedBy;
|
this.reviewedBy = reviewedBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getReviewedAt() {
|
public Instant getReviewedAt() {
|
||||||
return reviewedAt;
|
return reviewedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setReviewedAt(LocalDateTime reviewedAt) {
|
public void setReviewedAt(Instant reviewedAt) {
|
||||||
this.reviewedAt = reviewedAt;
|
this.reviewedAt = reviewedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "teacher_department_assignments")
|
@Table(name = "teacher_department_assignments")
|
||||||
@@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment {
|
|||||||
private String comment;
|
private String comment;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt = LocalDateTime.now();
|
private Instant createdAt = Instant.now();
|
||||||
|
|
||||||
@Column(name = "created_by")
|
@Column(name = "created_by")
|
||||||
private Long createdBy;
|
private Long createdBy;
|
||||||
@@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment {
|
|||||||
this.comment = comment;
|
this.comment = comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public void setCreatedAt(Instant createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCal
|
|||||||
@Param("date") LocalDate date
|
@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 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
|
@Modifying
|
||||||
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
|
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
|
||||||
void deleteByCalendarId(@Param("calendarId") Long calendarId);
|
void deleteByCalendarId(@Param("calendarId") Long calendarId);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.AcademicCalendar;
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
@@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
|||||||
where calendar.id = :id
|
where calendar.id = :id
|
||||||
""")
|
""")
|
||||||
Optional<AcademicCalendar> findByIdWithDetails(@Param("id") Long 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository<Academi
|
|||||||
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
||||||
|
|
||||||
void deleteByAcademicCalendar_Id(Long calendarId);
|
void deleteByAcademicCalendar_Id(Long calendarId);
|
||||||
|
|
||||||
|
boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.AcademicYear;
|
import com.magistr.app.model.AcademicYear;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
|
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
|
||||||
|
|
||||||
Optional<AcademicYear> findByTitle(String title);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ import com.magistr.app.model.AuthRefreshToken;
|
|||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
|
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
|
||||||
@@ -21,4 +24,27 @@ public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshTok
|
|||||||
WHERE token.tokenHash = :tokenHash
|
WHERE token.tokenHash = :tokenHash
|
||||||
""")
|
""")
|
||||||
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
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> findByStatusNot(String status);
|
||||||
|
|
||||||
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||||
|
|
||||||
@@ -60,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
@Param("semesterId") Long semesterId
|
@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("""
|
@Query("""
|
||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
|
|||||||
@@ -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")
|
@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 existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||||
|
|
||||||
|
boolean existsByTimeSlotId(Long timeSlotId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,51 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
|
|||||||
|
|
||||||
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
|
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);
|
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
|
||||||
|
|
||||||
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
|
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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select semester from Semester semester where semester.id = :id")
|
@Query("select semester from Semester semester where semester.id = :id")
|
||||||
Optional<Semester> findByIdForUpdate(@Param("id") Long id);
|
Optional<Semester> findByIdForUpdate(@Param("id") Long id);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<StudentGroupCalendarAssignment, Long> {
|
public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<StudentGroupCalendarAssignment, Long> {
|
||||||
|
|
||||||
@@ -51,4 +54,52 @@ public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<
|
|||||||
@Param("groupId") Long groupId,
|
@Param("groupId") Long groupId,
|
||||||
@Param("academicYearId") Long academicYearId
|
@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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||||
Optional<Subject> findByName(String name);
|
Optional<Subject> findByNameIgnoreCase(String name);
|
||||||
|
|
||||||
List<Subject> findByDepartmentId(Long departmentId);
|
List<Subject> findByDepartmentId(Long departmentId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
|
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
|
||||||
|
|
||||||
@@ -60,4 +63,46 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
|
|||||||
@Param("departmentId") Long departmentId,
|
@Param("departmentId") Long departmentId,
|
||||||
@Param("date") LocalDate date
|
@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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.magistr.app.repository;
|
|||||||
|
|
||||||
import com.magistr.app.model.TimeSlotDateAssignment;
|
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -15,5 +17,15 @@ public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlot
|
|||||||
|
|
||||||
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
|
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);
|
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.TimeSlot;
|
import com.magistr.app.model.TimeSlot;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -14,5 +20,45 @@ public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
|
|||||||
|
|
||||||
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
|
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
import com.magistr.app.model.TimeSlotScope;
|
import com.magistr.app.model.TimeSlotScope;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -15,4 +19,10 @@ public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Lo
|
|||||||
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
||||||
|
|
||||||
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package com.magistr.app.repository;
|
|||||||
|
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -20,4 +24,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
List<User> findByRoleAndStatusNot(Role role, String status);
|
List<User> findByRoleAndStatusNot(Role role, String status);
|
||||||
|
|
||||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Optional;
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AcademicDateService {
|
public class AcademicDateService {
|
||||||
@@ -33,6 +35,64 @@ public class AcademicDateService {
|
|||||||
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date);
|
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) {
|
public int getWeekNumber(Semester semester, LocalDate date) {
|
||||||
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
|
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
|
||||||
if (days < 0) {
|
if (days < 0) {
|
||||||
@@ -86,4 +146,128 @@ public class AcademicDateService {
|
|||||||
}
|
}
|
||||||
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent();
|
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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.magistr.app.repository.TimeSlotScopeRepository;
|
|||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.DayOfWeek;
|
import java.time.DayOfWeek;
|
||||||
@@ -33,6 +34,7 @@ public class ScheduleGeneratorService {
|
|||||||
private final TimeSlotRepository timeSlotRepository;
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||||
GroupRepository groupRepository,
|
GroupRepository groupRepository,
|
||||||
@@ -41,6 +43,20 @@ public class ScheduleGeneratorService {
|
|||||||
TimeSlotRepository timeSlotRepository,
|
TimeSlotRepository timeSlotRepository,
|
||||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
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.scheduleRuleRepository = scheduleRuleRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -48,45 +64,134 @@ public class ScheduleGeneratorService {
|
|||||||
this.timeSlotRepository = timeSlotRepository;
|
this.timeSlotRepository = timeSlotRepository;
|
||||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||||
|
this.businessTime = businessTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
||||||
validateRange(startDate, endDate);
|
validateRange(startDate, endDate);
|
||||||
StudentGroup group = groupRepository.findById(groupId)
|
StudentGroup group = groupRepository.findById(groupId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
.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<>();
|
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||||
List<RenderedLessonDto> result = new ArrayList<>();
|
List<RenderedLessonDto> result = new ArrayList<>();
|
||||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||||
Set<Long> primedSemesterIds = new HashSet<>();
|
Set<Long> primedSemesterIds = new HashSet<>();
|
||||||
|
|
||||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
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()) {
|
if (semesterOpt.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Semester semester = semesterOpt.get();
|
Semester semester = semesterOpt.get();
|
||||||
if (!academicDateService.hasCalendarAssignment(group, semester)
|
if (!dateSnapshot.hasCalendarAssignment(group, semester)
|
||||||
&& warnedAcademicYears.add(semester.getAcademicYear().getId())) {
|
&& warnedAcademicYears.add(semester.getAcademicYear().getId())) {
|
||||||
logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
|
logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
|
||||||
group.getName(), semester.getAcademicYear().getTitle());
|
group.getName(), semester.getAcademicYear().getTitle());
|
||||||
}
|
}
|
||||||
if (!academicDateService.isTheoryDay(group, semester, date)) {
|
if (!dateSnapshot.isTheoryDay(group, semester, date)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
List<ScheduleRule> rules = rulesByGroupAndSemester.getOrDefault(
|
||||||
semester.getId(),
|
new GroupSemesterKey(group.getId(), semester.getId()),
|
||||||
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
List.of()
|
||||||
);
|
);
|
||||||
if (primedSemesterIds.add(semester.getId())) {
|
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)) {
|
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("Преподаватель не найден");
|
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<>();
|
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||||
List<RenderedLessonDto> result = new ArrayList<>();
|
List<RenderedLessonDto> result = new ArrayList<>();
|
||||||
Set<Long> primedSemesterIds = new HashSet<>();
|
Set<Long> primedSemesterIds = new HashSet<>();
|
||||||
|
|
||||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
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()) {
|
if (semesterOpt.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Semester semester = semesterOpt.get();
|
Semester semester = semesterOpt.get();
|
||||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
List<ScheduleRule> semesterRules = rulesBySemester.getOrDefault(semester.getId(), List.of());
|
||||||
semester.getId(),
|
|
||||||
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
|
||||||
);
|
|
||||||
if (primedSemesterIds.add(semester.getId())) {
|
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)) {
|
for (ScheduleRule rule : sortRules(semesterRules)) {
|
||||||
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
|
processRuleForDate(
|
||||||
|
rule,
|
||||||
|
semester,
|
||||||
|
date,
|
||||||
|
null,
|
||||||
|
teacherId,
|
||||||
|
consumedHoursProgress,
|
||||||
|
result,
|
||||||
|
dateSnapshot,
|
||||||
|
timeSlotSnapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +306,48 @@ public class ScheduleGeneratorService {
|
|||||||
.toList();
|
.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,
|
private void primeConsumedHoursBeforeRange(Semester semester,
|
||||||
List<ScheduleRule> rules,
|
List<ScheduleRule> rules,
|
||||||
LocalDate rangeStart,
|
LocalDate rangeStart,
|
||||||
StudentGroup targetGroup,
|
StudentGroup targetGroup,
|
||||||
Long targetTeacherId,
|
Long targetTeacherId,
|
||||||
Map<String, Integer> consumedHoursProgress) {
|
Map<String, Integer> consumedHoursProgress,
|
||||||
|
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||||
|
TimeSlotSnapshot timeSlotSnapshot) {
|
||||||
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
||||||
if (!semester.getStartDate().isBefore(endExclusive)) {
|
if (!semester.getStartDate().isBefore(endExclusive)) {
|
||||||
return;
|
return;
|
||||||
@@ -175,7 +356,17 @@ public class ScheduleGeneratorService {
|
|||||||
List<ScheduleRule> sortedRules = sortRules(rules);
|
List<ScheduleRule> sortedRules = sortRules(rules);
|
||||||
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
|
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
|
||||||
for (ScheduleRule rule : sortedRules) {
|
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,
|
private void processRuleForDate(ScheduleRule rule,
|
||||||
LocalDate date,
|
Semester semester,
|
||||||
StudentGroup targetGroup,
|
LocalDate date,
|
||||||
Long targetTeacherId,
|
StudentGroup targetGroup,
|
||||||
Map<String, Integer> consumedHoursProgress,
|
Long targetTeacherId,
|
||||||
List<RenderedLessonDto> result) {
|
Map<String, Integer> consumedHoursProgress,
|
||||||
|
List<RenderedLessonDto> result,
|
||||||
|
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||||
|
TimeSlotSnapshot timeSlotSnapshot) {
|
||||||
if (rule.getLectureAcademicHours() == null
|
if (rule.getLectureAcademicHours() == null
|
||||||
|| rule.getLaboratoryAcademicHours() == null
|
|| rule.getLaboratoryAcademicHours() == null
|
||||||
|| rule.getPracticeAcademicHours() == null) {
|
|| rule.getPracticeAcademicHours() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
if (semester == null || !Objects.equals(semester.getId(), rule.getSemester().getId())) {
|
||||||
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rule.getSubject().isActiveOn(date)) {
|
if (!rule.getSubject().isActiveOn(date)) {
|
||||||
@@ -207,8 +400,7 @@ public class ScheduleGeneratorService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Semester semester = semesterOpt.get();
|
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date, dateSnapshot);
|
||||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
|
||||||
if (eligibleGroups.isEmpty()) {
|
if (eligibleGroups.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -233,7 +425,7 @@ public class ScheduleGeneratorService {
|
|||||||
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
if (date.isAfter(businessTime.today()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||||
@@ -283,7 +475,7 @@ public class ScheduleGeneratorService {
|
|||||||
.orElse(0);
|
.orElse(0);
|
||||||
|
|
||||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
||||||
totalHours, consumedHours, remainingAfterLesson));
|
totalHours, consumedHours, remainingAfterLesson, timeSlotSnapshot));
|
||||||
}
|
}
|
||||||
for (ActiveLessonScope scope : activeScopes) {
|
for (ActiveLessonScope scope : activeScopes) {
|
||||||
consumedHoursProgress.put(
|
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) {
|
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(targetGroup)
|
||||||
: List.of();
|
: List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rule.getGroups().stream()
|
return rule.getGroups().stream()
|
||||||
.filter(group -> group.isActiveOn(date))
|
.filter(group -> group.isActiveOn(date))
|
||||||
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
|
.filter(group -> dateSnapshot.isTheoryDay(group, semester, date))
|
||||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -356,6 +552,12 @@ public class ScheduleGeneratorService {
|
|||||||
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
|
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,
|
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
||||||
ScheduleRuleSlot slot,
|
ScheduleRuleSlot slot,
|
||||||
LocalDate date,
|
LocalDate date,
|
||||||
@@ -365,8 +567,9 @@ public class ScheduleGeneratorService {
|
|||||||
List<Subgroup> subgroups,
|
List<Subgroup> subgroups,
|
||||||
int lessonTypeAcademicHours,
|
int lessonTypeAcademicHours,
|
||||||
int consumedBeforeLesson,
|
int consumedBeforeLesson,
|
||||||
int remainingAfterLesson) {
|
int remainingAfterLesson,
|
||||||
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
TimeSlotSnapshot timeSlotSnapshot) {
|
||||||
|
TimeSlot timeSlot = timeSlotSnapshot.resolve(slot.getTimeSlot(), date);
|
||||||
List<StudentGroup> sortedGroups = groups.stream()
|
List<StudentGroup> sortedGroups = groups.stream()
|
||||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -415,23 +618,55 @@ public class ScheduleGeneratorService {
|
|||||||
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
|
private static final class TimeSlotSnapshot {
|
||||||
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
|
||||||
return baseSlot;
|
|
||||||
}
|
|
||||||
return effectiveScope(date)
|
|
||||||
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
|
|
||||||
scope.getId(),
|
|
||||||
baseSlot.getOrderNumber()))
|
|
||||||
.orElse(baseSlot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
|
private final Map<LocalDate, Long> dateScopeIds;
|
||||||
return dateAssignmentRepository.findByDate(date)
|
private final Map<Integer, Long> weekdayScopeIds;
|
||||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
private final Map<ScopeOrderKey, TimeSlot> slotsByScopeAndOrder;
|
||||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
|
||||||
APPLY_MODE_WEEKDAY,
|
private TimeSlotSnapshot(Collection<TimeSlotDateAssignment> dateAssignments,
|
||||||
date.getDayOfWeek().getValue()));
|
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) {
|
public static String dayName(DayOfWeek dayOfWeek) {
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ public class ScheduleQueryService {
|
|||||||
if (date == null) {
|
if (date == null) {
|
||||||
throw new IllegalArgumentException("Дата расписания обязательна");
|
throw new IllegalArgumentException("Дата расписания обязательна");
|
||||||
}
|
}
|
||||||
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
|
List<StudentGroup> groups = groupRepository.findAll().stream()
|
||||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
|
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
|
||||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
|
|
||||||
.toList();
|
.toList();
|
||||||
|
List<RenderedLessonDto> generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date);
|
||||||
return deduplicate(generatedLessons);
|
return deduplicate(generatedLessons);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,16 +85,70 @@ public class ScheduleQueryService {
|
|||||||
String parity,
|
String parity,
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
LocalDate endDate) {
|
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);
|
validateRange(startDate, endDate);
|
||||||
List<RenderedLessonDto> generatedLessons;
|
List<RenderedLessonDto> generatedLessons;
|
||||||
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
|
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
|
||||||
if (teacherOnlySearch) {
|
if (teacherOnlySearch) {
|
||||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||||
} else {
|
} else {
|
||||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
|
List<StudentGroup> groups = resolveGroups(
|
||||||
generatedLessons = groups.stream()
|
groupId,
|
||||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
departmentId,
|
||||||
.toList();
|
startDate,
|
||||||
|
endDate,
|
||||||
|
enforceInteractiveGroupLimit
|
||||||
|
);
|
||||||
|
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
|
||||||
}
|
}
|
||||||
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
|
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
|
||||||
.findByLessonDateBetweenWithDetails(startDate, endDate);
|
.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) {
|
if (groupId != null) {
|
||||||
return groupRepository.findById(groupId)
|
return groupRepository.findById(groupId)
|
||||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||||
@@ -265,7 +323,9 @@ public class ScheduleQueryService {
|
|||||||
groups = groups.stream()
|
groups = groups.stream()
|
||||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||||
.toList();
|
.toList();
|
||||||
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
if (enforceInteractiveGroupLimit
|
||||||
|
&& departmentId == null
|
||||||
|
&& groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||||
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
||||||
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import com.magistr.app.model.SemesterType;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CourseAndSemesterCalculator {
|
public class CourseAndSemesterCalculator {
|
||||||
|
|
||||||
public static int getActualCourse(Integer yearStartStudy) {
|
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) {
|
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
|
||||||
@@ -22,7 +23,7 @@ public class CourseAndSemesterCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static int getActualSemester(Integer yearStartStudy) {
|
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) {
|
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ spring.datasource.driver-class-name=org.postgresql.Driver
|
|||||||
spring.jpa.hibernate.ddl-auto=none
|
spring.jpa.hibernate.ddl-auto=none
|
||||||
spring.jpa.show-sql=false
|
spring.jpa.show-sql=false
|
||||||
spring.jpa.open-in-view=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}
|
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-ttl=${JWT_REFRESH_TOKEN_TTL:7d}
|
||||||
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}
|
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}
|
||||||
app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false}
|
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
|
#logging.level.root=DEBUG
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -131,6 +133,82 @@ class RefreshTokenServiceConcurrencyIntegrationTest {
|
|||||||
assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1);
|
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(
|
private Optional<RefreshTokenRotation> rotateAfterSignal(
|
||||||
String rawToken,
|
String rawToken,
|
||||||
CountDownLatch ready,
|
CountDownLatch ready,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -35,7 +36,7 @@ class RefreshTokenServiceTest {
|
|||||||
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
|
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
|
||||||
assertThat(stored.getTokenHash()).hasSize(64);
|
assertThat(stored.getTokenHash()).hasSize(64);
|
||||||
assertThat(stored.getTenant()).isEqualTo("magistr");
|
assertThat(stored.getTenant()).isEqualTo("magistr");
|
||||||
assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now());
|
assertThat(stored.getExpiresAt()).isAfter(Instant.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -96,8 +97,8 @@ class RefreshTokenServiceTest {
|
|||||||
token.setUser(user());
|
token.setUser(user());
|
||||||
token.setTenant("magistr");
|
token.setTenant("magistr");
|
||||||
token.setTokenHash(hash);
|
token.setTokenHash(hash);
|
||||||
token.setIssuedAt(LocalDateTime.now().minusMinutes(1));
|
token.setIssuedAt(Instant.now().minus(1, ChronoUnit.MINUTES));
|
||||||
token.setExpiresAt(LocalDateTime.now().plusDays(1));
|
token.setExpiresAt(Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,30 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.config.auth.AuthContext;
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
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 com.magistr.app.model.Role;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class AuthControllerTest {
|
class AuthControllerTest {
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void clearAuthContext() {
|
void clearAuthContext() {
|
||||||
AuthContext.clear();
|
AuthContext.clear();
|
||||||
|
TenantContext.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -35,4 +45,35 @@ class AuthControllerTest {
|
|||||||
assertThat(body).containsKey("departmentId");
|
assertThat(body).containsKey("departmentId");
|
||||||
assertThat(body.get("departmentId")).isNull();
|
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("Слишком много попыток входа. Повторите позже");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
|
|||||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import com.magistr.app.service.ScheduleQueryService;
|
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.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -51,7 +53,9 @@ class DepartmentWorkspaceControllerTest {
|
|||||||
mock(DepartmentRepository.class),
|
mock(DepartmentRepository.class),
|
||||||
mock(TeacherDepartmentAssignmentRepository.class),
|
mock(TeacherDepartmentAssignmentRepository.class),
|
||||||
mock(TeacherCreationRequestRepository.class),
|
mock(TeacherCreationRequestRepository.class),
|
||||||
mock(ScheduleQueryService.class)
|
mock(ScheduleQueryService.class),
|
||||||
|
mock(TeacherDepartmentService.class),
|
||||||
|
mock(SubjectImportService.class)
|
||||||
);
|
);
|
||||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||||
|
|
||||||
@@ -61,14 +65,13 @@ class DepartmentWorkspaceControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
|
void teachersUseDatedAssignmentsWithoutLegacyFallback() {
|
||||||
UserRepository userRepository = mock(UserRepository.class);
|
UserRepository userRepository = mock(UserRepository.class);
|
||||||
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
||||||
|
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||||
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
|
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
|
||||||
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
|
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
|
||||||
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
|
when(teacherDepartmentService.findTeachersForDepartmentAtDate(1L, LocalDate.now()))
|
||||||
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
|
|
||||||
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
|
|
||||||
.thenReturn(List.of(directTeacher, assignedTeacher));
|
.thenReturn(List.of(directTeacher, assignedTeacher));
|
||||||
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||||
mock(SubjectRepository.class),
|
mock(SubjectRepository.class),
|
||||||
@@ -77,13 +80,15 @@ class DepartmentWorkspaceControllerTest {
|
|||||||
mock(DepartmentRepository.class),
|
mock(DepartmentRepository.class),
|
||||||
assignmentRepository,
|
assignmentRepository,
|
||||||
mock(TeacherCreationRequestRepository.class),
|
mock(TeacherCreationRequestRepository.class),
|
||||||
mock(ScheduleQueryService.class)
|
mock(ScheduleQueryService.class),
|
||||||
|
teacherDepartmentService,
|
||||||
|
mock(SubjectImportService.class)
|
||||||
);
|
);
|
||||||
|
|
||||||
List<UserResponse> teachers = controller.getTeachers(1L);
|
List<UserResponse> teachers = controller.getTeachers(1L);
|
||||||
|
|
||||||
assertThat(teachers).extracting(UserResponse::getFullName)
|
assertThat(teachers).extracting(UserResponse::getFullName)
|
||||||
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
|
.containsExactlyInAnyOrder("Петров Препод Петрович", "Препод Тест Тестович");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -103,7 +108,9 @@ class DepartmentWorkspaceControllerTest {
|
|||||||
departmentRepository,
|
departmentRepository,
|
||||||
assignmentRepository,
|
assignmentRepository,
|
||||||
mock(TeacherCreationRequestRepository.class),
|
mock(TeacherCreationRequestRepository.class),
|
||||||
mock(ScheduleQueryService.class)
|
mock(ScheduleQueryService.class),
|
||||||
|
mock(TeacherDepartmentService.class),
|
||||||
|
mock(SubjectImportService.class)
|
||||||
);
|
);
|
||||||
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
|
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.magistr.app.model.User;
|
|||||||
import com.magistr.app.repository.DepartmentRepository;
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import com.magistr.app.service.TeacherDepartmentService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,16 +25,19 @@ class UserControllerTest {
|
|||||||
UserRepository userRepository = mock(UserRepository.class);
|
UserRepository userRepository = mock(UserRepository.class);
|
||||||
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||||
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
||||||
|
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||||
UserController controller = new UserController(
|
UserController controller = new UserController(
|
||||||
userRepository,
|
userRepository,
|
||||||
null,
|
null,
|
||||||
departmentRepository,
|
departmentRepository,
|
||||||
assignmentRepository
|
assignmentRepository,
|
||||||
|
teacherDepartmentService
|
||||||
);
|
);
|
||||||
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
|
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
|
||||||
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
|
when(teacherDepartmentService.findTeachersForDepartmentAtDate(
|
||||||
.thenReturn(List.of());
|
org.mockito.ArgumentMatchers.eq(2L),
|
||||||
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
|
org.mockito.ArgumentMatchers.any()
|
||||||
|
))
|
||||||
.thenReturn(List.of(teacher));
|
.thenReturn(List.of(teacher));
|
||||||
when(departmentRepository.findById(2L))
|
when(departmentRepository.findById(2L))
|
||||||
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
||||||
|
|||||||
@@ -1,24 +1,87 @@
|
|||||||
package com.magistr.app.controller;
|
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.Classroom;
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
import com.magistr.app.model.LifecycleEntity;
|
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.ClassroomRepository;
|
||||||
import com.magistr.app.repository.DepartmentRepository;
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
|
||||||
import com.magistr.app.service.ScheduleQueryService;
|
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.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
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.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
class WorkloadControllerTest {
|
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
|
@Test
|
||||||
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
||||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||||
@@ -26,19 +89,97 @@ class WorkloadControllerTest {
|
|||||||
WorkloadController controller = new WorkloadController(
|
WorkloadController controller = new WorkloadController(
|
||||||
scheduleQueryService,
|
scheduleQueryService,
|
||||||
classroomRepository,
|
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());
|
.thenReturn(List.of());
|
||||||
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||||
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
|
.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);
|
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
|
||||||
|
|
||||||
assertThat(result).extracting("id").containsExactly(3L);
|
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) {
|
private Classroom classroom(Long id, Boolean available) {
|
||||||
Classroom classroom = new Classroom();
|
Classroom classroom = new Classroom();
|
||||||
classroom.setId(id);
|
classroom.setId(id);
|
||||||
@@ -47,4 +188,36 @@ class WorkloadControllerTest {
|
|||||||
classroom.setIsAvailable(available);
|
classroom.setIsAvailable(available);
|
||||||
return classroom;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,31 +14,21 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDate;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@DisplayName("Миграция инвариантов точечных изменений расписания")
|
@DisplayName("Инварианты точечных изменений расписания базовой схемы")
|
||||||
class ScheduleOverrideMigrationIntegrationTest {
|
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
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные")
|
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")
|
||||||
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
|
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
|
||||||
Flyway flyway = targetThreeFlyway();
|
Flyway flyway = baselineFlyway();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
@@ -46,8 +36,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
|||||||
.as("После полной миграции должна существовать текущая версия")
|
.as("После полной миграции должна существовать текущая версия")
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion())
|
assertThat(flyway.info().current().getVersion().getVersion())
|
||||||
.as("Последней успешно применённой миграцией должна быть V3")
|
.as("Последней успешно применённой миграцией должна быть V1")
|
||||||
.isEqualTo("3");
|
.isEqualTo("1");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
@@ -106,81 +96,11 @@ class ScheduleOverrideMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private Flyway baselineFlyway() {
|
||||||
@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() {
|
|
||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("3"))
|
.target(MigrationVersion.fromVersion("1"))
|
||||||
.cleanDisabled(false)
|
|
||||||
.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Flyway targetTwoFlyway() {
|
|
||||||
return Flyway.configure()
|
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
|
||||||
.locations("classpath:db/migration")
|
|
||||||
.target(MigrationVersion.fromVersion("2"))
|
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.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) {
|
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@DisplayName("Миграция чётности академических часов правил расписания")
|
@DisplayName("Инварианты правил расписания базовой схемы")
|
||||||
class ScheduleRuleHoursMigrationIntegrationTest {
|
class ScheduleRuleHoursMigrationIntegrationTest {
|
||||||
|
|
||||||
private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
|
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");
|
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
||||||
void migrationEnforcesEvenAcademicHours() throws SQLException {
|
void migrationEnforcesEvenAcademicHours() throws SQLException {
|
||||||
Flyway flyway = targetFourFlyway();
|
Flyway flyway = baselineFlyway();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
@@ -40,8 +37,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
.as("После полной миграции должна существовать текущая версия")
|
.as("После полной миграции должна существовать текущая версия")
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion())
|
assertThat(flyway.info().current().getVersion().getVersion())
|
||||||
.as("Последней успешно применённой миграцией должна быть V4")
|
.as("Последней успешно применённой миграцией должна быть V1")
|
||||||
.isEqualTo("4");
|
.isEqualTo("1");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
@@ -63,7 +60,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
""",
|
""",
|
||||||
HOURS_CONSTRAINT
|
HOURS_CONSTRAINT
|
||||||
))
|
))
|
||||||
.as("Ограничение чётности V4 должно быть провалидировано")
|
.as("Ограничение чётности V1 должно быть провалидировано")
|
||||||
.isOne();
|
.isOne();
|
||||||
|
|
||||||
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
|
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
|
||||||
@@ -73,113 +70,11 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private Flyway baselineFlyway() {
|
||||||
@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() {
|
|
||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("4"))
|
.target(MigrationVersion.fromVersion("1"))
|
||||||
.cleanDisabled(false)
|
|
||||||
.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Flyway targetThreeFlyway() {
|
|
||||||
return Flyway.configure()
|
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
|
||||||
.locations("classpath:db/migration")
|
|
||||||
.target(MigrationVersion.fromVersion("3"))
|
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.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 {
|
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
for (int index = 0; index < parameters.length; index++) {
|
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) {
|
private record SeedIds(long subjectId, long semesterId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.magistr.app.model;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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, 20))).isTrue();
|
||||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
|
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("Тестовая архивация");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user