исправление багов

This commit is contained in:
Zuev
2026-07-13 03:28:18 +03:00
parent 39c58440cf
commit 85f61436b6
76 changed files with 9219 additions and 1258 deletions

278
BUG_FIX_PROGRESS.md Normal file
View File

@@ -0,0 +1,278 @@
# Журнал исправления проблем
Обновлено: 2026-07-13 (Europe/Moscow).
## Область и неизменяемые данные
- В работе ровно 33 проблемы: `1` и `334`.
- Проблема № 2 исключена: демонстрационные учётные записи, seed-данные и документация по ним не изменяются.
- `BUG_REPORT.md` не редактируется.
- Существующие Flyway-миграции не редактируются.
Исходное рабочее дерево перед началом реализации было чистым (`git status --short` без вывода).
Контрольные суммы исходных миграций:
| Файл | SHA-256 |
|---|---|
| `V1__init.sql` | `404f65f270dd87f4db996495fc970456c4fd0f762e7c32638199dffaa812ea7b` |
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
## Исходная линия проверок
| Проверка | Результат до изменений |
|---|---|
| `mvn -f backend/pom.xml test` | Maven на хосте отсутствует. Контейнерный эквивалент `maven:3.9-eclipse-temurin-17` завершён успешно: 30 тестов, 0 failures, 0 errors. |
| `find frontend ... node --check` | Успешно. |
| `docker compose config --quiet` | Успешно. |
| `kubectl kustomize ../k8s >/dev/null` | Успешно. |
| `bash -n ../k8s/deploy.sh` | Успешно. |
| `git diff --check` | Успешно. |
## Порядок выполнения
1. Безопасная production-конфигурация и внешние секреты: № 1 (с зависимой частью № 14).
2. Аутентификация и границы запроса: № 3, 4, 24, 25, 28.
3. Высокоприоритетные инварианты расписания: № 58.
4. Tenant lifecycle и Kubernetes coordination: № 9, 10, 13, 14.
5. Frontend-сессия и достоверность UI: № 11, 12, 33, 34.
6. Календарные и DB-инварианты: № 1517, 21, 31, 32.
7. История, права, масштаб и производительность: № 1823.
8. Локальный запуск, CI/CD, воспроизводимые артефакты и timezone: № 26, 27, 29, 30.
9. AutoUpdateDocs, полный регрессионный прогон и финальный аудит 33 пунктов.
## Чек-лист
Статусы меняются только после реализации и воспроизводимой проверки.
| № | Статус | Подтверждённая первопричина | Решение и основные файлы | Проверка |
|---:|---|---|---|---|
| 1 | требуется внешнее действие | В коде и production YAML были встроенные/placeholder JWT и DB credentials; tenant и OTel credentials находились в отслеживаемой конфигурации; Secure-cookie не был обязательным для production. | Встроенный JWT default удалён; добавлен fail-fast validator; production использует только внешние `app-secret`, `tenants-secret`, `otel-postgres-secret`; создан `docs/SECURITY_RUNBOOK.md`. Репозиторная часть исправлена. Оператору остаются ротация, provisioning, rollout и очистка истории. | 11 JWT unit/context tests; общий backend: 45/0/0; `scripts/check-production-secrets.sh`; Kustomize и shell syntax — успешно. |
| 3 | исправлено и проверено | Ротация refresh-токена выполняла read-check-write без блокировки/условного update. | `RefreshTokenService.rotate()` использует блокирующий `findByTokenHashForUpdate()` с `PESSIMISTIC_WRITE` и `JOIN FETCH` внутри транзакции. | Два синхронных запроса к Spring proxy и PostgreSQL: один успех, один отказ, один активный новый токен. Полный backend: 46/0/0/0. |
| 4 | исправлено и проверено | `AuthContext` устанавливался до role-check и сохранялся в servlet-потоке, когда `preHandle=false` не приводил к `afterCompletion`. | `AuthorizationInterceptor` очищает контекст в начале каждого запроса и устанавливает пользователя только после успешной проверки ролей; финальная очистка сохранена. | Один MockMvc-поток: `403`, публичный запрос, разрешённый запрос и очистка после завершения. Полный backend: 47/0/0/0. |
| 5 | исправлено и проверено | Override сохранялся без доказательства исходной пары, action-specific полей и ресурсных конфликтов. | `ScheduleOverrideService` строит base/effective day, валидирует матрицу и фактическое изменение, проверяет ресурсы и сериализует CRUD через PostgreSQL advisory locks; V3 закрепляет структурные инварианты. | 27 целевых unit/MockMvc/PostgreSQL tests; полный backend: 74/0/0/0. |
| 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. |
| 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 проглатывает ошибку, а отказ Secret-персистенции оставляет локальное состояние изменённым. | Реализуются prepare/validate/migrate кандидата, persistence до atomic swap и компенсация Secret при ошибке активации. | Готовятся unit и PostgreSQL lifecycle tests для credentials/migration/persistence/swap. |
| 10 | не начато | Два pod перезаписывают целый tenant-документ без `resourceVersion`; TCP probes не отражают готовность tenant-БД. | Optimistic locking с retry; отдельные liveness/readiness semantics. | Конкурентные PATCH и health tests; Kustomize-проверка probes. |
| 11 | не начато | Sunday вычисляется повторным мутированием даты; ошибки запросов превращаются в пустые результаты и зелёный статус. | Чистая local-date утилита и три явных состояния проверки. | 2026-07-02, переход года, частичная/полная ошибка API. |
| 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. |
| 13 | не начато | Watcher сравнивает только домены и фиксирует hash до успешной полной синхронизации. | Нормализованное сравнение полного `TenantConfig`, hash после успеха, bounded backoff. | Изменение URL/credentials и retry после ошибки. |
| 14 | исправлено и проверено | Kubernetes HTTP-клиент имел trust-all TLS fallback. | `KubernetesTenantSecretUpdater` загружает service-account CA, строит PKIX trust store, включает HTTPS hostname verification и не имеет небезопасного fallback. | Mock HTTPS: доверенный CA принят; чужой CA, неверный hostname и отсутствующий CA отклонены. Backend 45/0/0. |
| 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. |
| 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
| 17 | не начато | Изменение измерений группы/календаря оставляет несовместимые assignments/grid/subjects. | Отклонять несовместимое изменение транзакционно с `409`; контролировать `courseCount`. | Assignment сохранён после отказа; старшие строки не остаются. |
| 18 | не начато | Бизнес-решения смешивают `users.department_id` с датированными assignments и фильтруют архивные записи по текущему состоянию. | Date-aware resolver кафедры и единое использование истории. | Будущий перевод, прошлый период, архивный преподаватель, совместительство. |
| 19 | не начато | Workload принимает произвольный `departmentId` для роли `DEPARTMENT`. | Принудительный scope из `AuthContext`; глобальный доступ только разрешённым ролям. | Межкафедральный запрос кафедры не раскрывает данные. |
| 20 | не начато | Агрегаты переиспользуют интерактивный широкий поиск с лимитом 50 групп. | Специализированная batch/aggregate генерация без UI-лимита. | 51 и 100 активных групп. |
| 21 | не начато | Импорт ищет дисциплину по глобальному имени и меняет её владельца. | `409` для чужой записи, идемпотентность своей; scoped uniqueness при миграции. | Повторный свой импорт и совпадение чужой кафедры. |
| 22 | не начато | Видимые EDUCATION_OFFICE экраны вызывают backend endpoints с ADMIN-only правами. | Единая role matrix для backend/frontend; согласовать read/write capabilities. | Интеграционный сценарий календаря и форм обучения без штатного `403`. |
| 23 | не начато | Генератор повторно запрашивает semester/assignment/calendar day на каждой комбинации. | Батч-загрузка и request-scoped lookup maps. | Query-count test на 120 датах и десятках групп. |
| 24 | не начато | Истёкшие/отозванные refresh rows не удаляются. | Tenant-aware scheduled cleanup с retention и безопасностью двух pod. | Active/fresh rows сохраняются; cleanup идемпотентен и конкурентно безопасен. |
| 25 | не начато | Нет общего mapping DB constraints; контроллеры возвращают клиенту `getMessage()`. | Централизованный sanitizer/mapping `400`/`409`, убрать raw exception body. | Известный/неизвестный constraint без JDBC details. |
| 26 | не начато | Compose требует внешнюю сеть, не публикует HTTP, рассогласует DB/JWT и не имеет named volume. | Самодостаточная сеть/reverse proxy, согласованные env и volume. | Чистый `docker compose up -d --build`, HTTP `/` и `/api`. |
| 27 | не начато | Pipeline собирает/деплоит без обязательных тестов и для tag перезапускает mutable `main`. | Test gates, concurrency, immutable tag/digest rollout, rollback check. | Workflow lint/static inspection и shell tests. |
| 28 | не начато | Login не имеет shared rate limit; IP безусловно доверяет forwarded header. | PostgreSQL-backed tenant+trusted-IP+username limiter, lockout/audit, `429`/`Retry-After`. | Multi-instance integration tests, отсутствие user enumeration. |
| 29 | не начато | Business dates зависят от timezone процесса; frontend date-only строится через UTC. | Инъекция `Clock`/`Europe/Moscow`, UTC timestamps, локальное форматирование date-only, TZ контейнеров. | Boundary tests 00:0003:00 и первые дни месяца. |
| 30 | не начато | Образы/скачиваемые инструменты используют mutable tags/latest без checksum/SBOM/scan. | Pin versions+digests, SHA verification, SBOM и image scan gates. | Static scan production refs и CI configuration. |
| 31 | не начато | Group create/update допускает неположительные значения и уменьшение ниже активных подгрупп. | Общий validator, locking, новые CHECK constraints. | Create/update/concurrency и migration tests. |
| 32 | не начато | `between(start,end) > 120` разрешает 121 включительную дату. | Проверять `between + 1 <= 120` в обоих сервисах. | Границы 120/121. |
| 33 | не начато | Несколько `e.message` вставляются как HTML; парольные поля имеют `type=text`. | `textContent`/экранирование; `type=password` и autocomplete. | DOM/XSS static/unit test и HTML assertions. |
| 34 | не начато | В затронутых UI/log paths остаются английские метки/логи и raw exception text. | Русификация и безопасные сообщения на границе API/UI. | Поиск английских production сообщений и regression tests. |
## Завершённые этапы
### № 1 и зависимая № 14
- `JwtProperties` больше не содержит секрет по умолчанию; production startup отклоняет пустое, короткое, известное legacy/placeholder значение и `JWT_REFRESH_COOKIE_SECURE=false`.
- `../k8s/config.yaml` больше не создаёт Secret с литеральными значениями.
- `../k8s/backend.yaml` монтирует tenant-конфигурацию из `tenants-secret`.
- `../k8s/otel-collector.yaml` использует только `${env:...}` ссылки и `otel-postgres-secret`.
- `KubernetesTenantSecretUpdater` заменил `ConfigMapUpdater`, использует base64-поле Kubernetes Secret, service-account CA и hostname verification.
- `../k8s/deploy.sh` проверяет обязательные Secrets/ключи до rollout без вывода значений.
- Созданы `docs/SECURITY_RUNBOOK.md` и `scripts/check-production-secrets.sh`; документация синхронизирована через AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn test` | 45 тестов, 0 failures, 0 errors, BUILD SUCCESS. |
| `bash scripts/check-production-secrets.sh` | Успешно. |
| `kubectl kustomize ../k8s >/dev/null` | Успешно. |
| `bash -n ../k8s/deploy.sh` | Успешно. |
| `git diff --check` | Успешно. |
| Повторные SHA-256 V1/V2 | Совпадают с исходными. |
Изменённые вне Git-корня файлы: `../k8s/config.yaml`, `backend.yaml`,
`otel-collector.yaml`, `rbac.yaml`, `deploy.sh`, `README.md`. Их состояние проверяется
отдельно от `git diff`.
### № 3 — атомарная ротация refresh-токена
- Репозиторий захватывает строку исходного токена через `PESSIMISTIC_WRITE` и загружает
пользователя в том же запросе.
- `RefreshTokenService.rotate()` выполняет проверку активности, отзыв исходного токена и
создание следующего токена в одной транзакции после захвата блокировки.
- Добавлен интеграционный тест с двумя синхронными потоками, Spring transactional proxy,
Flyway V1/V2 и реальным PostgreSQL 16.3 через Testcontainers.
- Одновременная ротация подтверждает ровно один успешный результат, один отказ, две строки
в БД и ровно один активный следующий токен.
- `docs/ARCHITECTURE.md` и `docs/API.md` синхронизированы через AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный узкий `mvn -Dtest=RefreshTokenServiceTest,RefreshTokenServiceConcurrencyIntegrationTest test` | 5 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Контейнерный полный `mvn test` | 46 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
### № 4 — границы `AuthContext`
- `AuthorizationInterceptor.preHandle()` очищает `AuthContext` до проверки URI, метода,
публичного endpoint и bearer-токена.
- Аутентифицированный пользователь помещается в `ThreadLocal` только после успешной
проверки `@RequireRoles`; ответы `401` и `403` оставляют контекст пустым.
- `afterCompletion()` продолжает очищать контекст после успешно допущенного запроса.
- Регрессионный MockMvc-тест выполняет на одном JUnit-потоке запрещённый защищённый,
публичный и разрешённый защищённый запросы и проверяет контекст внутри обработчиков и
после завершения.
- `docs/ARCHITECTURE.md` синхронизирован через AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Контейнерный `mvn -Dtest=AuthorizationInterceptorTest test` | 1 тест, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Контейнерный полный `mvn test` | 47 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
### № 5 — безопасные точечные изменения расписания
- `ScheduleOverrideController` стал тонким HTTP-адаптером, а CRUD перенесён в публичные
транзакционные методы `ScheduleOverrideService`.
- Перед сохранением строится реальная базовая пара на дату с учётом семестра, календаря,
чётности, lifecycle и остатка часов; несуществующая occurrence возвращает `400`.
- Матрица `CANCEL`/`MOVE`/`REPLACE`, формат и action-specific фактическое изменение
проверяются до мутации managed entity.
- Сохранённые overrides и кандидат применяются общей логикой `ScheduleQueryService`;
полуоткрытые интервалы проверяются по преподавателю, аудитории, целой группе и
подгруппам. Конфликт возвращается как русский `409 Conflict`.
- PostgreSQL transaction advisory lock сериализует изменения даты между pod. Update/delete
дополнительно блокируют ID; старая и новая даты update захватываются в стабильном порядке,
после чего строка перечитывается с `PESSIMISTIC_WRITE`.
- Добавлена `V3__schedule_override_invariants.sql`: preflight legacy-строк, CHECK для
payload каждого действия и формата. Миграционный тест подтверждает полный rollback на
невалидном legacy `REPLACE` и clean upgrade до V3.
- `docs/API.md`, `docs/BUSINESS_LOGIC.md`, `docs/DATABASE.md` и `docs/ARCHITECTURE.md`
синхронизированы через AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Целевые контейнерные Maven-прогоны № 5 | 22 unit/MockMvc/PostgreSQL теста, затем 24 усиленных unit/MockMvc теста; оба прогона без failures/errors/skipped. Все 27 тестов № 5 входят в полный прогон. |
| Контейнерный полный `mvn test` | 74 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
| Повторные SHA-256 V1/V2 | Совпадают с исходными. |
| SHA-256 новой V3 | `eb5315b58a2bad95b0ad36718052788168be0d8f347649c5c98782949865357b`. |
### № 6 — замена преподавателя в результирующем расписании
- `ScheduleQueryService.search()` читает overrides диапазона один раз и переиспользует
один снимок для достраивания и применения изменений.
- Teacher-only путь по-прежнему строит базовое расписание преподавателя напрямую. Только
при наличии overrides с совпадающим `newTeacher` он группирует целевые слоты по дате и
один раз строит полный базовый день каждой релевантной даты.
- Из дня добавляются только occurrences с точным `baseRuleSlotId + lessonDate`; отсутствующая
базовая пара не создаёт фантомное занятие.
- Overrides применяются до окончательного teacher-фильтра: новый преподаватель видит замену,
исходный — нет. Итоговая дедупликация удаляет совпавшие базовые и достроенные фрагменты.
- `CANCEL` и `MOVE` продолжают применяться из того же snapshot; при отсутствии релевантной
замены `groupRepository.findAll()` не вызывается.
- `docs/API.md`, `docs/BUSINESS_LOGIC.md` и `docs/ARCHITECTURE.md` синхронизированы через
AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Узкий контейнерный Maven-прогон трёх `ScheduleQueryService*Test` | 10 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| Контейнерный полный `mvn test` | 79 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
### № 7 — полная валидация правил расписания
- `ScheduleRuleAdminController` стал тонким HTTP-адаптером; create/update/archive перенесены
в публичные транзакционные методы `ScheduleRuleService`.
- До обращений к справочникам сервис проверяет обязательные поля, неотрицательность и
чётность каждого лимита часов, положительную сумму, недели начала и структуру payload.
Ноль разрешён для неиспользуемого типа занятия.
- Слот принимает только активного пользователя с ролью `TEACHER`, базовый временной слот и
формат `Очно`/`Онлайн`; отсутствующая вложенная ссылка возвращает безопасный `400`.
- Кандидат строится отдельно от managed entity. Точные дубли и ресурсные конфликты слотов
нового payload проверяются попарно до сохранения; `ODD` и `EVEN` разрешены одновременно,
а `BOTH` пересекается с ними. Конфликтный update не изменяет существующее правило.
- Создание первым DB-запросом блокирует строку семестра. Update блокирует строку правила и
старый/новый семестры в стабильном порядке; реальный PostgreSQL-тест подтверждает, что из
двух параллельных create проходит один, а второй после commit видит `409 Conflict`.
- `V4__schedule_rule_even_hours.sql` выполняет русский preflight нечётных legacy-часов и
точных дублей слотов, затем добавляет провалидированный CHECK чётности и UNIQUE точного
payload слота. При legacy-нарушении V4 полностью откатывается и остаётся версия V3.
- `docs/API.md`, `docs/BUSINESS_LOGIC.md`, `docs/DATABASE.md` и `docs/ARCHITECTURE.md`
синхронизированы через AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Целевые service/MockMvc/Flyway/PostgreSQL прогоны № 7 | 23 теста, 0 failures, 0 errors, 0 skipped. |
| Контейнерный полный `mvn test` | 102 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
| Повторные SHA-256 V1/V2/V3 | Совпадают с зафиксированными значениями. |
| SHA-256 новой V4 | `211babace0f1620cac2186b42b6d3039c8ee8518284473b49da0a4d47186e490`. |
### № 8 — атомарная замена календарной сетки
- `AcademicCalendarController.saveGrid()` больше не содержит транзакцию, bulk delete,
построчное сохранение и внутренний catch; endpoint делегирует публичному
`AcademicCalendarGridService.replaceGrid()`.
- Сервис до первого write полностью валидирует непустой payload: `null`-строки, границы
курса и учебного года, согласованность `weekNumber` и ISO `dayOfWeek` с датой, наличие
activity type и уникальность `(courseNumber, date)`.
- Все `AcademicCalendarDay` строятся в памяти до `deleteByCalendarId`. Валидный набор
сохраняется как `delete → flush → saveAll → flush` в одной транзакции; исключения выходят
за Spring proxy и приводят к rollback.
- Очистка кэша зарегистрирована как `afterCommit`, поэтому не выполняется при rollback и не
создаёт окно повторного кэширования старой сетки между flush и commit.
- PostgreSQL-тест сравнивает count+MD5 старой seed-сетки: payload с корректной первой и
ошибочной второй строкой оставляет fingerprint неизменным и не добавляет первую строку;
следующий валидный запрос атомарно оставляет ровно две новые строки.
- `docs/API.md`, `docs/BUSINESS_LOGIC.md` и `docs/ARCHITECTURE.md` синхронизированы через
AutoUpdateDocs.
Фактические проверки этапа:
| Команда | Результат |
|---|---|
| Unit `AcademicCalendarGridServiceTest` | 8 тестов, 0 failures, 0 errors, 0 skipped. |
| PostgreSQL `AcademicCalendarGridServiceIntegrationTest` | 1 тест, 0 failures, 0 errors, 0 skipped; rollback/fingerprint и after-commit подтверждены. |
| Контейнерный полный `mvn test` | 111 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
## Точка продолжения
Текущий этап: **№ 9 — безопасная замена tenant DataSource**.
Следующая операция:
1. проверить актуальные create/update потоки `DatabaseController`, swap/remove API
`TenantRoutingDataSource`, Flyway и персистенцию tenant-конфигурации;
2. определить порядок prepare → connection validation → Flyway → persistence → atomic swap,
не закрывающий рабочий pool до полной готовности кандидата;
3. добавить компенсирующий rollback для ошибок персистенции/активации и явные русские
ошибки без ложного `200`;
4. покрыть неверные credentials, ошибку миграции и ошибку персистенции тестами, доказывающими
сохранность старого подключения;
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 9.

View File

@@ -76,6 +76,28 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-tls</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -1,39 +0,0 @@
package com.magistr.app.config;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/**
* При запуске приложения инициализирует БД для каждого тенанта.
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
*/
@Component
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final TenantRoutingDataSource routingDataSource;
private final TenantConfigWatcher configWatcher;
public DataInitializer(TenantRoutingDataSource routingDataSource,
TenantConfigWatcher configWatcher) {
this.routingDataSource = routingDataSource;
this.configWatcher = configWatcher;
}
@Override
public void run(String... args) {
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
configWatcher.initDatabaseForTenant(tenant);
}
log.info("Database initialization complete");
}
}

View File

@@ -25,6 +25,8 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
AuthContext.clear();
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
@@ -42,13 +44,13 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
return false;
}
AuthContext.setCurrentUser(user);
RequireRoles roles = resolveRoles(handler);
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
return false;
}
AuthContext.setCurrentUser(user);
return true;
}

View File

@@ -4,13 +4,34 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Set;
@Component
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
private static final Set<String> LEGACY_INSECURE_SECRET_FINGERPRINTS = Set.of(
"884e52f1fbb3b0caf409bc76a14ba8e601d6117be5a9188319f2e84f9e19a853",
"54f5809ada311ba1e12146a14db39ede2082b6f73ca7749bae8cd8450726eaf9"
);
private static final Set<String> PLACEHOLDER_MARKERS = Set.of(
"change-me",
"change_this",
"change-this",
"dev-only",
"placeholder",
"replace-with",
"replace_this",
"replace-this"
);
private String secret;
private Duration accessTtl = Duration.ofMinutes(15);
private Duration refreshTtl = Duration.ofDays(7);
private String refreshCookieName = "magistr_refresh";
@@ -57,10 +78,37 @@ public class JwtProperties {
}
public byte[] secretBytes() {
byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("JWT_SECRET обязателен и не может быть пустым");
}
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
}
String normalized = secret.toLowerCase(Locale.ROOT);
if (PLACEHOLDER_MARKERS.stream().anyMatch(normalized::contains)
|| LEGACY_INSECURE_SECRET_FINGERPRINTS.contains(sha256(bytes))) {
throw new IllegalStateException("JWT_SECRET содержит известное небезопасное или шаблонное значение");
}
return bytes;
}
public void validateForEnvironment(boolean production) {
secretBytes();
if (production && !refreshCookieSecure) {
throw new IllegalStateException(
"В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true"
);
}
}
private String sha256(byte[] value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", e);
}
}
}

View File

@@ -0,0 +1,24 @@
package com.magistr.app.config.auth;
import jakarta.annotation.PostConstruct;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
@Component
public class JwtSecurityStartupValidator {
private final JwtProperties properties;
private final Environment environment;
public JwtSecurityStartupValidator(JwtProperties properties, Environment environment) {
this.properties = properties;
this.environment = environment;
}
@PostConstruct
void validate() {
boolean production = environment.acceptsProfiles(Profiles.of("prod", "production"));
properties.validateForEnvironment(production);
}
}

View File

@@ -55,7 +55,7 @@ public class RefreshTokenService {
LocalDateTime now = LocalDateTime.now();
String currentHash = hashToken(rawToken);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHash(currentHash);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
if (storedOpt.isEmpty()) {
return Optional.empty();
}

View File

@@ -1,125 +0,0 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
*
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
*/
@Service
public class ConfigMapUpdater {
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
private static final String CONFIGMAP_NAME = "tenants-config";
private final ObjectMapper objectMapper = new ObjectMapper();
private final boolean runningInK8s;
public ConfigMapUpdater() {
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
if (!runningInK8s) {
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
}
}
/**
* Обновляет ConfigMap tenants-config с новым списком тенантов.
* @return true если обновление успешно (или мы не в K8s)
*/
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInK8s) {
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
return true;
}
try {
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
// Формируем JSON для тенантов
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(tenants);
// Strategic merge patch для ConfigMap
String patchBody = objectMapper.writeValueAsString(Map.of(
"data", Map.of("tenants.json", tenantsJson)
));
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
K8S_API_BASE, namespace, CONFIGMAP_NAME);
// Создаём HttpClient с отключённой проверкой сертификатов
// (внутри кластера используется self-signed CA)
HttpClient client = createInsecureClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/strategic-merge-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
return true;
} else {
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
return false;
}
} catch (Exception e) {
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
return false;
}
}
/**
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
*/
private HttpClient createInsecureClient() {
try {
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder()
.sslContext(sslContext)
.build();
} catch (Exception e) {
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
return HttpClient.newHttpClient();
}
}
}

View File

@@ -0,0 +1,178 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManagerFactory;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.util.Base64;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* Обновляет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
*
* <p>Работает только внутри Kubernetes pod и использует service-account token и
* service-account CA. В локальной разработке персистенция пропускается.</p>
*/
@Service
public class KubernetesTenantSecretUpdater {
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
private static final Path DEFAULT_TOKEN_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/token");
private static final Path DEFAULT_NAMESPACE_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/namespace");
private static final Path DEFAULT_CA_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
private final ObjectMapper objectMapper;
private final Path tokenPath;
private final Path namespacePath;
private final Path caPath;
private final String apiBase;
private final String secretName;
private final boolean runningInKubernetes;
public KubernetesTenantSecretUpdater() {
this(
DEFAULT_TOKEN_PATH,
DEFAULT_NAMESPACE_PATH,
DEFAULT_CA_PATH,
DEFAULT_API_BASE,
DEFAULT_SECRET_NAME,
new ObjectMapper()
);
}
KubernetesTenantSecretUpdater(Path tokenPath,
Path namespacePath,
Path caPath,
String apiBase,
String secretName,
ObjectMapper objectMapper) {
this.tokenPath = tokenPath;
this.namespacePath = namespacePath;
this.caPath = caPath;
this.apiBase = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
this.secretName = secretName;
this.objectMapper = objectMapper;
this.runningInKubernetes = Files.exists(tokenPath);
if (!runningInKubernetes) {
log.info("Приложение запущено вне Kubernetes — обновление tenant Secret будет пропущено");
}
}
/**
* Сохраняет полный список тенантов в ключе {@code tenants.json} Kubernetes Secret.
*
* @return {@code true}, если Secret обновлён или приложение запущено вне Kubernetes
*/
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInKubernetes) {
log.warn("Приложение запущено вне Kubernetes, персистенция tenant Secret пропущена");
return true;
}
try {
String token = Files.readString(tokenPath).trim();
String namespace = Files.readString(namespacePath).trim();
if (token.isBlank() || namespace.isBlank()) {
throw new IllegalStateException("ServiceAccount token или namespace не настроены");
}
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
String encodedTenants = Base64.getEncoder()
.encodeToString(tenantsJson.getBytes(StandardCharsets.UTF_8));
String patchBody = objectMapper.writeValueAsString(Map.of(
"data", Map.of("tenants.json", encodedTenants)
));
URI uri = URI.create(String.format(
"%s/api/v1/namespaces/%s/secrets/%s",
apiBase,
namespace,
secretName
));
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/strategic-merge-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
.build();
HttpResponse<Void> response = createSecureClient(caPath)
.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() == 200) {
log.info("Tenant Secret успешно обновлён: tenantCount={}", tenants.size());
return true;
}
log.error("Kubernetes API отклонил обновление tenant Secret: httpStatus={}", response.statusCode());
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Обновление tenant Secret прервано");
log.debug("Технические детали прерывания tenant Secret", e);
return false;
} catch (Exception e) {
log.error("Не удалось безопасно обновить tenant Secret: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали ошибки tenant Secret", e);
return false;
}
}
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certificates;
try (InputStream input = Files.newInputStream(serviceAccountCaPath)) {
certificates = certificateFactory.generateCertificates(input);
}
if (certificates.isEmpty()) {
throw new IllegalStateException("ServiceAccount CA не содержит сертификатов");
}
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
int index = 0;
for (Certificate certificate : certificates) {
trustStore.setCertificateEntry("kubernetes-ca-" + index++, certificate);
}
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
);
trustManagerFactory.init(trustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
SSLParameters sslParameters = new SSLParameters();
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
return HttpClient.newBuilder()
.sslContext(sslContext)
.sslParameters(sslParameters)
.build();
}
}

View File

@@ -2,25 +2,23 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.service.TenantLifecycleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.*;
import java.util.stream.Collectors;
import java.util.List;
/**
* Периодически перечитывает tenants.json (mounted ConfigMap).
* Если ConfigMap был обновлён через K8s API, этот компонент
* Периодически перечитывает tenants.json (mounted Secret).
* Если Secret был обновлён через K8s API, этот компонент
* подхватит изменения и синхронизирует in-memory datasource'ы.
*
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
@@ -30,8 +28,7 @@ public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
private final TenantRoutingDataSource routingDataSource;
private final DataSource dataSource;
private final TenantLifecycleService tenantLifecycleService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${app.tenants.config-path:tenants.json}")
@@ -40,9 +37,8 @@ public class TenantConfigWatcher {
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
private String lastConfigHash = "";
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
this.routingDataSource = routingDataSource;
this.dataSource = dataSource;
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService) {
this.tenantLifecycleService = tenantLifecycleService;
}
/**
@@ -50,6 +46,10 @@ public class TenantConfigWatcher {
*/
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void watchForChanges() {
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
}
private void watchForChangesSerialized() {
try {
File file = new File(tenantsConfigPath);
if (!file.exists()) return;
@@ -62,20 +62,24 @@ public class TenantConfigWatcher {
}
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
lastConfigHash = hash;
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
lastConfigHash = hash;
} catch (Exception e) {
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
log.debug("Технические детали синхронизации конфига тенантов", e);
}
}
/**
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
* Обновляет хеш конфига после ручного обновления Secret с этого же пода.
*/
public void refreshHash() {
tenantLifecycleService.executeSerialized(this::refreshHashSerialized);
}
private void refreshHashSerialized() {
try {
File file = new File(tenantsConfigPath);
if (file.exists()) {
@@ -83,7 +87,9 @@ public class TenantConfigWatcher {
lastConfigHash = configHash(content);
}
} catch (Exception e) {
log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали обновления хеша тенантов", e);
}
}
@@ -100,63 +106,7 @@ public class TenantConfigWatcher {
* Синхронизирует in-memory тенантов с конфигом из файла.
*/
private void syncTenants(List<TenantConfig> newTenants) {
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
Set<String> newDomains = newTenants.stream()
.map(t -> t.getDomain().toLowerCase())
.collect(Collectors.toSet());
// Добавить новые тенанты
for (TenantConfig tenant : newTenants) {
String domain = tenant.getDomain().toLowerCase();
if (!current.containsKey(domain)) {
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
routingDataSource.addTenant(tenant);
// Инициализируем БД для нового тенанта
initDatabaseForTenant(tenant);
}
tenantLifecycleService.synchronizeFromPersistedConfig(newTenants);
}
// Удалить тенанты, которых больше нет в конфиге
for (String existingDomain : new ArrayList<>(current.keySet())) {
if (!newDomains.contains(existingDomain)) {
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
routingDataSource.removeTenant(existingDomain);
}
}
}
/**
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
* Если БД уже существует, но история Flyway пуста —
* делает baseline (считает V1_init.sql уже выполненным).
*/
public void initDatabaseForTenant(TenantConfig tenant) {
String domain = tenant.getDomain();
try {
TenantContext.setCurrentTenant(domain);
log.info("[{}] Запускаем миграции Flyway", domain);
// Получаем DataSource конкретно для этого тенанта
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
if (tenantDs == null) {
// Если ещё не resolve'нулся (первый запуск), берём обёртку
tenantDs = dataSource;
}
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
.dataSource(tenantDs)
.baselineOnMigrate(true)
.baselineVersion("1")
.load();
flyway.migrate();
log.info("[{}] Миграции Flyway успешно выполнены", domain);
} catch (Exception e) {
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
} finally {
TenantContext.clear();
}
}
}

View File

@@ -2,6 +2,8 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.service.TenantDatabaseMigrationService;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -17,6 +19,7 @@ import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.io.File;
import java.io.IOException;
import java.sql.Connection;
import java.util.*;
/**
@@ -45,7 +48,7 @@ public class TenantDataSourceConfig {
@Bean
@Primary
public DataSource dataSource() {
public DataSource dataSource(TenantDatabaseMigrationService migrationService) {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
// Загружаем тенантов из JSON (read-only ConfigMap mount)
@@ -57,16 +60,12 @@ public class TenantDataSourceConfig {
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
);
tenants.add(defaultTenant);
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
}
// Регистрируем тенантов
for (TenantConfig tenant : tenants) {
try {
routingDataSource.addTenant(tenant);
} catch (Exception e) {
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
}
registerPreparedTenant(routingDataSource, migrationService, tenant);
}
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
@@ -80,7 +79,9 @@ public class TenantDataSourceConfig {
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
"sa", ""
);
routingDataSource.addTenant(h2Fallback);
if (!registerPreparedTenant(routingDataSource, migrationService, h2Fallback)) {
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
}
}
return routingDataSource;
@@ -120,18 +121,64 @@ public class TenantDataSourceConfig {
private List<TenantConfig> loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
log.info("Tenants config file not found: {}", tenantsConfigPath);
log.info("Файл конфигурации тенантов не найден");
return new ArrayList<>();
}
try {
ObjectMapper mapper = new ObjectMapper();
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
log.info("Загружено конфигураций тенантов: {}", list.size());
return list;
} catch (IOException e) {
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали чтения конфигурации тенантов", e);
return new ArrayList<>();
}
}
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
TenantDatabaseMigrationService migrationService,
TenantConfig tenant) {
HikariDataSource candidate = null;
boolean activated = false;
try {
candidate = routingDataSource.prepareTenantDataSource(tenant);
try (Connection connection = candidate.getConnection()) {
if (!connection.isValid(5)) {
throw new IllegalStateException("База данных не подтвердила готовность подключения");
}
}
if (!tenant.getUrl().trim().startsWith("jdbc:h2:")) {
migrationService.migrate(candidate);
}
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
activated = true;
closeDataSource(previous == null ? null : previous.dataSource());
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
return true;
} catch (Exception startupFailure) {
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
tenant.getDomain(), startupFailure.getClass().getSimpleName());
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
return false;
} finally {
if (!activated) {
closeDataSource(candidate);
}
}
}
private void closeDataSource(DataSource dataSource) {
if (dataSource instanceof HikariDataSource hikariDataSource) {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
closeFailure.getClass().getSimpleName());
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
}
}
}
}

View File

@@ -8,143 +8,252 @@ import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Objects;
import java.util.Optional;
/**
* DataSource, который переключается между БД разных тенантов.
* На каждый запрос determineCurrentLookupKey() возвращает текущий тенант из TenantContext.
* Runtime-состояние публикуется одним неизменяемым snapshot, поэтому запрос
* никогда не видит конфигурацию и DataSource из разных версий.
*/
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class);
private final Map<String, TenantConfig> tenantConfigs = new ConcurrentHashMap<>();
private final Map<Object, Object> dataSources = new ConcurrentHashMap<>();
private boolean initialized = false;
private final Object lifecycleMonitor = new Object();
private volatile Map<String, TenantState> tenantStates = Map.of();
public TenantRoutingDataSource() {
// Устанавливаем пустой map чтобы afterPropertiesSet не падал
setTargetDataSources(new HashMap<>());
// Inherited lifecycle Spring требует initial target map. Runtime routing
// выполняется переопределённым determineTargetDataSource().
setTargetDataSources(Map.of());
setLenientFallback(false);
}
@Override
protected Object determineCurrentLookupKey() {
String tenant = TenantContext.getCurrentTenant();
if (tenant == null) {
// Нет HTTP контекста (JPA init, background tasks) — берём первый доступный
if (!dataSources.isEmpty()) {
return dataSources.keySet().iterator().next().toString();
}
return "default";
return determineLookupKey(tenantStates);
}
// HTTP запрос — возвращаем точный ключ тенанта
// Если тенанта нет — TenantInterceptor уже вернул 404
return tenant;
@Override
protected DataSource determineTargetDataSource() {
Map<String, TenantState> snapshot = tenantStates;
String lookupKey = determineLookupKey(snapshot);
TenantState state = snapshot.get(lookupKey);
if (state == null || state.dataSource() == null) {
throw new IllegalStateException("Подключение для выбранного тенанта не настроено");
}
return state.dataSource();
}
/**
* Добавляет тенант и создаёт для него HikariCP пул.
* Создаёт Hikari pool, но не включает его в маршрутизацию.
*/
public void addTenant(TenantConfig config) {
String domain = config.getDomain().toLowerCase();
HikariDataSource ds = createDataSource(config);
dataSources.put(domain, ds);
tenantConfigs.put(domain, config);
// Обновляем target data sources
setTargetDataSources(dataSources);
afterPropertiesSet();
initialized = true;
log.info("Added tenant '{}' -> {}", domain, config.getUrl());
public HikariDataSource prepareTenantDataSource(TenantConfig config) {
TenantConfig normalized = normalizeConfig(config);
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(normalized.getUrl());
dataSource.setUsername(normalized.getUsername());
dataSource.setPassword(normalized.getPassword());
dataSource.setPoolName("tenant-" + normalized.getDomain() + "-candidate-"
+ Integer.toUnsignedString(System.identityHashCode(dataSource)));
dataSource.setMaximumPoolSize(10);
dataSource.setMinimumIdle(2);
dataSource.setConnectionTimeout(10_000);
dataSource.setValidationTimeout(5_000);
dataSource.setIdleTimeout(300_000);
dataSource.setMaxLifetime(600_000);
dataSource.setInitializationFailTimeout(10_000);
return dataSource;
}
/**
* Удаляет тенант и закрывает его пул соединений.
* Атомарно публикует подготовленный DataSource и возвращает прежнее состояние.
*/
public void removeTenant(String domain) {
domain = domain.toLowerCase();
Object removed = dataSources.remove(domain);
tenantConfigs.remove(domain);
public TenantState swapTenant(TenantConfig config, DataSource candidate) {
Objects.requireNonNull(candidate, "Подготовленный DataSource обязателен");
TenantConfig normalized = normalizeConfig(config);
String domain = normalized.getDomain();
if (removed instanceof HikariDataSource ds) {
ds.close();
log.info("Removed and closed tenant '{}'", domain);
synchronized (lifecycleMonitor) {
Map<String, TenantState> current = tenantStates;
TenantState previous = copyState(current.get(domain));
Map<String, TenantState> updated = new LinkedHashMap<>(current);
updated.put(domain, new TenantState(domain, normalized, candidate));
tenantStates = immutableStates(updated);
return previous;
}
setTargetDataSources(dataSources);
afterPropertiesSet();
}
/**
* Проверяет подключение к БД для указанного тенанта.
* Атомарно исключает тенанта из маршрутизации, но не закрывает возвращённый DataSource.
*/
public TenantState removeTenantAtomically(String domain) {
String normalizedDomain = normalizeDomain(domain);
synchronized (lifecycleMonitor) {
Map<String, TenantState> current = tenantStates;
TenantState previous = current.get(normalizedDomain);
if (previous == null) {
return null;
}
Map<String, TenantState> updated = new LinkedHashMap<>(current);
updated.remove(normalizedDomain);
tenantStates = immutableStates(updated);
return copyState(previous);
}
}
public Optional<TenantState> snapshotTenant(String domain) {
if (domain == null || domain.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(copyState(tenantStates.get(normalizeDomain(domain))));
}
public Map<String, TenantConfig> snapshotTenantConfigs() {
Map<String, TenantState> snapshot = tenantStates;
Map<String, TenantConfig> configs = new LinkedHashMap<>();
snapshot.forEach((domain, state) -> configs.put(domain, copyConfig(state.config())));
return Collections.unmodifiableMap(configs);
}
public Optional<DataSource> getTenantDataSource(String domain) {
if (domain == null || domain.isBlank()) {
return Optional.empty();
}
TenantState state = tenantStates.get(normalizeDomain(domain));
return state == null ? Optional.empty() : Optional.of(state.dataSource());
}
/**
* Проверяет подключение к БД для зарегистрированного тенанта.
*/
public boolean testConnection(String domain) {
DataSource ds = (DataSource) dataSources.get(domain.toLowerCase());
if (ds == null) return false;
Optional<DataSource> tenantDataSource = getTenantDataSource(domain);
if (tenantDataSource.isEmpty()) {
return false;
}
try (Connection conn = ds.getConnection()) {
return conn.isValid(5);
try (Connection connection = tenantDataSource.get().getConnection()) {
return connection.isValid(5);
} catch (SQLException e) {
log.warn("Connection test failed for tenant '{}': {}", domain, e.getMessage());
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
normalizeDomain(domain), e.getClass().getSimpleName());
log.debug("Технические детали проверки подключения тенанта", e);
return false;
}
}
/**
* Тестирует подключение по произвольным параметрам (без регистрации тенанта).
* Тестирует подключение по произвольным параметрам без регистрации тенанта.
*/
public String testExternalConnection(String url, String username, String password) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(url);
ds.setUsername(username);
ds.setPassword(password);
ds.setMaximumPoolSize(1);
ds.setConnectionTimeout(5000);
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setMaximumPoolSize(1);
dataSource.setConnectionTimeout(5_000);
try (Connection conn = ds.getConnection()) {
if (conn.isValid(5)) {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(5)) {
return "OK";
}
return "Подключение не валидно";
} catch (Exception e) {
return e.getMessage();
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали проверки внешнего подключения", e);
return "Не удалось подключиться к базе данных";
} finally {
ds.close();
dataSource.close();
}
}
/**
* Возвращает неизменяемый снимок конфигураций для совместимости существующих consumers.
*/
public Map<String, TenantConfig> getTenantConfigs() {
return tenantConfigs;
return snapshotTenantConfigs();
}
public boolean hasTenant(String domain) {
return tenantConfigs.containsKey(domain.toLowerCase());
return domain != null && !domain.isBlank() && tenantStates.containsKey(normalizeDomain(domain));
}
public boolean isInitialized() {
return initialized && !dataSources.isEmpty();
return !tenantStates.isEmpty();
}
private HikariDataSource createDataSource(TenantConfig config) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(config.getUrl());
ds.setUsername(config.getUsername());
ds.setPassword(config.getPassword());
ds.setPoolName("tenant-" + config.getDomain());
ds.setMaximumPoolSize(10);
ds.setMinimumIdle(2);
ds.setConnectionTimeout(10000);
ds.setIdleTimeout(300000);
ds.setMaxLifetime(600000);
// Не падать при инициализации если БД недоступна
ds.setInitializationFailTimeout(-1);
return ds;
private String determineLookupKey(Map<String, TenantState> snapshot) {
String tenant = TenantContext.getCurrentTenant();
if (tenant != null) {
return tenant;
}
return snapshot.isEmpty() ? "default" : snapshot.keySet().iterator().next();
}
private Map<String, TenantState> immutableStates(Map<String, TenantState> source) {
Map<String, TenantState> copy = new LinkedHashMap<>();
source.forEach((domain, state) -> copy.put(domain, new TenantState(
domain,
copyConfig(state.config()),
state.dataSource()
)));
return Collections.unmodifiableMap(copy);
}
private TenantState copyState(TenantState state) {
if (state == null) {
return null;
}
return new TenantState(state.domain(), copyConfig(state.config()), state.dataSource());
}
private TenantConfig normalizeConfig(TenantConfig config) {
if (config == null) {
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
}
String domain = normalizeDomain(config.getDomain());
if (config.getUrl() == null || config.getUrl().isBlank()) {
throw new IllegalArgumentException("URL базы данных не может быть пустым");
}
String name = config.getName() == null || config.getName().isBlank()
? domain
: config.getName().trim();
return new TenantConfig(
name,
domain,
config.getUrl().trim(),
config.getUsername(),
config.getPassword()
);
}
private TenantConfig copyConfig(TenantConfig config) {
if (config == null) {
return null;
}
return new TenantConfig(
config.getName(),
config.getDomain(),
config.getUrl(),
config.getUsername(),
config.getPassword()
);
}
private String normalizeDomain(String domain) {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Домен не может быть пустым");
}
return domain.trim().toLowerCase(Locale.ROOT);
}
public record TenantState(String domain, TenantConfig config, DataSource dataSource) {
}
}

View File

@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -23,34 +24,34 @@ public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.activityTypeRepository = activityTypeRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@@ -123,27 +124,10 @@ public class AcademicCalendarController {
}
@PutMapping("/{id}/grid")
@Transactional
public ResponseEntity<?> saveGrid(@PathVariable Long id, @RequestBody List<AcademicCalendarGridDayDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
if (rows == null || rows.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика"));
}
try {
calendarDayRepository.deleteByCalendarId(id);
for (AcademicCalendarGridDayDto row : rows) {
AcademicCalendarDay day = buildGridDay(calendar, row);
calendarDayRepository.save(day);
}
scheduleGeneratorService.clearCache();
public ResponseEntity<?> saveGrid(@PathVariable("id") Long id,
@RequestBody List<AcademicCalendarGridDayDto> rows) {
calendarGridService.replaceGrid(id, rows);
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@GetMapping("/{id}/subjects")
@@ -213,44 +197,6 @@ public class AcademicCalendarController {
calendar.setCourseCount(request.courseCount());
}
private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null || row.courseNumber() <= 0
|| row.date() == null
|| row.weekNumber() == null || row.weekNumber() <= 0
|| row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) {
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
}
if (row.date().isBefore(calendar.getAcademicYear().getStartDate())
|| row.date().isAfter(calendar.getAcademicYear().getEndDate())) {
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
}
if (row.courseNumber() > calendar.getCourseCount()) {
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
}
AcademicCalendarActivityType activityType = resolveActivityType(row);
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(row.courseNumber());
day.setDate(row.date());
day.setWeekNumber(row.weekNumber());
day.setDayOfWeek(row.dayOfWeek());
day.setActivityType(activityType);
return day;
}
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
if (row.activityTypeId() != null) {
return activityTypeRepository.findById(row.activityTypeId())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
if (row.activityCode() != null && !row.activityCode().isBlank()) {
return activityTypeRepository.findByCode(row.activityCode().trim())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
throw new IllegalArgumentException("Код активности обязателен");
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");

View File

@@ -1,12 +1,14 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.config.tenant.ConfigMapUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -14,15 +16,14 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
/**
* API управления подключениями к базам данных (тенантами).
* Доступно только для ADMIN.
*
* При добавлении/удалении тенанта:
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
* Изменения проходят безопасный lifecycle: подготовка и миграция временного
* пула, сохранение desired-конфигурации и только затем локальная активация.
*/
@RestController
@RequestMapping("/api/database")
@@ -30,15 +31,15 @@ import java.util.Map;
public class DatabaseController {
private final TenantRoutingDataSource routingDataSource;
private final ConfigMapUpdater configMapUpdater;
private final TenantConfigWatcher configWatcher;
private final TenantLifecycleService tenantLifecycleService;
private final TenantConfigWatcher tenantConfigWatcher;
public DatabaseController(TenantRoutingDataSource routingDataSource,
ConfigMapUpdater configMapUpdater,
TenantConfigWatcher configWatcher) {
TenantLifecycleService tenantLifecycleService,
TenantConfigWatcher tenantConfigWatcher) {
this.routingDataSource = routingDataSource;
this.configMapUpdater = configMapUpdater;
this.configWatcher = configWatcher;
this.tenantLifecycleService = tenantLifecycleService;
this.tenantConfigWatcher = tenantConfigWatcher;
}
/**
@@ -87,41 +88,19 @@ public class DatabaseController {
*/
@PostMapping("/tenants")
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
Map<String, Object> result = new HashMap<>();
if (config.getDomain() == null || config.getDomain().isBlank()) {
result.put("success", false);
result.put("message", "Домен не может быть пустым");
return ResponseEntity.badRequest().body(result);
}
if (config.getUrl() == null || config.getUrl().isBlank()) {
result.put("success", false);
result.put("message", "URL базы данных не может быть пустым");
return ResponseEntity.badRequest().body(result);
}
if (routingDataSource.hasTenant(config.getDomain())) {
routingDataSource.removeTenant(config.getDomain());
}
try {
// 1. Добавить в in-memory (мгновенно на этом поде)
routingDataSource.addTenant(config);
// 2. Инициализировать БД (init.sql) если нужно
configWatcher.initDatabaseForTenant(config);
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
persistToConfigMap();
return tenantLifecycleService.executeSerialized(() -> {
TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config);
tenantConfigWatcher.refreshHash();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Тенант '" + config.getDomain() + "' добавлен");
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "Ошибка: " + e.getMessage());
return ResponseEntity.internalServerError().body(result);
});
} catch (IllegalArgumentException validationFailure) {
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
} catch (TenantLifecycleException lifecycleFailure) {
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
}
}
@@ -129,21 +108,23 @@ public class DatabaseController {
* Удалить тенант.
*/
@DeleteMapping("/tenants/{domain}")
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable String domain) {
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
try {
return tenantLifecycleService.executeSerialized(() -> {
TenantConfig removed = tenantLifecycleService.removeTenant(domain);
tenantConfigWatcher.refreshHash();
Map<String, Object> result = new HashMap<>();
if (!routingDataSource.hasTenant(domain)) {
result.put("success", false);
result.put("message", "Тенант '" + domain + "' не найден");
return ResponseEntity.status(404).body(result);
}
routingDataSource.removeTenant(domain);
persistToConfigMap();
result.put("success", true);
result.put("message", "Тенант '" + domain + "' удалён");
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
return ResponseEntity.ok(result);
});
} catch (NoSuchElementException notFound) {
return lifecycleError(HttpStatus.NOT_FOUND, notFound.getMessage());
} catch (IllegalArgumentException validationFailure) {
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
} catch (TenantLifecycleException lifecycleFailure) {
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
}
}
/**
@@ -171,14 +152,10 @@ public class DatabaseController {
return ResponseEntity.ok(result);
}
/**
* Сохраняет текущий список тенантов в K8s ConfigMap.
*/
private void persistToConfigMap() {
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
if (ok) {
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
}
private ResponseEntity<Map<String, Object>> lifecycleError(HttpStatus status, String message) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", message);
return ResponseEntity.status(status).body(result);
}
}

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.service.ScheduleConflictException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -20,6 +21,15 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ScheduleConflictException.class)
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Изменение создаёт конфликт расписания"
: exception.getMessage();
return error(HttpStatus.CONFLICT, message, request);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
HttpServletRequest request) {
@@ -68,6 +78,7 @@ public class GlobalExceptionHandler {
private String reason(HttpStatus status) {
return switch (status) {
case BAD_REQUEST -> "Некорректный запрос";
case CONFLICT -> "Конфликт";
case NOT_FOUND -> "Не найдено";
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
default -> status.name();

View File

@@ -1,11 +1,9 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleOverrideService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -17,132 +15,30 @@ import java.util.Map;
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleOverrideController {
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final TimeSlotRepository timeSlotRepository;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleOverrideService scheduleOverrideService;
public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
public ScheduleOverrideController(ScheduleOverrideService scheduleOverrideService) {
this.scheduleOverrideService = scheduleOverrideService;
}
@GetMapping
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
.toList();
return scheduleOverrideService.getAll();
}
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
return save(null, request);
return ResponseEntity.ok(scheduleOverrideService.create(request));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return save(id, request);
return ResponseEntity.ok(scheduleOverrideService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
scheduleOverrideRepository.deleteById(id);
scheduleGeneratorService.clearCache();
scheduleOverrideService.delete(id);
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
}
private ResponseEntity<?> save(Long id, ScheduleOverrideDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
ScheduleOverride override = id == null
? scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate())
.orElseGet(ScheduleOverride::new)
: scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new);
override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow());
override.setLessonDate(request.lessonDate());
override.setAction(request.action());
override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null));
override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null));
override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null));
override.setNewLessonFormat(clean(request.newLessonFormat()));
override.setComment(clean(request.comment()));
override.setCreatedBy(AuthContext.currentUserId());
ScheduleOverride saved = scheduleOverrideRepository.save(override);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
private String validate(ScheduleOverrideDto request) {
if (request == null) {
return "Передайте данные изменения расписания";
}
if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) {
return "Базовый слот расписания не найден";
}
if (request.lessonDate() == null) {
return "Дата пары обязательна";
}
if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) {
return "Недопустимое действие изменения расписания";
}
if (request.newClassroomId() != null) {
Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null);
if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
return "Активная аудитория не найдена";
}
}
if (request.newTeacherId() != null) {
User teacher = userRepository.findById(request.newTeacherId()).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return "Активный преподаватель не найден";
}
}
if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) {
return "Временной слот не найден";
}
return null;
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
override.getLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -2,663 +2,79 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/schedule-rules")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleService scheduleRuleService;
private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository;
private final SemesterRepository semesterRepository;
private final GroupRepository groupRepository;
private final TimeSlotRepository timeSlotRepository;
private final UserRepository userRepository;
private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
SemesterRepository semesterRepository,
GroupRepository groupRepository,
TimeSlotRepository timeSlotRepository,
UserRepository userRepository,
ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository;
this.semesterRepository = semesterRepository;
this.groupRepository = groupRepository;
this.timeSlotRepository = timeSlotRepository;
this.userRepository = userRepository;
this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleService scheduleRuleService) {
this.scheduleRuleService = scheduleRuleService;
}
@GetMapping
public List<ScheduleRuleDto> getAll(@RequestParam(required = false) Long semesterId,
@RequestParam(required = false) Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId)))
.map(this::toDto)
.toList();
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
@RequestParam(name = "groupId", required = false) Long groupId) {
return scheduleRuleService.getAll(semesterId, groupId);
}
@GetMapping("/{id}")
public ResponseEntity<?> getById(@PathVariable Long id) {
return scheduleRuleRepository.findByIdWithDetails(id)
.<ResponseEntity<?>>map(rule -> ResponseEntity.ok(toDto(rule)))
.orElseGet(() -> ResponseEntity.notFound().build());
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
return ResponseEntity.ok(scheduleRuleService.getById(id));
}
@PostMapping
@Transactional
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
String validationError = validateRule(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
try {
ScheduleRule rule = new ScheduleRule();
applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
return ResponseEntity.ok(scheduleRuleService.create(request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
}
}
@PutMapping("/{id}")
@Transactional
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) {
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateRule(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
try {
ScheduleRule candidate = new ScheduleRule();
applyRule(candidate, request);
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
if (conflict != null) {
return conflictResponse(conflict);
}
applyRule(rule, request);
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
return ResponseEntity.ok(scheduleRuleService.update(id, request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
public ResponseEntity<Map<String, String>> delete(@PathVariable("id") Long id) {
scheduleRuleService.archive(id);
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
}
private String validateRule(ScheduleRuleDto request) {
if (request.subjectId() == null) {
return "Дисциплина обязательна";
}
if (request.semesterId() == null) {
return "Семестр обязателен";
}
String hoursValidationError = validateTypeHours(request);
if (hoursValidationError != null) {
return hoursValidationError;
}
if (request.groupIds() == null || request.groupIds().isEmpty()) {
return "Нужно выбрать хотя бы одну группу";
}
if (request.slots() == null || request.slots().isEmpty()) {
return "Добавьте хотя бы один слот занятия";
}
return null;
}
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены");
}
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
rule.setSubject(subject);
rule.setSemester(semester);
rule.setLectureAcademicHours(request.lectureAcademicHours());
rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
rule.setPracticeAcademicHours(request.practiceAcademicHours());
rule.setLectureStartWeek(request.lectureStartWeek());
rule.setLaboratoryStartWeek(request.laboratoryStartWeek());
rule.setPracticeStartWeek(request.practiceStartWeek());
rule.getGroups().clear();
rule.getGroups().addAll(groups);
List<ScheduleRuleSlot> slots = new ArrayList<>();
for (ScheduleRuleSlotDto slotDto : request.slots()) {
slots.add(buildSlot(rule, slotDto));
}
validateTypeCoverage(rule, slots);
rule.getSlots().clear();
rule.getSlots().addAll(slots);
}
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"message", "Невозможно сохранить правило: слот занят",
"conflictRule", toDto(conflict.rule()),
"conflictFields", conflict.fields().stream().toList(),
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
));
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
default -> field;
};
}
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
.orElse(null);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> newSlots,
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot newSlot : newSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(newSlot, existingSlot)
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
fields.addAll(conflictFields(newSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> firstGroupIds = groupIds(first);
Set<Long> secondGroupIds = groupIds(second);
firstGroupIds.retainAll(secondGroupIds);
for (Long groupId : firstGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
firstSubgroupIds.retainAll(secondSubgroupIds);
if (!firstSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
Set<Long> ids = new HashSet<>();
if (slot.getScheduleRule() == null) {
return ids;
}
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
ids.add(group.getId());
}
return ids;
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return subgroupIdsForGroup(slot, groupId).isEmpty();
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
Set<Long> ids = new HashSet<>();
for (Subgroup subgroup : slot.getSubgroups()) {
if (subgroup.getStudentGroup() != null
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
ids.add(subgroup.getId());
}
}
return ids;
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
}
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
}
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (timeSlot.getTimeSlotScope() == null
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroups(subgroups);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
slot.setLessonFormat(slotDto.lessonFormat().trim());
return slot;
}
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
if (slotDto.subgroupIds() != null) {
slotDto.subgroupIds().stream()
.filter(Objects::nonNull)
.forEach(subgroupIds::add);
}
if (slotDto.subgroupId() != null) {
subgroupIds.add(slotDto.subgroupId());
}
if (subgroupIds.isEmpty()) {
return new LinkedHashSet<>();
}
if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
}
Set<Long> ruleGroupIds = rule.getGroups().stream()
.map(StudentGroup::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> subgroupGroupIds = new HashSet<>();
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
for (Long subgroupId : subgroupIds) {
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
Long subgroupGroupId = subgroup.getStudentGroup().getId();
if (!ruleGroupIds.contains(subgroupGroupId)) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
if (!subgroupGroupIds.add(subgroupGroupId)) {
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
}
subgroups.add(subgroup);
}
return subgroups;
}
private String validateTypeHours(ScheduleRuleDto request) {
if (isMissing(request.lectureAcademicHours())
|| isMissing(request.laboratoryAcademicHours())
|| isMissing(request.practiceAcademicHours())) {
return "Укажите количество часов для всех типов занятий";
}
if (isNegative(request.lectureAcademicHours())
|| isNegative(request.laboratoryAcademicHours())
|| isNegative(request.practiceAcademicHours())) {
return "Количество часов по типам занятий не может быть отрицательным";
}
if (isInvalidStartWeek(request.lectureStartWeek())
|| isInvalidStartWeek(request.laboratoryStartWeek())
|| isInvalidStartWeek(request.practiceStartWeek())) {
return "Неделя начала занятий должна быть больше нуля";
}
int totalHours = valueOrZero(request.lectureAcademicHours())
+ valueOrZero(request.laboratoryAcademicHours())
+ valueOrZero(request.practiceAcademicHours());
if (totalHours <= 0) {
return "Укажите часы хотя бы для одного типа занятий";
}
return null;
}
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
for (ScheduleRuleSlot slot : slots) {
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
}
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int hours = rule.academicHoursFor(category);
boolean hasSlots = categoriesWithSlots.contains(category);
if (hours > 0 && !hasSlots) {
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот");
}
if (hours == 0 && hasSlots) {
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа");
}
}
}
private boolean isMissing(Integer value) {
return value == null;
}
private boolean isNegative(Integer value) {
return value < 0;
}
private boolean isInvalidStartWeek(Integer value) {
return value == null || value <= 0;
}
private int valueOrZero(Integer value) {
return value == null ? 0 : value;
}
private ScheduleRuleDto toDto(ScheduleRule rule) {
List<StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
.thenComparing(ScheduleRuleSlot::getId))
.map(this::toSlotDto)
.toList();
return new ScheduleRuleDto(
rule.getId(),
rule.getSubject().getId(),
rule.getSubject().getName(),
rule.getSemester().getId(),
rule.getSemester().getAcademicYear().getTitle(),
rule.getSemester().getSemesterType(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto(
slot.getId(),
slot.getDayOfWeek(),
dayName(slot.getDayOfWeek()),
slot.getParity(),
timeSlot.getId(),
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),
slot.getClassroom().getName(),
slot.getLessonType().getId(),
slot.getLessonType().getLessonType(),
slot.getLessonFormat()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
return "Неизвестно";
}
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
}
private String formatTimeSlot(TimeSlot timeSlot) {
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
}
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
return slot.getSubgroups().stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
}
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
private ResponseEntity<Map<String, Object>> conflictResponse(ScheduleRuleConflictException exception) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", exception.getMessage());
if (exception.getConflictRule() != null) {
body.put("conflictRule", exception.getConflictRule());
}
body.put("conflictFields", exception.getConflictFields());
body.put("conflictReasons", exception.getConflictReasons());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
}

View File

@@ -1,11 +1,24 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthRefreshToken;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
Optional<AuthRefreshToken> findByTokenHash(String tokenHash);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT token
FROM AuthRefreshToken token
JOIN FETCH token.user
WHERE token.tokenHash = :tokenHash
""")
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
}

View File

@@ -1,7 +1,9 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleOverride;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -26,4 +28,23 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
);
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
@Query("select o.lessonDate from ScheduleOverride o where o.id = :id")
Optional<LocalDate> findLessonDateById(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from ScheduleOverride o where o.id = :id")
Optional<ScheduleOverride> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select o
from ScheduleOverride o
where o.baseRuleSlot.id = :baseRuleSlotId
and o.lessonDate = :lessonDate
""")
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDateForUpdate(
@Param("baseRuleSlotId") Long baseRuleSlotId,
@Param("lessonDate") LocalDate lessonDate
);
}

View File

@@ -1,11 +1,14 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRule;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
@@ -76,6 +79,34 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
""")
List<ScheduleRule> findAllWithDetails();
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select rule from ScheduleRule rule where rule.id = :id")
Optional<ScheduleRule> findByIdForUpdate(@Param("id") Long id);
@Query("""
select distinct rule
from ScheduleRule rule
left join fetch rule.subject
left join fetch rule.semester semester
left join fetch semester.academicYear
left join fetch rule.groups groups
left join fetch rule.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 semester.id = :semesterId
and rule.status <> 'ARCHIVED'
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
order by rule.id
""")
List<ScheduleRule> findActiveBySemesterIdWithDetails(
@Param("semesterId") Long semesterId,
@Param("excludeRuleId") Long excludeRuleId
);
@Query("""
select distinct r
from ScheduleRule r
@@ -92,5 +123,5 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where r.id = :id
""")
java.util.Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
}

View File

@@ -2,9 +2,14 @@ package com.magistr.app.repository;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -15,4 +20,17 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select semester from Semester semester where semester.id = :id")
Optional<Semester> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select semester
from Semester semester
where semester.id in :ids
order by semester.id
""")
List<Semester> findAllByIdForUpdateOrderById(@Param("ids") Collection<Long> ids);
}

View File

@@ -10,6 +10,12 @@ import java.util.Optional;
public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
interface SubgroupGroupReference {
Long getSubgroupId();
Long getGroupId();
}
@Query("""
select subgroup
from Subgroup subgroup
@@ -74,4 +80,11 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
""")
long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
@Query("""
select subgroup.id as subgroupId, subgroup.studentGroup.id as groupId
from Subgroup subgroup
where subgroup.id in :subgroupIds
""")
List<SubgroupGroupReference> findGroupReferencesByIdIn(@Param("subgroupIds") List<Long> subgroupIds);
}

View File

@@ -0,0 +1,157 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarDay;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
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.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
@Service
public class AcademicCalendarGridService {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.activityTypeRepository = activityTypeRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public void replaceGrid(Long id, List<AcademicCalendarGridDayDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id)
.orElseThrow(() -> new NoSuchElementException("Календарный график не найден"));
if (rows == null || rows.isEmpty()) {
throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика");
}
List<AcademicCalendarDay> replacement = buildReplacement(calendar, rows);
calendarDayRepository.deleteByCalendarId(id);
calendarDayRepository.flush();
calendarDayRepository.saveAll(replacement);
calendarDayRepository.flush();
clearScheduleCacheAfterCommit();
}
private List<AcademicCalendarDay> buildReplacement(AcademicCalendar calendar,
List<AcademicCalendarGridDayDto> rows) {
List<AcademicCalendarDay> replacement = new ArrayList<>(rows.size());
Set<GridBusinessKey> uniqueRows = new HashSet<>();
for (AcademicCalendarGridDayDto row : rows) {
if (row == null) {
throw new IllegalArgumentException("Строка календарного графика не может быть пустой");
}
validateRequiredFields(row);
validateCourse(calendar, row.courseNumber());
validateDate(calendar.getAcademicYear(), row.date());
validateWeek(calendar.getAcademicYear(), row.date(), row.weekNumber());
validateDayOfWeek(row.date(), row.dayOfWeek());
GridBusinessKey businessKey = new GridBusinessKey(row.courseNumber(), row.date());
if (!uniqueRows.add(businessKey)) {
throw new IllegalArgumentException("Для курса и даты можно указать только одну активность");
}
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(row.courseNumber());
day.setDate(row.date());
day.setWeekNumber(row.weekNumber());
day.setDayOfWeek(row.dayOfWeek());
day.setActivityType(resolveActivityType(row));
replacement.add(day);
}
return replacement;
}
private void validateRequiredFields(AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null
|| row.date() == null
|| row.weekNumber() == null
|| row.weekNumber() <= 0
|| row.dayOfWeek() == null
|| row.dayOfWeek() < 1
|| row.dayOfWeek() > 7) {
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
}
}
private void validateCourse(AcademicCalendar calendar, Integer courseNumber) {
if (courseNumber < 1 || courseNumber > calendar.getCourseCount()) {
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
}
}
private void validateDate(AcademicYear academicYear, LocalDate date) {
if (date.isBefore(academicYear.getStartDate()) || date.isAfter(academicYear.getEndDate())) {
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
}
}
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
int expectedWeek = Math.toIntExact(daysFromYearStart / 7 + 1);
if (weekNumber != expectedWeek) {
throw new IllegalArgumentException("Номер недели не соответствует дате учебного года");
}
}
private void validateDayOfWeek(LocalDate date, Integer dayOfWeek) {
if (dayOfWeek != date.getDayOfWeek().getValue()) {
throw new IllegalArgumentException("День недели не соответствует дате");
}
}
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
if (row.activityTypeId() != null) {
return activityTypeRepository.findById(row.activityTypeId())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
if (row.activityCode() != null && !row.activityCode().isBlank()) {
return activityTypeRepository.findByCode(row.activityCode().trim())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
throw new IllegalArgumentException("Код активности обязателен");
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
scheduleGeneratorService.clearCache();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
scheduleGeneratorService.clearCache();
}
});
}
private record GridBusinessKey(Integer courseNumber, LocalDate date) {
}
}

View File

@@ -0,0 +1,44 @@
package com.magistr.app.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
public class PostgreSqlAdvisoryLock {
private static final int SCHEDULE_OVERRIDE_DATE_NAMESPACE = 0x4D414744;
private static final long SCHEDULE_OVERRIDE_ID_NAMESPACE_MASK = Long.MIN_VALUE;
@PersistenceContext
private EntityManager entityManager;
public void lockScheduleOverrideDate(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Дата блокировки расписания обязательна");
}
int exactEpochDay;
try {
exactEpochDay = Math.toIntExact(date.toEpochDay());
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("Дата находится за пределами диапазона PostgreSQL", exception);
}
entityManager.createNativeQuery("select pg_advisory_xact_lock(?1, ?2)")
.setParameter(1, SCHEDULE_OVERRIDE_DATE_NAMESPACE)
.setParameter(2, exactEpochDay)
.getSingleResult();
}
public void lockScheduleOverrideId(Long overrideId) {
if (overrideId == null || overrideId <= 0) {
throw new IllegalArgumentException("Идентификатор точечного изменения должен быть положительным");
}
// Одноаргументное пространство advisory-lock не пересекается с двухаргументным пространством дат.
long lockKey = overrideId ^ SCHEDULE_OVERRIDE_ID_NAMESPACE_MASK;
entityManager.createNativeQuery("select pg_advisory_xact_lock(?1)")
.setParameter(1, lockKey)
.getSingleResult();
}
}

View File

@@ -0,0 +1,148 @@
package com.magistr.app.service;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Отложенно закрывает пулы, исключённые из актуального снимка тенантов.
* Это даёт уже начатым запросам ограниченное время на завершение.
*/
@Service
public class RetiredTenantPoolService {
private static final Logger log = LoggerFactory.getLogger(RetiredTenantPoolService.class);
private final long graceTimeoutNanos;
private final long pollIntervalMillis;
private final ScheduledExecutorService scheduler;
private final Map<HikariDataSource, RetiredPool> retiredPools = new ConcurrentHashMap<>();
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
public RetiredTenantPoolService(
@Value("${app.tenants.pool-retirement.grace-timeout:30s}") Duration graceTimeout,
@Value("${app.tenants.pool-retirement.poll-interval:250ms}") Duration pollInterval) {
if (graceTimeout.isNegative()) {
throw new IllegalArgumentException("Период ожидания закрытия пула не может быть отрицательным");
}
if (pollInterval.isZero() || pollInterval.isNegative()) {
throw new IllegalArgumentException("Интервал проверки закрытия пула должен быть положительным");
}
this.graceTimeoutNanos = graceTimeout.toNanos();
this.pollIntervalMillis = Math.max(1L, pollInterval.toMillis());
this.scheduler = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "retired-tenant-pool-drain");
thread.setDaemon(true);
return thread;
});
this.scheduler.scheduleWithFixedDelay(
this::pollRetiredPools,
this.pollIntervalMillis,
this.pollIntervalMillis,
TimeUnit.MILLISECONDS
);
}
/**
* Передаёт выведенный из эксплуатации Hikari-пул на асинхронное закрытие.
*
* @param dataSource источник данных, ранее удалённый из активного снимка маршрутизации
*/
public void retire(DataSource dataSource) {
if (!(dataSource instanceof HikariDataSource hikariDataSource)) {
log.warn("Источник данных нельзя передать на отложенное закрытие: поддерживаются только Hikari-пулы");
return;
}
if (hikariDataSource.isClosed()) {
return;
}
if (shuttingDown.get()) {
closePool(hikariDataSource, "остановка приложения");
return;
}
long deadlineNanos = System.nanoTime() + graceTimeoutNanos;
RetiredPool previous = retiredPools.putIfAbsent(
hikariDataSource,
new RetiredPool(deadlineNanos)
);
if (previous == null) {
log.info("Пул подключений передан на отложенное закрытие: pool={}",
hikariDataSource.getPoolName());
}
}
private void pollRetiredPools() {
if (shuttingDown.get()) {
return;
}
long nowNanos = System.nanoTime();
retiredPools.forEach((pool, retiredPool) -> pollPool(pool, retiredPool, nowNanos));
}
private void pollPool(HikariDataSource pool, RetiredPool retiredPool, long nowNanos) {
if (pool.isClosed()) {
retiredPools.remove(pool, retiredPool);
return;
}
boolean graceExpired = nowNanos - retiredPool.deadlineNanos() >= 0;
boolean drained = hasNoActiveConnections(pool);
if ((drained || graceExpired) && retiredPools.remove(pool, retiredPool)) {
closePool(pool, drained ? "активные подключения завершены" : "истёк период ожидания");
}
}
private boolean hasNoActiveConnections(HikariDataSource pool) {
try {
HikariPoolMXBean poolMxBean = pool.getHikariPoolMXBean();
return poolMxBean != null && poolMxBean.getActiveConnections() == 0;
} catch (RuntimeException exception) {
log.warn("Не удалось проверить активные подключения выведенного пула: pool={}, errorType={}",
pool.getPoolName(), exception.getClass().getSimpleName());
return false;
}
}
private void closePool(HikariDataSource pool, String reason) {
try {
pool.close();
log.info("Выведенный пул подключений закрыт: pool={}, reason={}", pool.getPoolName(), reason);
} catch (RuntimeException exception) {
log.error("Не удалось закрыть выведенный пул подключений: pool={}, errorType={}",
pool.getPoolName(), exception.getClass().getSimpleName());
}
}
@PreDestroy
public void shutdown() {
if (!shuttingDown.compareAndSet(false, true)) {
return;
}
scheduler.shutdownNow();
retiredPools.forEach((pool, retiredPool) -> {
if (retiredPools.remove(pool, retiredPool)) {
closePool(pool, "остановка приложения");
}
});
}
private record RetiredPool(long deadlineNanos) {
}
}

View File

@@ -0,0 +1,8 @@
package com.magistr.app.service;
public class ScheduleConflictException extends RuntimeException {
public ScheduleConflictException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,532 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ScheduleOverrideService {
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final TimeSlotRepository timeSlotRepository;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleQueryService scheduleQueryService;
private final ScheduleGeneratorService scheduleGeneratorService;
private final PostgreSqlAdvisoryLock advisoryLock;
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
SubgroupRepository subgroupRepository,
ScheduleQueryService scheduleQueryService,
ScheduleGeneratorService scheduleGeneratorService,
PostgreSqlAdvisoryLock advisoryLock) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleQueryService = scheduleQueryService;
this.scheduleGeneratorService = scheduleGeneratorService;
this.advisoryLock = advisoryLock;
}
@Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
.toList();
}
@Transactional
public ScheduleOverrideDto create(ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideDate(command.lessonDate());
ScheduleOverride current = scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
.orElse(null);
return validateAndPersist(current, command);
}
@Transactional
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideId(id);
LocalDate observedOldDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
lockDatesInStableOrder(observedOldDate, command.lessonDate());
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
if (!Objects.equals(observedOldDate, current.getLessonDate())) {
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
.filter(other -> !Objects.equals(other.getId(), current.getId()))
.ifPresent(other -> {
throw new ScheduleConflictException(
"Для выбранной базовой пары и даты уже существует точечное изменение"
);
});
return validateAndPersist(current, command);
}
@Transactional
public void delete(Long id) {
advisoryLock.lockScheduleOverrideId(id);
LocalDate observedDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
advisoryLock.lockScheduleOverrideDate(observedDate);
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
if (!Objects.equals(observedDate, current.getLessonDate())) {
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
scheduleOverrideRepository.delete(current);
scheduleOverrideRepository.flush();
scheduleGeneratorService.clearCache();
}
private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) {
ResolvedReferences references = resolveReferences(command);
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
.toList();
if (sourceOccurrences.isEmpty()) {
throw new IllegalArgumentException(
"На выбранную дату базовая пара не формируется по правилам расписания"
);
}
ScheduleOverride candidate = buildCandidate(command, references);
if (!"CANCEL".equals(command.action())) {
validateEffectiveResult(current, command, candidate, baseDay, sourceOccurrences);
}
ScheduleOverride target = current == null ? new ScheduleOverride() : current;
copyCandidate(candidate, target);
target.setComment(command.comment());
target.setCreatedBy(AuthContext.currentUserId());
ScheduleOverride saved = scheduleOverrideRepository.saveAndFlush(target);
scheduleGeneratorService.clearCache();
return toDto(saved);
}
private void validateEffectiveResult(ScheduleOverride current,
OverrideCommand command,
ScheduleOverride candidate,
List<RenderedLessonDto> baseDay,
List<RenderedLessonDto> sourceOccurrences) {
Long currentId = current == null ? null : current.getId();
List<ScheduleOverride> effectiveOverrides = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate())
.stream()
.filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
.filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
.collect(Collectors.toCollection(ArrayList::new));
effectiveOverrides.add(candidate);
List<RenderedLessonDto> effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups(
baseDay,
command.lessonDate(),
effectiveOverrides
);
List<RenderedLessonDto> candidateOccurrences = effectiveDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
.toList();
if (candidateOccurrences.isEmpty()) {
throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару");
}
Set<EffectiveSourceTuple> sourceTuples = sourceOccurrences.stream()
.map(EffectiveSourceTuple::from)
.collect(Collectors.toSet());
Set<EffectiveSourceTuple> candidateTuples = candidateOccurrences.stream()
.map(EffectiveSourceTuple::from)
.collect(Collectors.toSet());
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
if (sourceTuples.equals(candidateTuples)) {
throw new IllegalArgumentException(
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
);
}
List<RenderedLessonDto> otherOccurrences = effectiveDay.stream()
.filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.toList();
Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
}
private void validateActionSpecificChange(String action,
List<RenderedLessonDto> sourceOccurrences,
List<RenderedLessonDto> candidateOccurrences) {
if ("MOVE".equals(action)) {
Set<MoveTuple> source = sourceOccurrences.stream()
.map(MoveTuple::from)
.collect(Collectors.toSet());
Set<MoveTuple> candidate = candidateOccurrences.stream()
.map(MoveTuple::from)
.collect(Collectors.toSet());
if (source.equals(candidate)) {
throw new IllegalArgumentException(
"Перенос должен фактически менять время пары или аудиторию"
);
}
}
if ("REPLACE".equals(action)) {
Set<ReplacementTuple> source = sourceOccurrences.stream()
.map(ReplacementTuple::from)
.collect(Collectors.toSet());
Set<ReplacementTuple> candidate = candidateOccurrences.stream()
.map(ReplacementTuple::from)
.collect(Collectors.toSet());
if (source.equals(candidate)) {
throw new IllegalArgumentException(
"Замена должна фактически менять преподавателя, аудиторию или формат пары"
);
}
}
}
private void validateResourceConflicts(List<RenderedLessonDto> candidates,
List<RenderedLessonDto> others,
Map<Long, Long> subgroupToGroup) {
for (RenderedLessonDto candidate : candidates) {
for (RenderedLessonDto other : others) {
if (!timeOverlaps(candidate, other)) {
continue;
}
if (candidate.teacherId() != null && Objects.equals(candidate.teacherId(), other.teacherId())) {
throw new ScheduleConflictException(
"Конфликт расписания: преподаватель уже занят в указанное время"
);
}
if (candidate.classroomId() != null && Objects.equals(candidate.classroomId(), other.classroomId())) {
throw new ScheduleConflictException(
"Конфликт расписания: аудитория уже занята в указанное время"
);
}
if (audiencesOverlap(candidate, other, subgroupToGroup)) {
throw new ScheduleConflictException(
"Конфликт расписания: группа или подгруппа уже занята в указанное время"
);
}
}
}
}
private boolean timeOverlaps(RenderedLessonDto first, RenderedLessonDto second) {
LocalTime firstStart = first.startTime();
LocalTime firstEnd = first.endTime();
LocalTime secondStart = second.startTime();
LocalTime secondEnd = second.endTime();
if (firstStart == null || firstEnd == null || secondStart == null || secondEnd == null) {
throw new IllegalStateException("Невозможно проверить конфликт: у пары не задано время");
}
if (!firstStart.isBefore(firstEnd) || !secondStart.isBefore(secondEnd)) {
throw new IllegalStateException("Невозможно проверить конфликт: границы времени пары некорректны");
}
return firstStart.isBefore(secondEnd) && secondStart.isBefore(firstEnd);
}
private boolean audiencesOverlap(RenderedLessonDto first,
RenderedLessonDto second,
Map<Long, Long> subgroupToGroup) {
Set<Long> commonGroupIds = new HashSet<>(safeIds(first.groupIds()));
commonGroupIds.retainAll(safeIds(second.groupIds()));
for (Long groupId : commonGroupIds) {
Set<Long> firstSubgroups = subgroupsForGroup(first, groupId, subgroupToGroup);
Set<Long> secondSubgroups = subgroupsForGroup(second, groupId, subgroupToGroup);
if (firstSubgroups.isEmpty() || secondSubgroups.isEmpty()) {
return true;
}
if (!java.util.Collections.disjoint(firstSubgroups, secondSubgroups)) {
return true;
}
}
return false;
}
private Set<Long> subgroupsForGroup(RenderedLessonDto lesson,
Long groupId,
Map<Long, Long> subgroupToGroup) {
return safeIds(lesson.subgroupIds()).stream()
.filter(subgroupId -> Objects.equals(subgroupToGroup.get(subgroupId), groupId))
.collect(Collectors.toSet());
}
private Map<Long, Long> loadSubgroupGroupMap(Collection<RenderedLessonDto> first,
Collection<RenderedLessonDto> second) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
first.forEach(lesson -> subgroupIds.addAll(safeIds(lesson.subgroupIds())));
second.forEach(lesson -> subgroupIds.addAll(safeIds(lesson.subgroupIds())));
if (subgroupIds.isEmpty()) {
return Map.of();
}
Map<Long, Long> result = new HashMap<>();
subgroupRepository.findGroupReferencesByIdIn(List.copyOf(subgroupIds))
.forEach(reference -> result.put(reference.getSubgroupId(), reference.getGroupId()));
return result;
}
private Set<Long> safeIds(List<Long> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
return values.stream()
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
private ResolvedReferences resolveReferences(OverrideCommand command) {
ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
TimeSlot newTimeSlot = command.newTimeSlotId() == null
? null
: timeSlotRepository.findById(command.newTimeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
Classroom newClassroom = command.newClassroomId() == null
? null
: classroomRepository.findById(command.newClassroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
User newTeacher = command.newTeacherId() == null
? null
: userRepository.findById(command.newTeacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (newTeacher != null
&& (newTeacher.getRole() != Role.TEACHER || !newTeacher.isActiveOn(command.lessonDate()))) {
throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
}
if (newClassroom != null
&& (!newClassroom.isActiveOn(command.lessonDate())
|| !Boolean.TRUE.equals(newClassroom.getIsAvailable()))) {
throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
}
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
}
private ScheduleOverride buildCandidate(OverrideCommand command, ResolvedReferences references) {
ScheduleOverride candidate = new ScheduleOverride();
candidate.setBaseRuleSlot(references.baseRuleSlot());
candidate.setLessonDate(command.lessonDate());
candidate.setAction(command.action());
candidate.setNewTimeSlot(references.newTimeSlot());
candidate.setNewClassroom(references.newClassroom());
candidate.setNewTeacher(references.newTeacher());
candidate.setNewLessonFormat(command.newLessonFormat());
return candidate;
}
private void copyCandidate(ScheduleOverride source, ScheduleOverride target) {
target.setBaseRuleSlot(source.getBaseRuleSlot());
target.setLessonDate(source.getLessonDate());
target.setAction(source.getAction());
target.setNewTimeSlot(source.getNewTimeSlot());
target.setNewClassroom(source.getNewClassroom());
target.setNewTeacher(source.getNewTeacher());
target.setNewLessonFormat(source.getNewLessonFormat());
}
private OverrideCommand validateAndNormalize(ScheduleOverrideDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные изменения расписания");
}
if (request.baseRuleSlotId() == null) {
throw new IllegalArgumentException("Базовый слот расписания обязателен");
}
if (request.lessonDate() == null) {
throw new IllegalArgumentException("Дата пары обязательна");
}
String action = clean(request.action());
action = action == null ? null : action.toUpperCase(Locale.ROOT);
if (action == null || !ACTIONS.contains(action)) {
throw new IllegalArgumentException("Недопустимое действие изменения расписания");
}
String lessonFormat = clean(request.newLessonFormat());
if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) {
throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»");
}
if ("CANCEL".equals(action)
&& (request.newTimeSlotId() != null
|| request.newClassroomId() != null
|| request.newTeacherId() != null
|| lessonFormat != null)) {
throw new IllegalArgumentException("Для отмены пары новые параметры должны быть пустыми");
}
if ("MOVE".equals(action)
&& request.newTimeSlotId() == null
&& request.newClassroomId() == null) {
throw new IllegalArgumentException("Для переноса укажите новое время или аудиторию");
}
if ("REPLACE".equals(action)
&& request.newTeacherId() == null
&& request.newClassroomId() == null
&& lessonFormat == null) {
throw new IllegalArgumentException(
"Для замены укажите нового преподавателя, аудиторию или формат пары"
);
}
return new OverrideCommand(
request.baseRuleSlotId(),
request.lessonDate(),
action,
request.newTimeSlotId(),
request.newClassroomId(),
request.newTeacherId(),
lessonFormat,
clean(request.comment())
);
}
private void lockDatesInStableOrder(LocalDate first, LocalDate second) {
List.of(first, second).stream()
.distinct()
.sorted(Comparator.naturalOrder())
.forEach(advisoryLock::lockScheduleOverrideDate);
}
private boolean sameKey(ScheduleOverride override, Long baseRuleSlotId, LocalDate lessonDate) {
return override.getBaseRuleSlot() != null
&& Objects.equals(override.getBaseRuleSlot().getId(), baseRuleSlotId)
&& Objects.equals(override.getLessonDate(), lessonDate);
}
private NoSuchElementException notFound(Long id) {
return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено");
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
override.getLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private record OverrideCommand(
Long baseRuleSlotId,
LocalDate lessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String newLessonFormat,
String comment
) {
}
private record ResolvedReferences(
ScheduleRuleSlot baseRuleSlot,
TimeSlot newTimeSlot,
Classroom newClassroom,
User newTeacher
) {
}
private record EffectiveSourceTuple(
Long timeSlotId,
LocalTime startTime,
LocalTime endTime,
Long teacherId,
Long classroomId,
String lessonFormat
) {
private static EffectiveSourceTuple from(RenderedLessonDto lesson) {
return new EffectiveSourceTuple(
lesson.timeSlotId(),
lesson.startTime(),
lesson.endTime(),
lesson.teacherId(),
lesson.classroomId(),
lesson.lessonFormat()
);
}
}
private record MoveTuple(
LocalTime startTime,
LocalTime endTime,
Long classroomId
) {
private static MoveTuple from(RenderedLessonDto lesson) {
return new MoveTuple(lesson.startTime(), lesson.endTime(), lesson.classroomId());
}
}
private record ReplacementTuple(
Long teacherId,
Long classroomId,
String lessonFormat
) {
private static ReplacementTuple from(RenderedLessonDto lesson) {
return new ReplacementTuple(lesson.teacherId(), lesson.classroomId(), lesson.lessonFormat());
}
}
}

View File

@@ -33,6 +33,48 @@ public class ScheduleQueryService {
this.groupLifecycleService = groupLifecycleService;
}
/**
* Строит фактическое базовое расписание на дату для всех групп без UI-ограничения
* количества групп, применяемого к широким пользовательским поисковым запросам.
*/
public List<RenderedLessonDto> buildBaseDayForAllGroups(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Дата расписания обязательна");
}
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
.toList();
return deduplicate(generatedLessons);
}
/**
* Строит результирующее расписание дня с переданным снимком точечных изменений.
* Репозиторий точечных изменений здесь намеренно не читается: вызывающий код может атомарно
* проверить ещё не сохранённое изменение внутри своей транзакции.
*/
public List<RenderedLessonDto> buildEffectiveDayForAllGroups(
LocalDate date,
Collection<ScheduleOverride> overrides
) {
return buildEffectiveDayForAllGroups(buildBaseDayForAllGroups(date), date, overrides);
}
/**
* Вариант без повторной генерации базового дня для транзакционных проверок.
*/
public List<RenderedLessonDto> buildEffectiveDayForAllGroups(
List<RenderedLessonDto> baseDay,
LocalDate date,
Collection<ScheduleOverride> overrides
) {
if (date == null) {
throw new IllegalArgumentException("Дата расписания обязательна");
}
List<RenderedLessonDto> safeBaseDay = baseDay == null ? List.of() : baseDay;
return deduplicate(applyOverrides(safeBaseDay, date, overrides));
}
public List<RenderedLessonDto> search(Long groupId,
Long teacherId,
Long classroomId,
@@ -45,7 +87,8 @@ public class ScheduleQueryService {
LocalDate endDate) {
validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons;
if (groupId == null && departmentId == null && teacherId != null) {
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
if (teacherOnlySearch) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else {
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
@@ -53,7 +96,16 @@ public class ScheduleQueryService {
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
}
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate);
if (teacherOnlySearch) {
generatedLessons = addOverrideSourceOccurrencesForTeacher(
generatedLessons,
teacherId,
overrideSnapshot
);
}
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
@@ -64,10 +116,50 @@ public class ScheduleQueryService {
return deduplicate(lessons);
}
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons, LocalDate startDate, LocalDate endDate) {
Map<String, ScheduleOverride> overrides = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate)
private List<RenderedLessonDto> addOverrideSourceOccurrencesForTeacher(
List<RenderedLessonDto> generatedLessons,
Long teacherId,
Collection<ScheduleOverride> overrideSnapshot
) {
Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.filter(override -> override.getLessonDate() != null)
.filter(override -> override.getBaseRuleSlot() != null)
.filter(override -> override.getBaseRuleSlot().getId() != null)
.filter(override -> override.getNewTeacher() != null)
.filter(override -> Objects.equals(override.getNewTeacher().getId(), teacherId))
.collect(Collectors.groupingBy(
ScheduleOverride::getLessonDate,
TreeMap::new,
Collectors.mapping(
override -> override.getBaseRuleSlot().getId(),
Collectors.toCollection(LinkedHashSet::new)
)
));
if (relevantSlotsByDate.isEmpty()) {
return generatedLessons;
}
List<RenderedLessonDto> supplementedLessons = new ArrayList<>(generatedLessons);
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> buildBaseDayForAllGroups(date).stream()
.filter(lesson -> date.equals(lesson.date()))
.filter(lesson -> baseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
.forEach(supplementedLessons::add));
return supplementedLessons;
}
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons,
LocalDate exactDate,
Collection<ScheduleOverride> suppliedOverrides) {
Map<String, ScheduleOverride> overrides = Optional.ofNullable(suppliedOverrides)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.filter(override -> override.getBaseRuleSlot() != null)
.filter(override -> override.getLessonDate() != null)
.filter(override -> exactDate == null || exactDate.equals(override.getLessonDate()))
.collect(Collectors.toMap(
override -> overrideKey(override.getBaseRuleSlot().getId(), override.getLessonDate()),
override -> override,

View File

@@ -0,0 +1,34 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import java.util.List;
public class ScheduleRuleConflictException extends RuntimeException {
private final ScheduleRuleDto conflictRule;
private final List<String> conflictFields;
private final List<String> conflictReasons;
public ScheduleRuleConflictException(String message,
ScheduleRuleDto conflictRule,
List<String> conflictFields,
List<String> conflictReasons) {
super(message);
this.conflictRule = conflictRule;
this.conflictFields = List.copyOf(conflictFields);
this.conflictReasons = List.copyOf(conflictReasons);
}
public ScheduleRuleDto getConflictRule() {
return conflictRule;
}
public List<String> getConflictFields() {
return conflictFields;
}
public List<String> getConflictReasons() {
return conflictReasons;
}
}

View File

@@ -0,0 +1,850 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleLessonCategory;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.LessonTypesRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ScheduleRuleService {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<String> VALID_LESSON_FORMATS = Set.of("Очно", "Онлайн");
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository;
private final SemesterRepository semesterRepository;
private final GroupRepository groupRepository;
private final TimeSlotRepository timeSlotRepository;
private final UserRepository userRepository;
private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
SemesterRepository semesterRepository,
GroupRepository groupRepository,
TimeSlotRepository timeSlotRepository,
UserRepository userRepository,
ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository;
this.semesterRepository = semesterRepository;
this.groupRepository = groupRepository;
this.timeSlotRepository = timeSlotRepository;
this.userRepository = userRepository;
this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream()
.anyMatch(group -> Objects.equals(group.getId(), groupId)))
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public ScheduleRuleDto getById(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
return toDto(rule);
}
@Transactional
public ScheduleRuleDto create(ScheduleRuleDto request) {
validateBasic(request);
// Это должна быть первая операция с БД: блокировка сериализует все записи
// правил одного семестра, включая запросы из разных экземпляров backend.
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
ScheduleRule candidate = buildCandidate(request, semester);
validateCandidate(candidate);
validateAgainstSavedRules(candidate, null);
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
scheduleGeneratorService.clearCache();
return toDto(saved);
}
@Transactional
public ScheduleRuleDto update(Long id, ScheduleRuleDto request) {
validateBasic(request);
// Сначала блокируется сама строка правила. Это гарантирует, что old semester
// определён по актуальному состоянию и два update одного правила не расходятся.
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
Long oldSemesterId = managedRule.getSemester().getId();
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
List.of(oldSemesterId, request.semesterId())
);
Semester targetSemester = lockedSemesters.get(request.semesterId());
if (targetSemester == null) {
throw new IllegalArgumentException("Семестр не найден");
}
ScheduleRule candidate = buildCandidate(request, targetSemester);
validateCandidate(candidate);
validateAgainstSavedRules(candidate, managedRule.getId());
applyCandidate(managedRule, candidate);
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
scheduleGeneratorService.clearCache();
return toDto(saved);
}
@Transactional
public void archive(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
Long semesterId = rule.getSemester().getId();
semesterRepository.findByIdForUpdate(semesterId)
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
rule.archive();
scheduleRuleRepository.saveAndFlush(rule);
scheduleGeneratorService.clearCache();
}
private Map<Long, Semester> lockSemestersInStableOrder(Collection<Long> semesterIds) {
List<Long> orderedIds = new ArrayList<>(new TreeSet<>(semesterIds));
return semesterRepository.findAllByIdForUpdateOrderById(orderedIds).stream()
.collect(Collectors.toMap(
Semester::getId,
Function.identity(),
(first, ignored) -> first,
LinkedHashMap::new
));
}
private void validateBasic(ScheduleRuleDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные правила обязательны");
}
if (request.subjectId() == null) {
throw new IllegalArgumentException("Дисциплина обязательна");
}
if (request.semesterId() == null) {
throw new IllegalArgumentException("Семестр обязателен");
}
validateHours("лекций", request.lectureAcademicHours());
validateHours("лабораторных занятий", request.laboratoryAcademicHours());
validateHours("практических занятий", request.practiceAcademicHours());
long totalHours = (long) request.lectureAcademicHours()
+ request.laboratoryAcademicHours()
+ request.practiceAcademicHours();
if (totalHours <= 0) {
throw new IllegalArgumentException("Укажите часы хотя бы для одного типа занятий");
}
validateStartWeek("лекций", request.lectureStartWeek());
validateStartWeek("лабораторных занятий", request.laboratoryStartWeek());
validateStartWeek("практических занятий", request.practiceStartWeek());
if (request.groupIds() == null || request.groupIds().isEmpty()) {
throw new IllegalArgumentException("Нужно выбрать хотя бы одну группу");
}
if (request.groupIds().stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("Идентификаторы групп не могут быть пустыми");
}
if (new HashSet<>(request.groupIds()).size() != request.groupIds().size()) {
throw new IllegalArgumentException("Группы в правиле не должны повторяться");
}
if (request.slots() == null || request.slots().isEmpty()) {
throw new IllegalArgumentException("Добавьте хотя бы один слот занятия");
}
for (ScheduleRuleSlotDto slot : request.slots()) {
validateBasicSlot(slot);
}
}
private void validateHours(String typeName, Integer hours) {
if (hours == null) {
throw new IllegalArgumentException("Укажите количество часов для " + typeName);
}
if (hours < 0) {
throw new IllegalArgumentException("Количество часов для " + typeName + " не может быть отрицательным");
}
if (hours % ACADEMIC_HOURS_PER_SLOT != 0) {
throw new IllegalArgumentException("Количество часов для " + typeName + " должно быть чётным");
}
}
private void validateStartWeek(String typeName, Integer startWeek) {
if (startWeek == null || startWeek <= 0) {
throw new IllegalArgumentException("Неделя начала для " + typeName + " должна быть больше нуля");
}
}
private void validateBasicSlot(ScheduleRuleSlotDto slot) {
if (slot == null) {
throw new IllegalArgumentException("Данные слота занятия обязательны");
}
if (slot.dayOfWeek() == null || slot.dayOfWeek() < 1 || slot.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
}
if (slot.parity() == null || !VALID_SLOT_PARITIES.contains(slot.parity())) {
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
}
if (slot.timeSlotId() == null) {
throw new IllegalArgumentException("Временной слот обязателен");
}
if (slot.teacherId() == null) {
throw new IllegalArgumentException("Преподаватель обязателен");
}
if (slot.classroomId() == null) {
throw new IllegalArgumentException("Аудитория обязательна");
}
if (slot.lessonTypeId() == null) {
throw new IllegalArgumentException("Тип занятия обязателен");
}
normalizeLessonFormat(slot.lessonFormat());
}
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
List<StudentGroup> groups = new ArrayList<>(groupRepository.findAllById(request.groupIds()));
if (groups.size() != request.groupIds().size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены");
}
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
groups.sort(Comparator.comparing(StudentGroup::getId));
ScheduleRule candidate = new ScheduleRule();
candidate.setSubject(subject);
candidate.setSemester(semester);
candidate.setLectureAcademicHours(request.lectureAcademicHours());
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
candidate.setLectureStartWeek(request.lectureStartWeek());
candidate.setLaboratoryStartWeek(request.laboratoryStartWeek());
candidate.setPracticeStartWeek(request.practiceStartWeek());
candidate.setGroups(new LinkedHashSet<>(groups));
List<ScheduleRuleSlot> slots = new ArrayList<>();
for (ScheduleRuleSlotDto slotDto : request.slots()) {
slots.add(buildSlot(candidate, slotDto));
}
candidate.setSlots(new LinkedHashSet<>(slots));
validateTypeCoverage(candidate, slots);
return candidate;
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (timeSlot.getTimeSlotScope() == null
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
if (teacher.getRole() != Role.TEACHER) {
throw new IllegalArgumentException("В слот расписания можно назначить только пользователя с ролью преподавателя");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroups(subgroups);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
slot.setLessonFormat(normalizeLessonFormat(slotDto.lessonFormat()));
return slot;
}
private Set<Subgroup> resolveSubgroups(ScheduleRule rule,
ScheduleRuleSlotDto slotDto,
ScheduleLessonCategory category) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
if (slotDto.subgroupIds() != null) {
slotDto.subgroupIds().stream()
.filter(Objects::nonNull)
.forEach(subgroupIds::add);
}
if (slotDto.subgroupId() != null) {
subgroupIds.add(slotDto.subgroupId());
}
if (subgroupIds.isEmpty()) {
return new LinkedHashSet<>();
}
if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
}
Set<Long> ruleGroupIds = rule.getGroups().stream()
.map(StudentGroup::getId)
.collect(Collectors.toSet());
Set<Long> subgroupGroupIds = new HashSet<>();
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
for (Long subgroupId : subgroupIds) {
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
if (subgroup.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную подгруппу нельзя использовать в расписании");
}
Long subgroupGroupId = subgroup.getStudentGroup().getId();
if (!ruleGroupIds.contains(subgroupGroupId)) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
if (!subgroupGroupIds.add(subgroupGroupId)) {
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
}
subgroups.add(subgroup);
}
return subgroups;
}
private String normalizeLessonFormat(String lessonFormat) {
if (lessonFormat == null || lessonFormat.isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
String normalized = lessonFormat.trim();
if (!VALID_LESSON_FORMATS.contains(normalized)) {
throw new IllegalArgumentException("Формат занятия должен быть «Очно» или «Онлайн»");
}
return normalized;
}
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
for (ScheduleRuleSlot slot : slots) {
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
}
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int hours = rule.academicHoursFor(category);
boolean hasSlots = categoriesWithSlots.contains(category);
if (hours > 0 && !hasSlots) {
throw new IllegalArgumentException(
"Для типа «" + category.getDisplayName() + "» добавьте хотя бы один слот"
);
}
if (hours == 0 && hasSlots) {
throw new IllegalArgumentException(
"Для типа «" + category.getDisplayName() + "» укажите часы или удалите слоты этого типа"
);
}
}
}
private void validateCandidate(ScheduleRule candidate) {
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
validateExactDuplicates(slots);
validateInternalConflicts(slots);
}
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
Set<SlotIdentity> seen = new HashSet<>();
for (ScheduleRuleSlot slot : slots) {
if (!seen.add(slotIdentity(slot))) {
throw new ScheduleRuleConflictException(
"Невозможно сохранить правило: в списке есть дублирующиеся слоты",
null,
List.of("slot"),
List.of("Дублирующийся слот")
);
}
}
}
private SlotIdentity slotIdentity(ScheduleRuleSlot slot) {
List<Long> subgroupIds = slot.getSubgroups().stream()
.map(Subgroup::getId)
.sorted()
.toList();
return new SlotIdentity(
slot.getDayOfWeek(),
slot.getParity(),
timeSlotId(slot),
subgroupIds,
teacherId(slot),
classroomId(slot),
slot.getLessonType().getId(),
slot.getLessonFormat()
);
}
private void validateInternalConflicts(List<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
for (int firstIndex = 0; firstIndex < slots.size(); firstIndex++) {
for (int secondIndex = firstIndex + 1; secondIndex < slots.size(); secondIndex++) {
ScheduleRuleSlot first = slots.get(firstIndex);
ScheduleRuleSlot second = slots.get(secondIndex);
if (!sameTimeSlot(first, second)
|| !activeWeeksOverlap(activeWeeks.get(first), activeWeeks.get(second))) {
continue;
}
List<String> fields = new ArrayList<>(conflictFields(first, second));
if (!fields.isEmpty()) {
throw new ScheduleRuleConflictException(
"Невозможно сохранить правило: слоты внутри правила конфликтуют",
null,
fields,
conflictReasons(fields)
);
}
}
}
}
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
ScheduleRuleConflict conflict = scheduleRuleRepository
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
.stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null
&& Objects.equals(rule.getSemester().getId(), candidate.getSemester().getId()))
.map(rule -> toConflict(rule, candidate.getSlots(), candidateActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflictResult -> conflictResult.rule().getId()))
.orElse(null);
if (conflict == null) {
return;
}
List<String> fields = new ArrayList<>(conflict.fields());
throw new ScheduleRuleConflictException(
"Невозможно сохранить правило: слот занят",
toDto(conflict.rule()),
fields,
conflictReasons(fields)
);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> candidateSlots,
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot candidateSlot : candidateSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(candidateSlot, existingSlot)
&& activeWeeksOverlap(
candidateActiveWeeks.get(candidateSlot),
existingActiveWeeks.get(existingSlot)
)) {
fields.addAll(conflictFields(candidateSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private List<String> conflictReasons(Collection<String> fields) {
return fields.stream().map(this::conflictFieldLabel).toList();
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
case "slot" -> "Дублирующийся слот";
default -> field;
};
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> commonGroupIds = groupIds(first);
commonGroupIds.retainAll(groupIds(second));
for (Long groupId : commonGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> commonSubgroupIds = subgroupIdsForGroup(first, groupId);
commonSubgroupIds.retainAll(subgroupIdsForGroup(second, groupId));
if (!commonSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
if (!slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.filter(subgroup -> subgroup.getStudentGroup() != null)
.map(subgroup -> subgroup.getStudentGroup().getId())
.collect(Collectors.toCollection(HashSet::new));
}
if (slot.getScheduleRule() == null) {
return new HashSet<>();
}
return slot.getScheduleRule().getGroups().stream()
.map(StudentGroup::getId)
.collect(Collectors.toCollection(HashSet::new));
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return slot.getSubgroups().isEmpty() && groupIds(slot).contains(groupId);
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
return slot.getSubgroups().stream()
.filter(subgroup -> subgroup.getStudentGroup() != null)
.filter(subgroup -> Objects.equals(subgroup.getStudentGroup().getId(), groupId))
.map(Subgroup::getId)
.collect(Collectors.toCollection(HashSet::new));
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null
? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null
? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(
first.getId() == null ? 0L : first.getId(),
second.getId() == null ? 0L : second.getId()
);
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
managedRule.setSubject(candidate.getSubject());
managedRule.setSemester(candidate.getSemester());
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
managedRule.setLaboratoryAcademicHours(candidate.getLaboratoryAcademicHours());
managedRule.setPracticeAcademicHours(candidate.getPracticeAcademicHours());
managedRule.setLectureStartWeek(candidate.getLectureStartWeek());
managedRule.setLaboratoryStartWeek(candidate.getLaboratoryStartWeek());
managedRule.setPracticeStartWeek(candidate.getPracticeStartWeek());
managedRule.getGroups().clear();
managedRule.getGroups().addAll(candidate.getGroups());
managedRule.getSlots().clear();
for (ScheduleRuleSlot slot : candidate.getSlots()) {
slot.setScheduleRule(managedRule);
managedRule.getSlots().add(slot);
}
}
private ScheduleRuleDto toDto(ScheduleRule rule) {
List<StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
.thenComparing(ScheduleRuleSlot::getId, Comparator.nullsLast(Comparator.naturalOrder())))
.map(this::toSlotDto)
.toList();
return new ScheduleRuleDto(
rule.getId(),
rule.getSubject().getId(),
rule.getSubject().getName(),
rule.getSemester().getId(),
rule.getSemester().getAcademicYear().getTitle(),
rule.getSemester().getSemesterType(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto(
slot.getId(),
slot.getDayOfWeek(),
dayName(slot.getDayOfWeek()),
slot.getParity(),
timeSlot.getId(),
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),
slot.getClassroom().getName(),
slot.getLessonType().getId(),
slot.getLessonType().getLessonType(),
slot.getLessonFormat()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
return "Неизвестно";
}
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
}
private String formatTimeSlot(TimeSlot timeSlot) {
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
}
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
return slot.getSubgroups().stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
}
private record SlotIdentity(Integer dayOfWeek,
ScheduleParity parity,
Long timeSlotId,
List<Long> subgroupIds,
Long teacherId,
Long classroomId,
Long lessonTypeId,
String lessonFormat) {
}
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
}

View File

@@ -0,0 +1,25 @@
package com.magistr.app.service;
import org.flywaydb.core.Flyway;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.util.Objects;
@Service
public class TenantDatabaseMigrationService {
/**
* Выполняет миграции указанной tenant-БД. Ошибка намеренно не перехватывается:
* вызывающий lifecycle обязан отменить активацию неподготовленного пула.
*/
public void migrate(DataSource dataSource) {
Objects.requireNonNull(dataSource, "DataSource для миграции обязателен");
Flyway.configure()
.dataSource(dataSource)
.baselineOnMigrate(true)
.baselineVersion("1")
.load()
.migrate();
}
}

View File

@@ -0,0 +1,15 @@
package com.magistr.app.service;
/**
* Безопасная для HTTP-ответа ошибка изменения lifecycle тенанта.
*/
public class TenantLifecycleException extends RuntimeException {
public TenantLifecycleException(String message) {
super(message);
}
public TenantLifecycleException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,339 @@
package com.magistr.app.service;
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@Service
public class TenantLifecycleService {
private static final Logger log = LoggerFactory.getLogger(TenantLifecycleService.class);
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
);
private final TenantRoutingDataSource routingDataSource;
private final KubernetesTenantSecretUpdater tenantSecretUpdater;
private final TenantDatabaseMigrationService migrationService;
private final RetiredTenantPoolService retiredPoolService;
public TenantLifecycleService(TenantRoutingDataSource routingDataSource,
KubernetesTenantSecretUpdater tenantSecretUpdater,
TenantDatabaseMigrationService migrationService,
RetiredTenantPoolService retiredPoolService) {
this.routingDataSource = routingDataSource;
this.tenantSecretUpdater = tenantSecretUpdater;
this.migrationService = migrationService;
this.retiredPoolService = retiredPoolService;
}
/**
* Выполняет составную lifecycle-операцию под общим reentrant monitor.
* Используется для сериализации внешних read/compare/apply последовательностей
* с add/update/remove, чтобы watcher не применял устаревший снимок.
*/
public synchronized <T> T executeSerialized(Supplier<T> action) {
return action.get();
}
/**
* Выполняет составную lifecycle-операцию без возвращаемого значения.
*/
public synchronized void executeSerialized(Runnable action) {
action.run();
}
/**
* Добавляет или обновляет tenant без разрыва действующего подключения.
*/
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
TenantConfig config = validateAndNormalize(requestedConfig);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
desiredConfigs.put(config.getDomain(), copyConfig(config));
HikariDataSource candidate = null;
boolean activated = false;
try {
candidate = prepareCandidate(config);
verifyConnection(candidate);
migrateCandidate(candidate);
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
TenantRoutingDataSource.TenantState previous;
try {
previous = routingDataSource.swapTenant(config, candidate);
} catch (RuntimeException activationFailure) {
throw activationFailureAfterCompensation(oldConfigs, activationFailure);
}
activated = true;
retireDataSource(previous == null ? null : previous.dataSource());
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
return copyConfig(config);
} finally {
if (!activated) {
closeCandidateDataSource(candidate);
}
}
}
/**
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
*/
public synchronized TenantConfig removeTenant(String requestedDomain) {
String domain = validateAndNormalizeDomain(requestedDomain);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
TenantConfig existing = oldConfigs.get(domain);
if (existing == null) {
throw new NoSuchElementException("Тенант '" + domain + "' не найден");
}
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
desiredConfigs.remove(domain);
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
TenantRoutingDataSource.TenantState removed;
try {
removed = routingDataSource.removeTenantAtomically(domain);
if (removed == null) {
throw new IllegalStateException("Локальное состояние тенанта отсутствует");
}
} catch (RuntimeException removalFailure) {
throw removalFailureAfterCompensation(oldConfigs, removalFailure);
}
retireDataSource(removed.dataSource());
log.info("Lifecycle тенанта '{}' удалён", domain);
return copyConfig(existing);
}
/**
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
* Secret повторно и использует тот же monitor, что ручные API add/update/remove.
* Обновление параметров существующего domain остаётся отдельной задачей watcher №13.
*/
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
if (requestedConfigs == null) {
throw new IllegalArgumentException("Список конфигураций тенантов обязателен");
}
List<TenantConfig> configs = requestedConfigs.stream()
.map(this::validateAndNormalize)
.toList();
Set<String> desiredDomains = configs.stream()
.map(TenantConfig::getDomain)
.collect(Collectors.toSet());
for (TenantConfig config : configs) {
if (!routingDataSource.hasTenant(config.getDomain())) {
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config);
}
}
for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) {
if (!desiredDomains.contains(existingDomain)) {
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
TenantRoutingDataSource.TenantState removed =
routingDataSource.removeTenantAtomically(existingDomain);
retireDataSource(removed == null ? null : removed.dataSource());
}
}
}
private void activatePersistedConfig(TenantConfig config) {
HikariDataSource candidate = null;
boolean activated = false;
try {
candidate = prepareCandidate(config);
verifyConnection(candidate);
migrateCandidate(candidate);
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
activated = true;
retireDataSource(previous == null ? null : previous.dataSource());
} finally {
if (!activated) {
closeCandidateDataSource(candidate);
}
}
}
private HikariDataSource prepareCandidate(TenantConfig config) {
try {
return routingDataSource.prepareTenantDataSource(config);
} catch (RuntimeException preparationFailure) {
logTechnicalFailure("Не удалось создать временный pool тенанта", preparationFailure);
throw new TenantLifecycleException("Не удалось подготовить подключение к базе данных тенанта",
preparationFailure);
}
}
private void verifyConnection(HikariDataSource candidate) {
try (Connection connection = candidate.getConnection()) {
if (!connection.isValid(5)) {
throw new TenantLifecycleException("База данных тенанта не подтвердила готовность подключения");
}
} catch (TenantLifecycleException e) {
throw e;
} catch (Exception connectionFailure) {
logTechnicalFailure("Не удалось проверить временное подключение тенанта", connectionFailure);
throw new TenantLifecycleException("Не удалось подключиться к базе данных тенанта", connectionFailure);
}
}
private void migrateCandidate(DataSource candidate) {
try {
migrationService.migrate(candidate);
} catch (RuntimeException migrationFailure) {
logTechnicalFailure("Миграции временной tenant-БД завершились ошибкой", migrationFailure);
throw new TenantLifecycleException("Не удалось выполнить миграции базы данных тенанта", migrationFailure);
}
}
private void persistDesired(Map<String, TenantConfig> desiredConfigs) {
boolean persisted;
try {
persisted = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(desiredConfigs));
} catch (RuntimeException persistenceFailure) {
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
}
if (!persisted) {
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов");
}
}
private void persistDesiredWithCompensation(Map<String, TenantConfig> desiredConfigs,
Map<String, TenantConfig> oldConfigs) {
try {
persistDesired(desiredConfigs);
} catch (TenantLifecycleException persistenceFailure) {
boolean restored = restorePersistedSnapshot(oldConfigs);
String message = restored
? "Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"
: "Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret";
throw new TenantLifecycleException(message, persistenceFailure);
}
}
private TenantLifecycleException activationFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
RuntimeException activationFailure) {
boolean restored = restorePersistedSnapshot(oldConfigs);
logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure);
String message = restored
? "Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена"
: "Не удалось активировать подключение тенанта; требуется проверить tenant Secret";
return new TenantLifecycleException(message, activationFailure);
}
private TenantLifecycleException removalFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
RuntimeException removalFailure) {
boolean restored = restorePersistedSnapshot(oldConfigs);
logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure);
String message = restored
? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена"
: "Не удалось отключить тенанта; требуется проверить tenant Secret";
return new TenantLifecycleException(message, removalFailure);
}
private boolean restorePersistedSnapshot(Map<String, TenantConfig> oldConfigs) {
try {
boolean restored = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(oldConfigs));
if (!restored) {
log.error("Не удалось компенсирующе восстановить tenant Secret");
}
return restored;
} catch (RuntimeException compensationFailure) {
logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой",
compensationFailure);
return false;
}
}
private List<TenantConfig> sortedConfigs(Map<String, TenantConfig> configs) {
List<TenantConfig> sorted = new ArrayList<>();
configs.values().stream()
.map(this::copyConfig)
.sorted(Comparator.comparing(TenantConfig::getDomain))
.forEach(sorted::add);
return sorted;
}
private TenantConfig validateAndNormalize(TenantConfig config) {
if (config == null) {
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
}
String domain = validateAndNormalizeDomain(config.getDomain());
if (config.getUrl() == null || config.getUrl().isBlank()) {
throw new IllegalArgumentException("URL базы данных не может быть пустым");
}
String url = config.getUrl().trim();
if (!url.startsWith("jdbc:")) {
throw new IllegalArgumentException("URL базы данных должен начинаться с jdbc:");
}
String name = config.getName() == null || config.getName().isBlank()
? domain
: config.getName().trim();
return new TenantConfig(name, domain, url, config.getUsername(), config.getPassword());
}
private String validateAndNormalizeDomain(String domain) {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Домен не может быть пустым");
}
String normalized = domain.trim().toLowerCase(Locale.ROOT);
if (!DOMAIN_PATTERN.matcher(normalized).matches()) {
throw new IllegalArgumentException("Домен содержит недопустимые символы");
}
return normalized;
}
private TenantConfig copyConfig(TenantConfig config) {
return new TenantConfig(
config.getName(),
config.getDomain(),
config.getUrl(),
config.getUsername(),
config.getPassword()
);
}
private void retireDataSource(DataSource dataSource) {
if (dataSource != null) {
retiredPoolService.retire(dataSource);
}
}
private void closeCandidateDataSource(DataSource dataSource) {
if (dataSource instanceof HikariDataSource hikariDataSource) {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
logTechnicalFailure("Не удалось штатно закрыть Hikari pool", closeFailure);
}
}
}
private void logTechnicalFailure(String message, Throwable failure) {
log.error("{}: errorType={}", message, failure.getClass().getSimpleName());
log.debug("Технические детали tenant lifecycle", failure);
}
}

View File

@@ -15,7 +15,7 @@ spring.jpa.open-in-view=false
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
# JWT авторизация
app.jwt.secret=${JWT_SECRET:dev-only-change-this-jwt-secret-32-bytes-minimum}
app.jwt.secret=${JWT_SECRET:}
app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m}
app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d}
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
package com.magistr.app.config.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.model.Role;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AuthorizationInterceptorTest {
private static final String TENANT = "tenant-a";
private static final String TEACHER_TOKEN = "teacher-token";
private JwtTokenService jwtTokenService;
private ContextProbeController controller;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
AuthContext.clear();
TenantContext.clear();
TenantContext.setCurrentTenant(TENANT);
jwtTokenService = mock(JwtTokenService.class);
controller = new ContextProbeController();
mockMvc = MockMvcBuilders
.standaloneSetup(controller)
.addInterceptors(new AuthorizationInterceptor(jwtTokenService, new ObjectMapper()))
.build();
}
@AfterEach
void clearThreadLocalContexts() {
AuthContext.clear();
TenantContext.clear();
}
@Test
@DisplayName("Отказ по роли не оставляет пользователя в следующих запросах того же потока")
void forbiddenRequestDoesNotLeakAuthContextToFollowingRequestsOnSameThread() throws Exception {
AuthenticatedUser teacher = new AuthenticatedUser(7L, "преподаватель", Role.TEACHER, 3L);
when(jwtTokenService.authenticate(TEACHER_TOKEN, TENANT)).thenReturn(Optional.of(teacher));
long junitThreadId = Thread.currentThread().getId();
mockMvc.perform(get("/api/test/admin")
.header("Authorization", "Bearer " + TEACHER_TOKEN))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.message").value("Недостаточно прав для выполнения операции"));
assertThat(AuthContext.getCurrentUser())
.as("Контекст должен быть пуст после отказа по роли")
.isNull();
mockMvc.perform(post("/api/auth/refresh"))
.andExpect(status().isOk());
assertThat(controller.publicHandlerCalled)
.as("Публичный обработчик должен быть вызван")
.isTrue();
assertThat(controller.publicHandlerUser)
.as("Публичный обработчик не должен видеть пользователя предыдущего запроса")
.isNull();
assertThat(controller.publicHandlerThreadId)
.as("Публичный запрос должен выполняться в потоке JUnit")
.isEqualTo(junitThreadId);
mockMvc.perform(get("/api/test/teacher")
.header("Authorization", "Bearer " + TEACHER_TOKEN))
.andExpect(status().isOk());
assertThat(controller.protectedHandlerUser)
.as("Разрешённый обработчик должен видеть аутентифицированного пользователя")
.isEqualTo(teacher);
assertThat(controller.protectedHandlerThreadId)
.as("Разрешённый запрос должен выполняться в том же потоке JUnit")
.isEqualTo(junitThreadId);
assertThat(controller.protectedHandlerThreadId)
.as("Последовательные обработчики должны выполняться в одном потоке")
.isEqualTo(controller.publicHandlerThreadId);
assertThat(AuthContext.getCurrentUser())
.as("После завершения разрешённого запроса контекст должен быть очищен")
.isNull();
verify(jwtTokenService, times(2)).authenticate(TEACHER_TOKEN, TENANT);
}
@RestController
static class ContextProbeController {
private boolean publicHandlerCalled;
private AuthenticatedUser publicHandlerUser;
private long publicHandlerThreadId;
private AuthenticatedUser protectedHandlerUser;
private long protectedHandlerThreadId;
@PostMapping("/api/auth/refresh")
String publicRefresh() {
publicHandlerCalled = true;
publicHandlerUser = AuthContext.getCurrentUser();
publicHandlerThreadId = Thread.currentThread().getId();
return publicHandlerUser == null ? "Контекст пуст" : "Контекст содержит пользователя";
}
@GetMapping("/api/test/admin")
@RequireRoles(Role.ADMIN)
String adminOnly() {
return "Администратор";
}
@GetMapping("/api/test/teacher")
@RequireRoles(Role.TEACHER)
String teacherOnly() {
protectedHandlerUser = AuthContext.getCurrentUser();
protectedHandlerThreadId = Thread.currentThread().getId();
return protectedHandlerUser.username();
}
}
}

View File

@@ -0,0 +1,71 @@
package com.magistr.app.config.auth;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JwtPropertiesTest {
@Test
void rejectsMissingSecret() {
JwtProperties properties = new JwtProperties();
assertThatThrownBy(properties::secretBytes)
.isInstanceOf(IllegalStateException.class)
.hasMessage("JWT_SECRET обязателен и не может быть пустым");
}
@Test
void rejectsShortSecret() {
JwtProperties properties = properties("short-value", true);
assertThatThrownBy(properties::secretBytes)
.isInstanceOf(IllegalStateException.class)
.hasMessage("JWT_SECRET должен быть не короче 32 байт");
}
@Test
void rejectsPlaceholderSecret() {
String placeholder = String.join("-", "replace", "with", "production", "value") + "x".repeat(32);
JwtProperties properties = properties(placeholder, true);
assertThatThrownBy(properties::secretBytes)
.isInstanceOf(IllegalStateException.class)
.hasMessage("JWT_SECRET содержит известное небезопасное или шаблонное значение");
}
@Test
void rejectsInsecureRefreshCookieInProduction() {
JwtProperties properties = properties(testSecret(), false);
assertThatThrownBy(() -> properties.validateForEnvironment(true))
.isInstanceOf(IllegalStateException.class)
.hasMessage("В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true");
}
@Test
void allowsInsecureRefreshCookieOutsideProduction() {
JwtProperties properties = properties(testSecret(), false);
assertThatCode(() -> properties.validateForEnvironment(false)).doesNotThrowAnyException();
}
@Test
void acceptsSecureProductionConfiguration() {
JwtProperties properties = properties(testSecret(), true);
assertThatCode(() -> properties.validateForEnvironment(true)).doesNotThrowAnyException();
}
private JwtProperties properties(String secret, boolean secureCookie) {
JwtProperties properties = new JwtProperties();
properties.setSecret(secret);
properties.setRefreshCookieSecure(secureCookie);
return properties;
}
private String testSecret() {
return "A1b2C3d4".repeat(6);
}
}

View File

@@ -0,0 +1,101 @@
package com.magistr.app.config.auth;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JwtSecurityStartupValidatorTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(JwtValidationTestConfiguration.class)
.withPropertyValues("spring.profiles.active=production");
@Test
void productionContextDoesNotStartWithoutSecret() {
contextRunner
.withPropertyValues("app.jwt.refresh-cookie-secure=true")
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.hasRootCauseMessage("JWT_SECRET обязателен и не может быть пустым");
});
}
@Test
void productionContextDoesNotStartWithPlaceholderSecret() {
String placeholder = String.join("-", "change", "this", "production", "value") + "x".repeat(32);
contextRunner
.withPropertyValues(
"app.jwt.secret=" + placeholder,
"app.jwt.refresh-cookie-secure=true"
)
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.hasRootCauseMessage(
"JWT_SECRET содержит известное небезопасное или шаблонное значение"
);
});
}
@Test
void productionContextStartsWithSecureConfiguration() {
contextRunner
.withPropertyValues(
"app.jwt.secret=" + "A1b2C3d4".repeat(6),
"app.jwt.refresh-cookie-secure=true"
)
.run(context -> assertThat(context).hasNotFailed());
}
@Test
void stopsStartupForInsecureProductionCookie() {
JwtProperties properties = properties(false);
MockEnvironment environment = new MockEnvironment();
environment.setActiveProfiles("production");
JwtSecurityStartupValidator validator = new JwtSecurityStartupValidator(properties, environment);
assertThatThrownBy(validator::validate)
.isInstanceOf(IllegalStateException.class)
.hasMessage("В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true");
}
@Test
void acceptsSecureProductionConfiguration() {
JwtProperties properties = properties(true);
MockEnvironment environment = new MockEnvironment();
environment.setActiveProfiles("production");
JwtSecurityStartupValidator validator = new JwtSecurityStartupValidator(properties, environment);
assertThatCode(validator::validate).doesNotThrowAnyException();
}
private JwtProperties properties(boolean secureCookie) {
JwtProperties properties = new JwtProperties();
properties.setSecret("A1b2C3d4".repeat(6));
properties.setRefreshCookieSecure(secureCookie);
return properties;
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(JwtProperties.class)
static class JwtValidationTestConfiguration {
@Bean
JwtSecurityStartupValidator jwtSecurityStartupValidator(JwtProperties properties,
Environment environment) {
return new JwtSecurityStartupValidator(properties, environment);
}
}
}

View File

@@ -0,0 +1,205 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthRefreshToken;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.JdbcTemplate;
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.util.ArrayList;
import java.util.List;
import java.util.Optional;
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 = RefreshTokenServiceConcurrencyIntegrationTest.TestApplication.class,
properties = "app.jwt.refresh-ttl=7d"
)
class RefreshTokenServiceConcurrencyIntegrationTest {
private static final String TENANT = "test-tenant";
@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 RefreshTokenService service;
@Autowired
private AuthRefreshTokenRepository tokenRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private TransactionTemplate transactionTemplate;
private ExecutorService executor;
@AfterEach
void stopExecutor() throws InterruptedException {
if (executor == null) {
return;
}
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
@Test
void onlyOneConcurrentRotationCreatesNextSession() throws Exception {
assertThat(AopUtils.isAopProxy(service)).isTrue();
User user = createTestUser();
String rawToken = service.createSession(user, TENANT, "Исходный браузер", "127.0.0.1");
executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<Optional<RefreshTokenRotation>>> futures = new ArrayList<>();
transactionTemplate.executeWithoutResult(status -> {
Long lockedTokenId = jdbcTemplate.queryForObject(
"SELECT id FROM auth_refresh_tokens WHERE token_hash = ? FOR UPDATE",
Long.class,
service.hashToken(rawToken)
);
assertThat(lockedTokenId).isNotNull();
jdbcTemplate.execute("LOCK TABLE auth_refresh_tokens IN ACCESS EXCLUSIVE MODE");
futures.add(executor.submit(
() -> rotateAfterSignal(rawToken, ready, start, "Первый браузер")
));
futures.add(executor.submit(
() -> rotateAfterSignal(rawToken, ready, start, "Второй браузер")
));
awaitReady(ready);
start.countDown();
awaitBlockedRotations();
});
List<Optional<RefreshTokenRotation>> results = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(results).filteredOn(Optional::isPresent).hasSize(1);
assertThat(results).filteredOn(Optional::isEmpty).hasSize(1);
List<AuthRefreshToken> storedTokens = tokenRepository.findAll();
assertThat(storedTokens).hasSize(2);
assertThat(storedTokens).filteredOn(token -> token.getRevokedAt() == null).hasSize(1);
assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1);
}
private Optional<RefreshTokenRotation> rotateAfterSignal(
String rawToken,
CountDownLatch ready,
CountDownLatch start,
String userAgent
) throws InterruptedException {
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Не удалось синхронно запустить конкурентную ротацию");
}
return service.rotate(rawToken, TENANT, userAgent, "127.0.0.1");
}
private void awaitReady(CountDownLatch ready) {
try {
if (!ready.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Конкурентные потоки не успели подготовиться к ротации");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание конкурентных потоков прервано", e);
}
}
private void awaitBlockedRotations() {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
do {
Integer blockedQueries = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
AND wait_event_type = 'Lock'
""", Integer.class);
if (blockedQueries != null && blockedQueries >= 2) {
return;
}
try {
Thread.sleep(25);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание блокировки ротации прервано", e);
}
} while (System.nanoTime() < deadline);
throw new IllegalStateException("Оба запроса ротации не заблокировались на исходном токене");
}
private User createTestUser() {
Long departmentId = jdbcTemplate.queryForObject(
"INSERT INTO departments (name, code) VALUES (?, ?) RETURNING id",
Long.class,
"Тестовая кафедра ротации " + UUID.randomUUID(),
System.nanoTime()
);
User user = new User();
user.setUsername("rt-" + UUID.randomUUID());
user.setPassword("тестовый-хэш");
user.setRole(Role.ADMIN);
user.setFullName("Тест конкурентной ротации");
user.setJobTitle("Тестовый пользователь");
user.setDepartmentId(departmentId);
return userRepository.saveAndFlush(user);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = AuthRefreshToken.class)
@EnableJpaRepositories(basePackageClasses = AuthRefreshTokenRepository.class)
@EnableConfigurationProperties(JwtProperties.class)
@Import(RefreshTokenService.class)
static class TestApplication {
}
}

View File

@@ -45,7 +45,7 @@ class RefreshTokenServiceTest {
RefreshTokenService service = new RefreshTokenService(repository, properties());
String rawToken = "raw-refresh-token";
AuthRefreshToken stored = activeToken(service.hashToken(rawToken));
when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
when(repository.findByTokenHashForUpdate(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
Optional<RefreshTokenRotation> rotation = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1");
@@ -64,7 +64,7 @@ class RefreshTokenServiceTest {
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
RefreshTokenService service = new RefreshTokenService(repository, properties());
String rawToken = "raw-refresh-token";
when(repository.findByTokenHash(service.hashToken(rawToken)))
when(repository.findByTokenHashForUpdate(service.hashToken(rawToken)))
.thenReturn(Optional.of(activeToken(service.hashToken(rawToken))));
assertThat(service.rotate(rawToken, "n8n", "Browser", "127.0.0.1")).isEmpty();

View File

@@ -0,0 +1,154 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import okhttp3.tls.HandshakeCertificates;
import okhttp3.tls.HeldCertificate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
class KubernetesTenantSecretUpdaterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@TempDir
Path tempDirectory;
@Test
void updatesSecretThroughTrustedCaWithoutSendingPlainTenantJson() throws Exception {
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
HeldCertificate serverCertificate = serverCertificate(ca, "localhost");
try (MockWebServer server = httpsServer(serverCertificate)) {
server.enqueue(new MockResponse().setResponseCode(200));
TestFiles files = testFiles(ca);
String rawPassword = UUID.randomUUID().toString();
TenantConfig tenant = new TenantConfig("Университет", "test", "jdbc:test", "user", rawPassword);
KubernetesTenantSecretUpdater updater = updater(files, server);
boolean updated = updater.updateTenantsConfig(List.of(tenant));
assertThat(updated).isTrue();
RecordedRequest request = server.takeRequest(2, TimeUnit.SECONDS);
assertThat(request).isNotNull();
assertThat(request.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret");
String body = request.getBody().readUtf8();
assertThat(body).doesNotContain(rawPassword);
JsonNode patch = objectMapper.readTree(body);
String decoded = new String(
Base64.getDecoder().decode(patch.path("data").path("tenants.json").asText()),
StandardCharsets.UTF_8
);
assertThat(decoded).contains("\"domain\" : \"test\"");
}
}
@Test
void rejectsServerSignedByDifferentCa() throws Exception {
HeldCertificate trustedCa = certificateAuthority("Доверенный тестовый CA");
HeldCertificate untrustedCa = certificateAuthority("Недоверенный тестовый CA");
HeldCertificate serverCertificate = serverCertificate(untrustedCa, "localhost");
try (MockWebServer server = httpsServer(serverCertificate)) {
server.enqueue(new MockResponse().setResponseCode(200));
KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server);
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
}
}
@Test
void rejectsCertificateForDifferentHostname() throws Exception {
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
HeldCertificate serverCertificate = serverCertificate(ca, "not-localhost.invalid");
try (MockWebServer server = httpsServer(serverCertificate)) {
server.enqueue(new MockResponse().setResponseCode(200));
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
}
}
@Test
void failsClosedWhenServiceAccountCaIsMissing() throws Exception {
TestFiles files = testFiles(certificateAuthority("Доверенный тестовый CA"));
Files.delete(files.caPath());
KubernetesTenantSecretUpdater updater = new KubernetesTenantSecretUpdater(
files.tokenPath(),
files.namespacePath(),
files.caPath(),
"https://localhost:1",
"tenants-secret",
objectMapper
);
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
}
private KubernetesTenantSecretUpdater updater(TestFiles files, MockWebServer server) {
String apiBase = server.url("/").toString();
return new KubernetesTenantSecretUpdater(
files.tokenPath(),
files.namespacePath(),
files.caPath(),
apiBase,
"tenants-secret",
objectMapper
);
}
private TestFiles testFiles(HeldCertificate ca) throws Exception {
Path tokenPath = tempDirectory.resolve(UUID.randomUUID() + "-token");
Path namespacePath = tempDirectory.resolve(UUID.randomUUID() + "-namespace");
Path caPath = tempDirectory.resolve(UUID.randomUUID() + "-ca.crt");
Files.writeString(tokenPath, "test-service-account-token");
Files.writeString(namespacePath, "magistr");
Files.writeString(caPath, ca.certificatePem());
return new TestFiles(tokenPath, namespacePath, caPath);
}
private HeldCertificate certificateAuthority(String commonName) {
return new HeldCertificate.Builder()
.certificateAuthority(1)
.commonName(commonName)
.duration(1, TimeUnit.DAYS)
.build();
}
private HeldCertificate serverCertificate(HeldCertificate ca, String hostname) {
return new HeldCertificate.Builder()
.commonName(hostname)
.addSubjectAlternativeName(hostname)
.duration(1, TimeUnit.DAYS)
.signedBy(ca)
.build();
}
private MockWebServer httpsServer(HeldCertificate serverCertificate) throws Exception {
HandshakeCertificates certificates = new HandshakeCertificates.Builder()
.heldCertificate(serverCertificate)
.build();
MockWebServer server = new MockWebServer();
server.useHttps(certificates.sslSocketFactory(), false);
server.start();
return server;
}
private record TestFiles(Path tokenPath, Path namespacePath, Path caPath) {
}
}

View File

@@ -1,11 +1,37 @@
package com.magistr.app.config.tenant;
import com.magistr.app.service.RetiredTenantPoolService;
import com.magistr.app.service.TenantDatabaseMigrationService;
import com.magistr.app.service.TenantLifecycleService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
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 java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
class TenantConfigWatcherTest {
@TempDir
Path tempDirectory;
@Test
void configHashUsesSha256Hex() {
String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
@@ -20,4 +46,138 @@ class TenantConfigWatcherTest {
assertThat(TenantConfigWatcher.configHash("tenant-a"))
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
}
@Test
void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception {
Path configPath = tempDirectory.resolve("tenants.json");
String content = """
[{
"name": "Тестовый tenant",
"domain": "alpha",
"url": "jdbc:postgresql://db/alpha",
"username": "alpha",
"password": "тестовый-пароль"
}]
""";
Files.writeString(configPath, content);
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
doThrow(new IllegalStateException("тестовая ошибка синхронизации"))
.doNothing()
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
watcher.watchForChanges();
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
watcher.watchForChanges();
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
.isEqualTo(TenantConfigWatcher.configHash(content));
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
watcher.watchForChanges();
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
}
@Test
void successfulHashPreventsRepeatedSynchronizationOfSameContent() throws Exception {
Path configPath = tempDirectory.resolve("tenants.json");
String content = "[]";
Files.writeString(configPath, content);
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
doNothing().when(lifecycleService).synchronizeFromPersistedConfig(anyList());
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
watcher.watchForChanges();
watcher.watchForChanges();
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
.isEqualTo(TenantConfigWatcher.configHash(content));
}
@Test
void watcherCannotReadBetweenSerializedApiMutationAndRefreshHash() throws Exception {
Path configPath = tempDirectory.resolve("tenants.json");
String content = "[]";
Files.writeString(configPath, content);
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
TenantLifecycleService lifecycleService = new TenantLifecycleService(
routingDataSource,
mock(KubernetesTenantSecretUpdater.class),
mock(TenantDatabaseMigrationService.class),
mock(RetiredTenantPoolService.class)
);
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService);
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
CountDownLatch allowApiRefresh = new CountDownLatch(1);
AtomicReference<Thread> watcherThread = new AtomicReference<>();
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> api = executor.submit(() -> lifecycleService.executeSerialized(() -> {
apiMutationReachedRefreshWindow.countDown();
await(allowApiRefresh, "Ожидание разрешения refreshHash прервано");
watcher.refreshHash();
}));
assertThat(apiMutationReachedRefreshWindow.await(5, TimeUnit.SECONDS)).isTrue();
Future<?> concurrentWatcher = executor.submit(() -> {
watcherThread.set(Thread.currentThread());
watcher.watchForChanges();
});
awaitBlocked(watcherThread);
assertThat(concurrentWatcher).isNotDone();
allowApiRefresh.countDown();
api.get(5, TimeUnit.SECONDS);
concurrentWatcher.get(5, TimeUnit.SECONDS);
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
.isEqualTo(TenantConfigWatcher.configHash(content));
verify(routingDataSource, never()).snapshotTenantConfigs();
} finally {
allowApiRefresh.countDown();
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, Path configPath) {
doAnswer(invocation -> {
invocation.<Runnable>getArgument(0).run();
return null;
}).when(lifecycleService).executeSerialized(any(Runnable.class));
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService);
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
return watcher;
}
private void await(CountDownLatch latch, String message) {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException(message);
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(message, exception);
}
}
private void awaitBlocked(AtomicReference<Thread> threadReference) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (System.nanoTime() < deadline) {
Thread thread = threadReference.get();
if (thread != null && thread.getState() == Thread.State.BLOCKED) {
return;
}
try {
Thread.sleep(10);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание блокировки watcher прервано", exception);
}
}
throw new IllegalStateException("Watcher не заблокировался на общем lifecycle coordinator");
}
}

View File

@@ -0,0 +1,76 @@
package com.magistr.app.config.tenant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TenantRoutingDataSourceTest {
@AfterEach
void clearTenantContext() {
TenantContext.clear();
}
@Test
void atomicallyPublishesOneImmutableStateAndKeepsObtainedConnectionUsable() throws Exception {
TenantRoutingDataSource routing = new TenantRoutingDataSource();
DataSource oldDataSource = mock(DataSource.class);
DataSource newDataSource = mock(DataSource.class);
Connection oldConnection = mock(Connection.class);
Connection newConnection = mock(Connection.class);
when(oldDataSource.getConnection()).thenReturn(oldConnection);
when(newDataSource.getConnection()).thenReturn(newConnection);
when(oldConnection.isValid(1)).thenReturn(true);
TenantConfig oldConfig = config("Старый", "ALPHA", "jdbc:old");
TenantConfig newConfig = config("Новый", "alpha", "jdbc:new");
assertThat(routing.swapTenant(oldConfig, oldDataSource)).isNull();
oldConfig.setUrl("jdbc:mutated-request");
TenantContext.setCurrentTenant("alpha");
Connection inFlight = routing.getConnection();
assertThat(inFlight).isSameAs(oldConnection);
TenantRoutingDataSource.TenantState previous = routing.swapTenant(newConfig, newDataSource);
assertThat(previous).isNotNull();
assertThat(previous.dataSource()).isSameAs(oldDataSource);
assertThat(previous.config().getUrl()).isEqualTo("jdbc:old");
assertThat(routing.getConnection()).isSameAs(newConnection);
assertThat(inFlight.isValid(1)).isTrue();
verify(oldConnection).isValid(1);
assertThat(routing.snapshotTenantConfigs().get("alpha").getUrl()).isEqualTo("jdbc:new");
}
@Test
void snapshotsAreDetachedAndUnmodifiableAndRemovalReturnsRetiredState() {
TenantRoutingDataSource routing = new TenantRoutingDataSource();
DataSource dataSource = mock(DataSource.class);
TenantConfig config = config("Alpha", "alpha", "jdbc:alpha");
routing.swapTenant(config, dataSource);
Map<String, TenantConfig> snapshot = routing.snapshotTenantConfigs();
snapshot.get("alpha").setUrl("jdbc:changed-copy");
assertThat(routing.snapshotTenantConfigs().get("alpha").getUrl()).isEqualTo("jdbc:alpha");
assertThatThrownBy(() -> snapshot.put("beta", config("Beta", "beta", "jdbc:beta")))
.isInstanceOf(UnsupportedOperationException.class);
TenantRoutingDataSource.TenantState removed = routing.removeTenantAtomically(" ALPHA ");
assertThat(removed).isNotNull();
assertThat(removed.dataSource()).isSameAs(dataSource);
assertThat(routing.snapshotTenant("alpha")).isEmpty();
assertThat(routing.getTenantDataSource("alpha")).isEmpty();
}
private TenantConfig config(String name, String domain, String url) {
return new TenantConfig(name, domain, url, "user", "password");
}
}

View File

@@ -0,0 +1,54 @@
package com.magistr.app.controller;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.service.ScheduleConflictException;
import com.magistr.app.service.ScheduleOverrideService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class ScheduleOverrideControllerTest {
private ScheduleOverrideService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(ScheduleOverrideService.class);
mockMvc = standaloneSetup(new ScheduleOverrideController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsConflictWithRussianMessageForScheduleConflict() throws Exception {
when(service.create(any(ScheduleOverrideDto.class))).thenThrow(new ScheduleConflictException(
"Конфликт расписания: преподаватель уже занят в указанное время"
));
mockMvc.perform(post("/api/edu-office/schedule/overrides")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"baseRuleSlotId": 101,
"lessonDate": "2026-09-01",
"action": "MOVE",
"newTimeSlotId": 2
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.status").value(409))
.andExpect(jsonPath("$.error").value("Конфликт"))
.andExpect(jsonPath("$.message")
.value("Конфликт расписания: преподаватель уже занят в указанное время"));
}
}

View File

@@ -0,0 +1,151 @@
package com.magistr.app.controller;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.NoSuchElementException;
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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class ScheduleRuleAdminControllerTest {
private ScheduleRuleService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(ScheduleRuleService.class);
mockMvc = standaloneSetup(new ScheduleRuleAdminController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsBadRequestWithRussianValidationMessage() throws Exception {
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
"Количество часов для лекций должно быть чётным"
));
mockMvc.perform(post("/api/admin/schedule-rules")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.error").value("Некорректный запрос"))
.andExpect(jsonPath("$.message")
.value("Количество часов для лекций должно быть чётным"));
}
@Test
void returnsInternalConflictWithoutArtificialConflictRule() throws Exception {
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new ScheduleRuleConflictException(
"Невозможно сохранить правило: слоты внутри правила конфликтуют",
null,
List.of("teacher", "group"),
List.of("Преподаватель", "Группа")
));
mockMvc.perform(post("/api/admin/schedule-rules")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message")
.value("Невозможно сохранить правило: слоты внутри правила конфликтуют"))
.andExpect(jsonPath("$.conflictRule").doesNotExist())
.andExpect(jsonPath("$.conflictFields[0]").value("teacher"))
.andExpect(jsonPath("$.conflictFields[1]").value("group"))
.andExpect(jsonPath("$.conflictReasons[0]").value("Преподаватель"))
.andExpect(jsonPath("$.conflictReasons[1]").value("Группа"));
}
@Test
void returnsPersistedConflictWithConflictingRuleDto() throws Exception {
ScheduleRuleDto conflictingRule = dto(321L);
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new ScheduleRuleConflictException(
"Невозможно сохранить правило: слот занят",
conflictingRule,
List.of("classroom"),
List.of("Аудитория")
));
mockMvc.perform(post("/api/admin/schedule-rules")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value("Невозможно сохранить правило: слот занят"))
.andExpect(jsonPath("$.conflictRule.id").value(321))
.andExpect(jsonPath("$.conflictFields[0]").value("classroom"))
.andExpect(jsonPath("$.conflictReasons[0]").value("Аудитория"));
}
@Test
void returnsNotFoundWhenRuleDoesNotExist() throws Exception {
when(service.getById(404L)).thenThrow(new NoSuchElementException("Правило расписания не найдено"));
mockMvc.perform(get("/api/admin/schedule-rules/404"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.error").value("Не найдено"))
.andExpect(jsonPath("$.message").value("Правило расписания не найдено"));
}
private ScheduleRuleDto dto(Long id) {
return new ScheduleRuleDto(
id,
1L,
"Дисциплина",
2L,
"2026-2027",
null,
2,
0,
0,
1,
1,
1,
List.of(3L),
List.of("Группа"),
List.of()
);
}
private String validRequestJson() {
return """
{
"subjectId": 1,
"semesterId": 2,
"lectureAcademicHours": 2,
"laboratoryAcademicHours": 0,
"practiceAcademicHours": 0,
"lectureStartWeek": 1,
"laboratoryStartWeek": 1,
"practiceStartWeek": 1,
"groupIds": [3],
"slots": [
{
"dayOfWeek": 1,
"parity": "BOTH",
"timeSlotId": 4,
"teacherId": 5,
"classroomId": 6,
"lessonTypeId": 7,
"lessonFormat": "Очно"
}
]
}
""";
}
}

View File

@@ -0,0 +1,294 @@
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.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Миграция инвариантов точечных изменений расписания")
class ScheduleOverrideMigrationIntegrationTest {
private static final String[] OVERRIDE_CONSTRAINTS = {
"chk_schedule_overrides_cancel_payload",
"chk_schedule_overrides_move_payload",
"chk_schedule_overrides_replace_payload",
"chk_schedule_overrides_format"
};
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные")
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
Flyway flyway = targetThreeFlyway();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current())
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V3")
.isEqualTo("3");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
LocalDate validDate = LocalDate.of(2026, 3, 2);
insertOverride(connection, seedIds, validDate, "CANCEL", null, null, null);
insertOverride(connection, seedIds, validDate.plusDays(1), "MOVE", seedIds.classroomId(), null, null);
insertOverride(connection, seedIds, validDate.plusDays(2), "REPLACE", null, seedIds.teacherId(), null);
insertOverride(connection, seedIds, validDate.plusDays(3), "REPLACE", null, null, "Онлайн");
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_overrides"))
.as("Все допустимые варианты точечного изменения должны сохраниться")
.isEqualTo(4L);
LocalDate invalidDate = LocalDate.of(2026, 4, 6);
assertCheckViolation(
connection,
seedIds,
invalidDate,
"CANCEL",
seedIds.classroomId(),
null,
null,
"chk_schedule_overrides_cancel_payload"
);
assertCheckViolation(
connection,
seedIds,
invalidDate.plusDays(1),
"MOVE",
null,
null,
null,
"chk_schedule_overrides_move_payload"
);
assertCheckViolation(
connection,
seedIds,
invalidDate.plusDays(2),
"REPLACE",
null,
null,
null,
"chk_schedule_overrides_replace_payload"
);
assertCheckViolation(
connection,
seedIds,
invalidDate.plusDays(3),
"REPLACE",
null,
seedIds.teacherId(),
"Смешанный",
"chk_schedule_overrides_format"
);
}
}
@Test
@DisplayName("V3 останавливается на legacy-данных до добавления ограничений")
void migrationFailsPreflightWithoutPartialConstraints() throws SQLException {
Flyway targetTwo = targetTwoFlyway();
targetTwo.clean();
targetTwo.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
insertOverride(
connection,
seedIds,
LocalDate.of(2026, 5, 4),
"REPLACE",
null,
null,
null
);
}
Flyway targetThree = targetThreeFlyway();
Throwable migrationFailure = catchThrowable(targetThree::migrate);
assertThat(migrationFailure)
.as("V3 должна остановить миграцию при пустом legacy-изменении REPLACE")
.isNotNull();
assertThat(collectMessages(migrationFailure))
.as("Ошибка preflight должна объяснять проблему на русском языке")
.contains("Невозможно применить инварианты точечных изменений")
.contains("REPLACE=1")
.contains("Исправьте данные tenant-БД и повторите миграцию");
assertThat(targetThree.info().current())
.as("После отката V3 должна остаться текущая успешная версия")
.isNotNull();
assertThat(targetThree.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна остаться V2")
.isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(
connection,
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '3'"
))
.as("V3 не должна отмечаться как успешно применённая")
.isZero();
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid = 'schedule_overrides'::regclass
AND conname::text = ANY (?)
""",
connection.createArrayOf("text", OVERRIDE_CONSTRAINTS)
))
.as("Ни одно ограничение V3 не должно остаться после неуспешного preflight")
.isZero();
}
}
private Flyway targetThreeFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("3"))
.cleanDisabled(false)
.load();
}
private Flyway targetTwoFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
}
private SeedIds loadSeedIds(Connection connection) throws SQLException {
return new SeedIds(
queryLong(connection, "SELECT id FROM schedule_rule_slots ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM classrooms ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1")
);
}
private void insertOverride(
Connection connection,
SeedIds seedIds,
LocalDate lessonDate,
String action,
Long classroomId,
Long teacherId,
String lessonFormat
) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schedule_overrides (
base_rule_slot_id,
lesson_date,
action,
new_classroom_id,
new_teacher_id,
new_lesson_format
) VALUES (?, ?, ?, ?, ?, ?)
""")) {
statement.setLong(1, seedIds.baseRuleSlotId());
statement.setDate(2, Date.valueOf(lessonDate));
statement.setString(3, action);
setNullableLong(statement, 4, classroomId);
setNullableLong(statement, 5, teacherId);
statement.setString(6, lessonFormat);
statement.executeUpdate();
}
}
private void assertCheckViolation(
Connection connection,
SeedIds seedIds,
LocalDate lessonDate,
String action,
Long classroomId,
Long teacherId,
String lessonFormat,
String expectedConstraint
) {
Throwable failure = catchThrowable(() -> insertOverride(
connection,
seedIds,
lessonDate,
action,
classroomId,
teacherId,
lessonFormat
));
assertThat(failure)
.as("Недопустимые данные должны быть отклонены ограничением %s", expectedConstraint)
.isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState())
.as("Ограничение %s должно вернуть SQLSTATE нарушения CHECK", expectedConstraint)
.isEqualTo("23514");
assertThat(sqlFailure.getMessage())
.as("Ошибка должна содержать имя сработавшего ограничения")
.contains(expectedConstraint);
}
private void setNullableLong(PreparedStatement statement, int index, Long value) throws SQLException {
if (value == null) {
statement.setObject(index, null);
} else {
statement.setLong(index, value);
}
}
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 String collectMessages(Throwable failure) {
StringBuilder messages = new StringBuilder();
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Throwable current = failure;
while (current != null && visited.add(current)) {
if (current.getMessage() != null) {
messages.append(current.getMessage()).append('\n');
}
current = current.getCause();
}
return messages.toString();
}
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
}
}

View File

@@ -0,0 +1,337 @@
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.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Миграция чётности академических часов правил расписания")
class ScheduleRuleHoursMigrationIntegrationTest {
private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
void migrationEnforcesEvenAcademicHours() throws SQLException {
Flyway flyway = targetFourFlyway();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current())
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V4")
.isEqualTo("4");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");
insertScheduleRule(connection, seedIds, 2, 4, 6);
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_rules"))
.as("Правило с чётными академическими часами должно сохраниться")
.isEqualTo(rulesBeforeInsert + 1);
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid = 'schedule_rules'::regclass
AND conname = ?
AND convalidated
""",
HOURS_CONSTRAINT
))
.as("Ограничение чётности V4 должно быть провалидировано")
.isOne();
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
assertOddHoursRejected(connection, seedIds, 2, 1, 2, "лабораторных работ");
assertOddHoursRejected(connection, seedIds, 2, 2, 1, "практик");
assertExactSlotDuplicateRejected(connection);
}
}
@Test
@DisplayName("V4 останавливается на legacy-правиле с нечётными часами до добавления ограничения")
void migrationFailsPreflightWithoutPartialConstraint() throws SQLException {
Flyway targetThree = targetThreeFlyway();
targetThree.clean();
targetThree.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
insertScheduleRule(connection, seedIds, 1, 3, 5);
}
Flyway targetFour = targetFourFlyway();
Throwable migrationFailure = catchThrowable(targetFour::migrate);
assertThat(migrationFailure)
.as("V4 должна остановить миграцию при нечётных часах legacy-правила")
.isNotNull();
assertThat(collectMessages(migrationFailure))
.as("Ошибка preflight должна объяснять проблему на русском языке")
.contains("Невозможно применить инвариант чётности академических часов правил расписания")
.contains("лекции=1")
.contains("лабораторные=1")
.contains("практики=1")
.contains("Исправьте данные tenant-БД и повторите миграцию");
assertThat(targetFour.info().current())
.as("После отката V4 должна остаться текущая успешная версия")
.isNotNull();
assertThat(targetFour.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна остаться V3")
.isEqualTo("3");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(
connection,
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
))
.as("V4 не должна отмечаться как успешно применённая")
.isZero();
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid = 'schedule_rules'::regclass
AND conname = ?
""",
HOURS_CONSTRAINT
))
.as("Ограничение V4 не должно частично остаться после неуспешного preflight")
.isZero();
}
}
@Test
@DisplayName("V4 останавливается на legacy-дубле слота до добавления ограничений")
void migrationFailsOnLegacyExactSlotDuplicate() throws SQLException {
Flyway targetThree = targetThreeFlyway();
targetThree.clean();
targetThree.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
duplicateExistingSlot(connection);
}
Flyway targetFour = targetFourFlyway();
Throwable migrationFailure = catchThrowable(targetFour::migrate);
assertThat(migrationFailure)
.as("V4 должна остановить миграцию при точном legacy-дубле слота")
.isNotNull();
assertThat(collectMessages(migrationFailure))
.as("Ошибка preflight должна объяснять проблему уникальности на русском языке")
.contains("Невозможно применить инвариант уникальности слотов правил расписания")
.contains("групп точных дублей=1")
.contains("Исправьте данные tenant-БД и повторите миграцию");
assertThat(targetFour.info().current())
.as("После отката V4 должна остаться текущая успешная версия")
.isNotNull();
assertThat(targetFour.info().current().getVersion().getVersion()).isEqualTo("3");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(
connection,
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
)).isZero();
assertConstraintAbsent(connection, HOURS_CONSTRAINT);
assertConstraintAbsent(connection, EXACT_SLOT_CONSTRAINT);
}
}
private Flyway targetFourFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("4"))
.cleanDisabled(false)
.load();
}
private Flyway targetThreeFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("3"))
.cleanDisabled(false)
.load();
}
private SeedIds loadSeedIds(Connection connection) throws SQLException {
return new SeedIds(
queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1")
);
}
private void insertScheduleRule(
Connection connection,
SeedIds seedIds,
int lectureHours,
int laboratoryHours,
int practiceHours
) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schedule_rules (
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
""")) {
statement.setLong(1, seedIds.subjectId());
statement.setLong(2, seedIds.semesterId());
statement.setInt(3, lectureHours);
statement.setInt(4, laboratoryHours);
statement.setInt(5, practiceHours);
statement.executeUpdate();
}
}
private void assertOddHoursRejected(
Connection connection,
SeedIds seedIds,
int lectureHours,
int laboratoryHours,
int practiceHours,
String lessonKind
) {
Throwable failure = catchThrowable(() -> insertScheduleRule(
connection,
seedIds,
lectureHours,
laboratoryHours,
practiceHours
));
assertThat(failure)
.as("Нечётное количество часов %s должно быть отклонено", lessonKind)
.isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState())
.as("Ограничение чётности для %s должно вернуть SQLSTATE нарушения CHECK", lessonKind)
.isEqualTo("23514");
assertThat(sqlFailure.getMessage())
.as("Ошибка должна содержать имя ограничения чётности")
.contains(HOURS_CONSTRAINT);
}
private void assertExactSlotDuplicateRejected(Connection connection) {
Throwable failure = catchThrowable(() -> duplicateExistingSlot(connection));
assertThat(failure)
.as("Точный дубль слота одного правила должен быть отклонён")
.isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState())
.as("Ограничение уникальности слота должно вернуть SQLSTATE unique violation")
.isEqualTo("23505");
assertThat(sqlFailure.getMessage()).contains(EXACT_SLOT_CONSTRAINT);
}
private void duplicateExistingSlot(Connection connection) 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,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
FROM schedule_rule_slots
ORDER BY id
LIMIT 1
""")) {
assertThat(statement.executeUpdate())
.as("Для проверки должен быть скопирован один seed-слот")
.isEqualTo(1);
}
}
private void assertConstraintAbsent(Connection connection, String constraintName) throws SQLException {
assertThat(queryLong(
connection,
"""
SELECT count(*)
FROM pg_constraint
WHERE conrelid IN ('schedule_rules'::regclass, 'schedule_rule_slots'::regclass)
AND conname = ?
""",
constraintName
)).as("Ограничение %s не должно частично остаться после неуспешной V4", constraintName)
.isZero();
}
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.length; index++) {
statement.setObject(index + 1, parameters[index]);
}
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next())
.as("SQL-запрос должен вернуть одну строку: %s", sql)
.isTrue();
return resultSet.getLong(1);
}
}
}
private String collectMessages(Throwable failure) {
StringBuilder messages = new StringBuilder();
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Throwable current = failure;
while (current != null && visited.add(current)) {
if (current.getMessage() != null) {
messages.append(current.getMessage()).append('\n');
}
current = current.getCause();
}
return messages.toString();
}
private record SeedIds(long subjectId, long semesterId) {
}
}

View File

@@ -0,0 +1,212 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendarDay;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
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.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.JdbcTemplate;
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.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@Testcontainers
@SpringBootTest(
classes = AcademicCalendarGridServiceIntegrationTest.TestApplication.class,
properties = "spring.jpa.open-in-view=false"
)
class AcademicCalendarGridServiceIntegrationTest {
@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 AcademicCalendarGridService service;
@Autowired
private ScheduleGeneratorService scheduleGeneratorService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private TransactionTemplate transactionTemplate;
@Test
void invalidSecondRowKeepsOldGridAndSuccessfulReplacementIsAtomic() {
assertThat(AopUtils.isAopProxy(service)).isTrue();
long calendarId = queryLong("""
SELECT calendar.id
FROM academic_calendars calendar
JOIN academic_years year ON year.id = calendar.academic_year_id
WHERE year.title = '2025-2026'
ORDER BY calendar.id
LIMIT 1
""");
long theoryActivityId = queryLong(
"SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'"
);
long holidayActivityId = queryLong(
"SELECT id FROM academic_calendar_activity_types WHERE code = 'К'"
);
LocalDate yearStart = jdbcTemplate.queryForObject("""
SELECT year.start_date
FROM academic_calendars calendar
JOIN academic_years year ON year.id = calendar.academic_year_id
WHERE calendar.id = ?
""", LocalDate.class, calendarId);
assertThat(yearStart).isEqualTo(LocalDate.of(2025, 9, 1));
GridFingerprint before = fingerprint(calendarId);
assertThat(before.rowCount()).isPositive();
AcademicCalendarGridDayDto firstValid = row(
calendarId, 1, yearStart, 1, 1, holidayActivityId, "К"
);
AcademicCalendarGridDayDto secondInvalid = row(
calendarId, 1, yearStart.plusDays(1), 1, 7, theoryActivityId, "Т"
);
assertThatThrownBy(() -> service.replaceGrid(
calendarId,
List.of(firstValid, secondInvalid)
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("День недели не соответствует дате");
assertThat(fingerprint(calendarId)).isEqualTo(before);
assertThat(queryLong("""
SELECT count(*)
FROM academic_calendar_days day
WHERE day.calendar_id = ?
AND day.course_number = 1
AND day.date = ?
AND day.activity_type_id = ?
""", calendarId, yearStart, holidayActivityId)).isZero();
verify(scheduleGeneratorService, never()).clearCache();
AcademicCalendarGridDayDto secondValid = row(
calendarId, 1, yearStart.plusDays(1), 1, 2, theoryActivityId, "Т"
);
transactionTemplate.executeWithoutResult(status -> {
service.replaceGrid(calendarId, List.of(firstValid, secondValid));
verify(scheduleGeneratorService, never()).clearCache();
});
GridFingerprint after = fingerprint(calendarId);
assertThat(after.rowCount()).isEqualTo(2L);
assertThat(after).isNotEqualTo(before);
assertThat(queryLong("""
SELECT count(*)
FROM academic_calendar_days day
WHERE day.calendar_id = ?
AND (
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
OR
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
)
""", calendarId, yearStart, holidayActivityId,
yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
verify(scheduleGeneratorService, times(1)).clearCache();
}
private GridFingerprint fingerprint(long calendarId) {
GridFingerprint fingerprint = jdbcTemplate.queryForObject("""
SELECT
count(*) AS row_count,
md5(string_agg(
concat_ws('|',
day.course_number::text,
day.date::text,
day.week_number::text,
day.day_of_week::text,
day.activity_type_id::text
),
E'\\n' ORDER BY day.course_number, day.date, day.id
)) AS content_hash
FROM academic_calendar_days day
WHERE day.calendar_id = ?
""", (result, rowNumber) -> new GridFingerprint(
result.getLong("row_count"),
result.getString("content_hash")
), calendarId);
assertThat(fingerprint).isNotNull();
return fingerprint;
}
private long queryLong(String sql, Object... arguments) {
Long value = jdbcTemplate.queryForObject(sql, Long.class, arguments);
assertThat(value).isNotNull();
return value;
}
private AcademicCalendarGridDayDto row(long calendarId,
int course,
LocalDate date,
int week,
int dayOfWeek,
long activityTypeId,
String activityCode) {
return new AcademicCalendarGridDayDto(
null,
calendarId,
course,
date,
week,
dayOfWeek,
activityTypeId,
activityCode,
null,
null
);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = AcademicCalendarDay.class)
@EnableJpaRepositories(basePackageClasses = AcademicCalendarDayRepository.class)
@Import({AcademicCalendarGridService.class, TestDependencies.class})
static class TestApplication {
}
@TestConfiguration(proxyBeanMethods = false)
static class TestDependencies {
@Bean
ScheduleGeneratorService scheduleGeneratorService() {
return mock(ScheduleGeneratorService.class);
}
}
private record GridFingerprint(long rowCount, String contentHash) {
}
}

View File

@@ -0,0 +1,229 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarDay;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.NoSuchElementException;
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.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AcademicCalendarGridServiceTest {
private static final long CALENDAR_ID = 10L;
private static final long ACTIVITY_TYPE_ID = 20L;
private static final LocalDate YEAR_START = LocalDate.of(2025, 9, 1);
@Mock
private AcademicCalendarRepository calendarRepository;
@Mock
private AcademicCalendarDayRepository calendarDayRepository;
@Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private AcademicCalendarGridService service;
private AcademicCalendarActivityType activityType;
@BeforeEach
void setUp() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(1L);
academicYear.setTitle("2025-2026");
academicYear.setStartDate(YEAR_START);
academicYear.setEndDate(LocalDate.of(2026, 6, 30));
AcademicCalendar calendar = new AcademicCalendar();
calendar.setId(CALENDAR_ID);
calendar.setTitle("Тестовый календарный график");
calendar.setAcademicYear(academicYear);
calendar.setCourseCount(4);
activityType = new AcademicCalendarActivityType();
activityType.setId(ACTIVITY_TYPE_ID);
activityType.setCode("Т");
activityType.setName("Теоретическое обучение");
activityType.setAllowSchedule(true);
service = new AcademicCalendarGridService(
calendarRepository,
calendarDayRepository,
activityTypeRepository,
scheduleGeneratorService
);
when(calendarRepository.findByIdWithDetails(CALENDAR_ID)).thenReturn(Optional.of(calendar));
lenient().when(activityTypeRepository.findById(ACTIVITY_TYPE_ID)).thenReturn(Optional.of(activityType));
}
@Test
void rejectsDuplicateCourseAndDateBeforeMutation() {
AcademicCalendarGridDayDto first = validRow(YEAR_START, 1, 1);
AcademicCalendarGridDayDto duplicate = validRow(YEAR_START, 1, 1);
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(first, duplicate)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Для курса и даты можно указать только одну активность");
verifyNoMutation();
}
@Test
void rejectsNullSecondRowBeforeMutation() {
List<AcademicCalendarGridDayDto> rows = Arrays.asList(
validRow(YEAR_START, 1, 1),
null
);
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, rows))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Строка календарного графика не может быть пустой");
verifyNoMutation();
}
@Test
void rejectsDayOfWeekThatDoesNotMatchDateBeforeMutation() {
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
AcademicCalendarGridDayDto invalidSecond = validRow(YEAR_START.plusDays(1), 1, 7);
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, invalidSecond)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("День недели не соответствует дате");
verifyNoMutation();
}
@Test
void rejectsWeekThatDoesNotMatchDateBeforeMutation() {
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
AcademicCalendarGridDayDto invalidSecond = validRow(YEAR_START.plusDays(7), 1, 1);
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, invalidSecond)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Номер недели не соответствует дате учебного года");
verifyNoMutation();
}
@Test
void rejectsDateOutsideAcademicYearBeforeMutation() {
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
AcademicCalendarGridDayDto outsideYear = validRow(LocalDate.of(2026, 7, 1), 44, 3);
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, outsideYear)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Дата выходит за пределы учебного года");
verifyNoMutation();
}
@Test
void rejectsUnknownActivityInLastRowBeforeMutation() {
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
AcademicCalendarGridDayDto unknownActivity = row(
YEAR_START.plusDays(1), 1, 2, 999L
);
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, unknownActivity)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Код активности не найден");
verifyNoMutation();
}
@Test
void reportsMissingCalendarWithoutMutation() {
when(calendarRepository.findByIdWithDetails(CALENDAR_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.replaceGrid(
CALENDAR_ID,
List.of(validRow(YEAR_START, 1, 1))
))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("Календарный график не найден");
verifyNoMutation();
}
@Test
void replacesOnlyAfterWholePayloadIsValid() {
AcademicCalendarGridDayDto first = validRow(YEAR_START, 1, 1);
AcademicCalendarGridDayDto second = validRow(YEAR_START.plusDays(1), 1, 2);
service.replaceGrid(CALENDAR_ID, List.of(first, second));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
InOrder mutationOrder = inOrder(calendarDayRepository, scheduleGeneratorService);
mutationOrder.verify(calendarDayRepository).deleteByCalendarId(CALENDAR_ID);
mutationOrder.verify(calendarDayRepository).flush();
mutationOrder.verify(calendarDayRepository).saveAll(replacementCaptor.capture());
mutationOrder.verify(calendarDayRepository).flush();
mutationOrder.verify(scheduleGeneratorService).clearCache();
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getDate)
.containsExactly(YEAR_START, YEAR_START.plusDays(1));
assertThat(replacementCaptor.getValue())
.allSatisfy(day -> {
assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID);
assertThat(day.getActivityType()).isSameAs(activityType);
});
}
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
}
private AcademicCalendarGridDayDto row(LocalDate date,
int weekNumber,
int dayOfWeek,
long activityTypeId) {
return new AcademicCalendarGridDayDto(
null,
CALENDAR_ID,
1,
date,
weekNumber,
dayOfWeek,
activityTypeId,
null,
null,
null
);
}
private void verifyNoMutation() {
verify(calendarDayRepository, never()).deleteByCalendarId(any());
verify(calendarDayRepository, never()).flush();
verify(calendarDayRepository, never()).save(any());
verify(calendarDayRepository, never()).saveAll(any());
verify(scheduleGeneratorService, never()).clearCache();
}
}

View File

@@ -0,0 +1,142 @@
package com.magistr.app.service;
import com.magistr.app.model.ScheduleOverride;
import org.junit.jupiter.api.AfterEach;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
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.time.LocalDate;
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 java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(
classes = PostgreSqlAdvisoryLockIntegrationTest.TestApplication.class,
properties = {
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none"
}
)
class PostgreSqlAdvisoryLockIntegrationTest {
private static final LocalDate TEST_DATE = LocalDate.of(2026, 9, 1);
@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 PostgreSqlAdvisoryLock advisoryLock;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
private ExecutorService executor;
@AfterEach
void stopExecutor() throws InterruptedException {
if (executor == null) {
return;
}
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
@Test
void serializesTwoSpringTransactionsForSameDate() throws Exception {
executor = Executors.newFixedThreadPool(2);
CountDownLatch firstHasLock = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
CountDownLatch secondReachedLock = new CountDownLatch(1);
AtomicInteger secondBackendPid = new AtomicInteger();
Future<?> first = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> {
advisoryLock.lockScheduleOverrideDate(TEST_DATE);
firstHasLock.countDown();
await(releaseFirst, "Ожидание освобождения первой advisory-блокировки прервано");
}));
assertThat(firstHasLock.await(5, TimeUnit.SECONDS)).isTrue();
Future<?> second = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> {
secondBackendPid.set(jdbcTemplate.queryForObject("select pg_backend_pid()", Integer.class));
secondReachedLock.countDown();
advisoryLock.lockScheduleOverrideDate(TEST_DATE);
}));
assertThat(secondReachedLock.await(5, TimeUnit.SECONDS)).isTrue();
awaitAdvisoryLockWait(secondBackendPid.get());
assertThat(second).as("вторая транзакция должна ждать ту же дату").isNotDone();
releaseFirst.countDown();
first.get(5, TimeUnit.SECONDS);
second.get(5, TimeUnit.SECONDS);
}
private void awaitAdvisoryLockWait(int backendPid) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
do {
Boolean waiting = jdbcTemplate.queryForObject("""
select wait_event_type = 'Lock' and wait_event = 'advisory'
from pg_stat_activity
where pid = ?
""", Boolean.class, backendPid);
if (Boolean.TRUE.equals(waiting)) {
return;
}
try {
Thread.sleep(25);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание PostgreSQL advisory-блокировки прервано", exception);
}
} while (System.nanoTime() < deadline);
throw new IllegalStateException("Вторая транзакция не перешла в ожидание advisory-блокировки");
}
private void await(CountDownLatch latch, String message) {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException(message);
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(message, exception);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = ScheduleOverride.class)
@Import(PostgreSqlAdvisoryLock.class)
static class TestApplication {
}
}

View File

@@ -0,0 +1,671 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Collection;
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.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
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 ScheduleOverrideServiceTest {
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
private static final Long BASE_SLOT_ID = 101L;
private static final Long OTHER_SLOT_ID = 202L;
private static final Long SOURCE_TIME_SLOT_ID = 1L;
private static final Long NEW_TIME_SLOT_ID = 2L;
private static final Long SOURCE_TEACHER_ID = 11L;
private static final Long NEW_TEACHER_ID = 12L;
private static final Long SOURCE_CLASSROOM_ID = 21L;
private static final Long NEW_CLASSROOM_ID = 22L;
private static final Long GROUP_ID = 31L;
private static final Long OTHER_GROUP_ID = 32L;
private static final Long SUBGROUP_A_ID = 41L;
private static final Long SUBGROUP_B_ID = 42L;
@Mock
private ScheduleOverrideRepository scheduleOverrideRepository;
@Mock
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
@Mock
private TimeSlotRepository timeSlotRepository;
@Mock
private ClassroomRepository classroomRepository;
@Mock
private UserRepository userRepository;
@Mock
private SubgroupRepository subgroupRepository;
@Mock
private ScheduleQueryService scheduleQueryService;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private PostgreSqlAdvisoryLock advisoryLock;
private ScheduleOverrideService service;
private ScheduleRuleSlot baseSlot;
private TimeSlot sourceTimeSlot;
private TimeSlot newTimeSlot;
private Classroom sourceClassroom;
private Classroom newClassroom;
private User sourceTeacher;
private User newTeacher;
private RenderedLessonDto sourceLesson;
@BeforeEach
void setUp() {
baseSlot = ruleSlot(BASE_SLOT_ID);
sourceTimeSlot = timeSlot(SOURCE_TIME_SLOT_ID, 1, "09:00", "10:30");
newTimeSlot = timeSlot(NEW_TIME_SLOT_ID, 2, "10:40", "12:10");
sourceClassroom = classroom(SOURCE_CLASSROOM_ID, "А-101");
newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202");
sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель");
newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель");
sourceLesson = lesson(
BASE_SLOT_ID,
SOURCE_TIME_SLOT_ID,
"09:00",
"10:30",
SOURCE_TEACHER_ID,
SOURCE_CLASSROOM_ID,
List.of(GROUP_ID),
List.of(),
"Очно"
);
service = new ScheduleOverrideService(
scheduleOverrideRepository,
scheduleRuleSlotRepository,
timeSlotRepository,
classroomRepository,
userRepository,
subgroupRepository,
scheduleQueryService,
scheduleGeneratorService,
advisoryLock
);
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
when(timeSlotRepository.findById(SOURCE_TIME_SLOT_ID)).thenReturn(Optional.of(sourceTimeSlot));
when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.of(newTimeSlot));
when(classroomRepository.findById(SOURCE_CLASSROOM_ID)).thenReturn(Optional.of(sourceClassroom));
when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.of(newClassroom));
when(userRepository.findById(SOURCE_TEACHER_ID)).thenReturn(Optional.of(sourceTeacher));
when(userRepository.findById(NEW_TEACHER_ID)).thenReturn(Optional.of(newTeacher));
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
.thenReturn(Optional.empty());
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE)).thenReturn(List.of());
when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(sourceLesson));
when(subgroupRepository.findGroupReferencesByIdIn(anyList())).thenAnswer(invocation -> {
List<Long> subgroupIds = invocation.getArgument(0);
return subgroupIds.stream()
.map(id -> subgroupReference(id, GROUP_ID))
.toList();
});
}
@Test
void rejectsNullRequestWithRussianMessage() {
assertThatThrownBy(() -> service.create(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Передайте данные изменения расписания");
}
@Test
void rejectsMissingRequiredRequestReferences() {
assertThatThrownBy(() -> service.create(request(null, DATE, "CANCEL", null, null, null, null)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Базовый слот расписания обязателен");
assertThatThrownBy(() -> service.create(request(BASE_SLOT_ID, null, "CANCEL", null, null, null, null)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Дата пары обязательна");
}
@Test
void rejectsMissingDatabaseReferencesWithRussianMessages() {
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.create(cancelRequest()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Базовый слот расписания не найден");
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.create(moveRequest()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Временной слот не найден");
when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.of(newTimeSlot));
when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.create(replaceRequest()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Аудитория не найдена");
when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.of(newClassroom));
when(userRepository.findById(NEW_TEACHER_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.create(replaceRequest()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Преподаватель не найден");
}
@Test
void enforcesCancelMoveReplaceActionMatrix() {
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "SKIP", null, null, null, null
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Недопустимое действие изменения расписания");
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "CANCEL", NEW_TIME_SLOT_ID, null, null, null
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Для отмены пары новые параметры должны быть пустыми");
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "MOVE", null, null, NEW_TEACHER_ID, null
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Для переноса укажите новое время или аудиторию");
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Для замены укажите нового преподавателя, аудиторию или формат пары");
}
@Test
void rejectsUnsupportedLessonFormat() {
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "REPLACE", null, NEW_CLASSROOM_ID, NEW_TEACHER_ID, "Гибрид"
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Формат пары должен быть «Очно» или «Онлайн»");
}
@Test
void acceptsCancelMoveAndReplaceAndNormalizesText() {
ScheduleOverrideDto cancelled = service.create(cancelRequest());
assertThat(cancelled.action()).isEqualTo("CANCEL");
stubEffectiveDay(movedLesson());
ScheduleOverrideDto moved = service.create(request(
BASE_SLOT_ID, DATE, " move ", NEW_TIME_SLOT_ID, null, null, null
));
assertThat(moved.action()).isEqualTo("MOVE");
assertThat(moved.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
stubEffectiveDay(replacedLesson());
ScheduleOverrideDto replaced = service.create(request(
BASE_SLOT_ID, DATE, " replace ", null, NEW_CLASSROOM_ID, NEW_TEACHER_ID, " Онлайн "
));
assertThat(replaced.action()).isEqualTo("REPLACE");
assertThat(replaced.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
assertThat(replaced.newClassroomId()).isEqualTo(NEW_CLASSROOM_ID);
assertThat(replaced.newLessonFormat()).isEqualTo("Онлайн");
verify(scheduleGeneratorService, org.mockito.Mockito.times(3)).clearCache();
}
@Test
void acceptsReplaceWithOnlyOneReplacementField() {
RenderedLessonDto teacherOnlyReplacement = lesson(
BASE_SLOT_ID,
SOURCE_TIME_SLOT_ID,
"09:00",
"10:30",
NEW_TEACHER_ID,
SOURCE_CLASSROOM_ID,
List.of(GROUP_ID),
List.of(),
"Очно"
);
stubEffectiveDay(teacherOnlyReplacement);
ScheduleOverrideDto replaced = service.create(request(
BASE_SLOT_ID, DATE, "REPLACE", null, null, NEW_TEACHER_ID, null
));
assertThat(replaced.action()).isEqualTo("REPLACE");
assertThat(replaced.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
assertThat(replaced.newClassroomId()).isNull();
assertThat(replaced.newLessonFormat()).isNull();
}
@Test
void rejectsOverrideWhenActualSourceLessonDoesNotExist() {
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(
lesson(OTHER_SLOT_ID, SOURCE_TIME_SLOT_ID, "09:00", "10:30",
SOURCE_TEACHER_ID, SOURCE_CLASSROOM_ID, List.of(GROUP_ID), List.of(), "Очно")
));
assertThatThrownBy(() -> service.create(moveRequest()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("На выбранную дату базовая пара не формируется по правилам расписания");
verify(scheduleOverrideRepository, never()).saveAndFlush(any());
}
@Test
void rejectsActualNoOpMove() {
stubEffectiveDay(sourceLesson);
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "MOVE", null, SOURCE_CLASSROOM_ID, null, null
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
}
@Test
void rejectsDifferentTimeSlotIdWithSameActualInterval() {
TimeSlot sameIntervalSlot = timeSlot(3L, 3, "09:00", "10:30");
when(timeSlotRepository.findById(3L)).thenReturn(Optional.of(sameIntervalSlot));
stubEffectiveDay(lesson(
BASE_SLOT_ID,
3L,
"09:00",
"10:30",
SOURCE_TEACHER_ID,
SOURCE_CLASSROOM_ID,
List.of(GROUP_ID),
List.of(),
"Очно"
));
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
}
@Test
void rejectsActualNoOpReplace() {
stubEffectiveDay(sourceLesson);
assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "REPLACE", null,
SOURCE_CLASSROOM_ID, SOURCE_TEACHER_ID, "Очно"
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Замена должна фактически менять преподавателя, аудиторию или формат пары");
}
@Test
void rejectsTeacherConflictFromEffectiveDay() {
RenderedLessonDto candidate = movedLesson();
RenderedLessonDto occupied = lesson(
OTHER_SLOT_ID, 8L, "11:00", "12:30",
SOURCE_TEACHER_ID, NEW_CLASSROOM_ID,
List.of(OTHER_GROUP_ID), List.of(), "Очно"
);
stubEffectiveDay(candidate, occupied);
assertThatThrownBy(() -> service.create(moveRequest()))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
}
@Test
void rejectsClassroomConflictFromEffectiveDay() {
RenderedLessonDto candidate = movedLesson();
RenderedLessonDto occupied = lesson(
OTHER_SLOT_ID, 8L, "11:00", "12:30",
NEW_TEACHER_ID, SOURCE_CLASSROOM_ID,
List.of(OTHER_GROUP_ID), List.of(), "Очно"
);
stubEffectiveDay(candidate, occupied);
assertThatThrownBy(() -> service.create(moveRequest()))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Конфликт расписания: аудитория уже занята в указанное время");
}
@Test
void rejectsWholeGroupConflictWithSubgroupLesson() {
RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of());
RenderedLessonDto occupied = lesson(
OTHER_SLOT_ID, 8L, "11:00", "12:30",
NEW_TEACHER_ID, NEW_CLASSROOM_ID,
List.of(GROUP_ID), List.of(SUBGROUP_A_ID), "Очно"
);
stubEffectiveDay(candidate, occupied);
assertThatThrownBy(() -> service.create(moveRequest()))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Конфликт расписания: группа или подгруппа уже занята в указанное время");
}
@Test
void rejectsConflictOfSameSubgroup() {
RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of(SUBGROUP_A_ID));
RenderedLessonDto occupied = lesson(
OTHER_SLOT_ID, 8L, "11:00", "12:30",
NEW_TEACHER_ID, NEW_CLASSROOM_ID,
List.of(GROUP_ID), List.of(SUBGROUP_A_ID), "Очно"
);
stubEffectiveDay(candidate, occupied);
assertThatThrownBy(() -> service.create(moveRequest()))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Конфликт расписания: группа или подгруппа уже занята в указанное время");
}
@Test
void allowsDifferentSubgroupsOfSameGroup() {
RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of(SUBGROUP_A_ID));
RenderedLessonDto occupied = lesson(
OTHER_SLOT_ID, 8L, "11:00", "12:30",
NEW_TEACHER_ID, NEW_CLASSROOM_ID,
List.of(GROUP_ID), List.of(SUBGROUP_B_ID), "Очно"
);
stubEffectiveDay(candidate, occupied);
ScheduleOverrideDto saved = service.create(moveRequest());
assertThat(saved.action()).isEqualTo("MOVE");
verify(scheduleOverrideRepository).saveAndFlush(any(ScheduleOverride.class));
}
@Test
void allowsAdjacentIntervalsForSameResources() {
RenderedLessonDto candidate = movedLesson();
RenderedLessonDto adjacent = lesson(
OTHER_SLOT_ID, 9L, "12:10", "13:40",
SOURCE_TEACHER_ID, SOURCE_CLASSROOM_ID,
List.of(GROUP_ID), List.of(), "Очно"
);
stubEffectiveDay(candidate, adjacent);
ScheduleOverrideDto saved = service.create(moveRequest());
assertThat(saved.action()).isEqualTo("MOVE");
}
@Test
void ignoresExistingCancellationBecauseConflictIsCalculatedFromEffectiveDay() {
ScheduleOverride cancelledOtherLesson = override(700L, ruleSlot(OTHER_SLOT_ID), "CANCEL", null);
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(cancelledOtherLesson));
stubEffectiveDay(movedLesson());
service.create(moveRequest());
ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
assertThat(captor.getValue())
.extracting(ScheduleOverride::getAction)
.containsExactlyInAnyOrder("CANCEL", "MOVE");
}
@Test
void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() {
ScheduleOverride stored = override(500L, baseSlot, "CANCEL", null);
when(scheduleOverrideRepository.findLessonDateById(500L)).thenReturn(Optional.of(DATE));
when(scheduleOverrideRepository.findByIdForUpdate(500L)).thenReturn(Optional.of(stored));
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
.thenReturn(Optional.of(stored));
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(stored));
RenderedLessonDto occupied = lesson(
OTHER_SLOT_ID, 8L, "11:00", "12:30",
SOURCE_TEACHER_ID, NEW_CLASSROOM_ID,
List.of(OTHER_GROUP_ID), List.of(), "Очно"
);
stubEffectiveDay(movedLesson(), occupied);
assertThatThrownBy(() -> service.update(500L, moveRequest()))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
assertThat(captor.getValue()).hasSize(1);
assertThat(captor.getValue().iterator().next()).isNotSameAs(stored);
assertThat(stored.getAction()).isEqualTo("CANCEL");
assertThat(stored.getNewTimeSlot()).isNull();
verify(scheduleOverrideRepository, never()).saveAndFlush(stored);
verify(scheduleGeneratorService, never()).clearCache();
}
@Test
void idempotentUpdateExcludesStoredOverrideFromConflictCheck() {
ScheduleOverride stored = override(501L, baseSlot, "MOVE", newTimeSlot);
when(scheduleOverrideRepository.findLessonDateById(501L)).thenReturn(Optional.of(DATE));
when(scheduleOverrideRepository.findByIdForUpdate(501L)).thenReturn(Optional.of(stored));
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
.thenReturn(Optional.of(stored));
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(stored));
stubEffectiveDay(movedLesson());
ScheduleOverrideDto result = service.update(501L, moveRequest());
assertThat(result.id()).isEqualTo(501L);
assertThat(result.action()).isEqualTo("MOVE");
assertThat(result.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
assertThat(captor.getValue()).singleElement().isNotSameAs(stored);
verify(scheduleOverrideRepository).saveAndFlush(stored);
}
private void stubEffectiveDay(RenderedLessonDto... lessons) {
when(scheduleQueryService.buildEffectiveDayForAllGroups(
eq(List.of(sourceLesson)),
eq(DATE),
any(Collection.class)
)).thenReturn(List.of(lessons));
}
private ScheduleOverrideDto cancelRequest() {
return request(BASE_SLOT_ID, DATE, "CANCEL", null, null, null, null);
}
private ScheduleOverrideDto moveRequest() {
return request(BASE_SLOT_ID, DATE, "MOVE", NEW_TIME_SLOT_ID, null, null, null);
}
private ScheduleOverrideDto replaceRequest() {
return request(
BASE_SLOT_ID, DATE, "REPLACE", null,
NEW_CLASSROOM_ID, NEW_TEACHER_ID, "Онлайн"
);
}
private ScheduleOverrideDto request(Long baseRuleSlotId,
LocalDate date,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String format) {
return new ScheduleOverrideDto(
null,
baseRuleSlotId,
date,
action,
newTimeSlotId,
newClassroomId,
newTeacherId,
format,
" Тестовый комментарий ",
null,
null
);
}
private RenderedLessonDto movedLesson() {
return movedLesson(List.of(GROUP_ID), List.of());
}
private RenderedLessonDto movedLesson(List<Long> groupIds, List<Long> subgroupIds) {
return lesson(
BASE_SLOT_ID,
NEW_TIME_SLOT_ID,
"10:40",
"12:10",
SOURCE_TEACHER_ID,
SOURCE_CLASSROOM_ID,
groupIds,
subgroupIds,
"Очно"
);
}
private RenderedLessonDto replacedLesson() {
return lesson(
BASE_SLOT_ID,
SOURCE_TIME_SLOT_ID,
"09:00",
"10:30",
NEW_TEACHER_ID,
NEW_CLASSROOM_ID,
List.of(GROUP_ID),
List.of(),
"Онлайн"
);
}
private RenderedLessonDto lesson(Long ruleSlotId,
Long timeSlotId,
String start,
String end,
Long teacherId,
Long classroomId,
List<Long> groupIds,
List<Long> subgroupIds,
String format) {
return new RenderedLessonDto(
900L,
ruleSlotId,
DATE,
2,
"Вторник",
1,
ScheduleParity.BOTH,
timeSlotId,
1,
LocalTime.parse(start),
LocalTime.parse(end),
501L,
"Тестовая дисциплина",
teacherId,
"Тестовый преподаватель",
classroomId,
"Тестовая аудитория",
601L,
"Лекция",
format,
subgroupIds.size() == 1 ? subgroupIds.get(0) : null,
subgroupIds.size() == 1 ? "Подгруппа" : null,
subgroupIds,
subgroupIds.stream().map(id -> "Подгруппа " + id).toList(),
groupIds,
groupIds.stream().map(id -> "Группа " + id).toList(),
"Лекция",
2,
0,
0,
ScheduleParity.BOTH
);
}
private ScheduleRuleSlot ruleSlot(Long id) {
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setId(id);
return slot;
}
private TimeSlot timeSlot(Long id, int order, String start, String end) {
TimeSlot slot = new TimeSlot();
slot.setId(id);
slot.setOrderNumber(order);
slot.setStartTime(LocalTime.parse(start));
slot.setEndTime(LocalTime.parse(end));
slot.setDurationMinutes((int) java.time.Duration.between(slot.getStartTime(), slot.getEndTime()).toMinutes());
return slot;
}
private Classroom classroom(Long id, String name) {
Classroom classroom = new Classroom();
classroom.setId(id);
classroom.setName(name);
classroom.setIsAvailable(true);
return classroom;
}
private User teacher(Long id, String name) {
User teacher = new User();
teacher.setId(id);
teacher.setRole(Role.TEACHER);
teacher.setFullName(name);
return teacher;
}
private ScheduleOverride override(Long id,
ScheduleRuleSlot slot,
String action,
TimeSlot replacementTimeSlot) {
ScheduleOverride override = new ScheduleOverride();
ReflectionTestUtils.setField(override, "id", id);
override.setBaseRuleSlot(slot);
override.setLessonDate(DATE);
override.setAction(action);
override.setNewTimeSlot(replacementTimeSlot);
return override;
}
private SubgroupRepository.SubgroupGroupReference subgroupReference(Long subgroupId, Long groupId) {
SubgroupRepository.SubgroupGroupReference reference = org.mockito.Mockito.mock(
SubgroupRepository.SubgroupGroupReference.class
);
when(reference.getSubgroupId()).thenReturn(subgroupId);
when(reference.getGroupId()).thenReturn(groupId);
return reference;
}
@SuppressWarnings({"unchecked", "rawtypes"})
private ArgumentCaptor<Collection<ScheduleOverride>> collectionCaptor() {
return ArgumentCaptor.forClass((Class) Collection.class);
}
}

View File

@@ -0,0 +1,152 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class ScheduleQueryServiceOverrideTest {
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
@Test
void appliesAllReplacementFieldsToProvidedDaySnapshot() {
ScheduleQueryService service = service();
RenderedLessonDto base = lesson();
ScheduleOverride override = override("MOVE");
TimeSlot newTimeSlot = new TimeSlot();
newTimeSlot.setId(2L);
newTimeSlot.setOrderNumber(2);
newTimeSlot.setStartTime(LocalTime.of(10, 40));
newTimeSlot.setEndTime(LocalTime.of(12, 10));
override.setNewTimeSlot(newTimeSlot);
Classroom newClassroom = new Classroom();
newClassroom.setId(22L);
newClassroom.setName("Б-202");
override.setNewClassroom(newClassroom);
User newTeacher = new User();
newTeacher.setId(12L);
newTeacher.setRole(Role.TEACHER);
newTeacher.setFullName("Новый преподаватель");
override.setNewTeacher(newTeacher);
override.setNewLessonFormat("Онлайн");
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
List.of(base),
DATE,
List.of(override)
);
assertThat(result).singleElement().satisfies(lesson -> {
assertThat(lesson.timeSlotId()).isEqualTo(2L);
assertThat(lesson.startTime()).isEqualTo(LocalTime.of(10, 40));
assertThat(lesson.endTime()).isEqualTo(LocalTime.of(12, 10));
assertThat(lesson.teacherId()).isEqualTo(12L);
assertThat(lesson.teacherName()).isEqualTo("Новый преподаватель");
assertThat(lesson.classroomId()).isEqualTo(22L);
assertThat(lesson.classroomName()).isEqualTo("Б-202");
assertThat(lesson.lessonFormat()).isEqualTo("Онлайн");
assertThat(lesson.groupIds()).containsExactly(31L);
});
}
@Test
void removesCancelledOccurrenceFromProvidedDaySnapshot() {
ScheduleQueryService service = service();
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
List.of(lesson()),
DATE,
List.of(override("CANCEL"))
);
assertThat(result).isEmpty();
}
@Test
void ignoresOverrideForAnotherDate() {
ScheduleQueryService service = service();
ScheduleOverride override = override("CANCEL");
override.setLessonDate(DATE.plusDays(1));
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
List.of(lesson()),
DATE,
List.of(override)
);
assertThat(result).singleElement().extracting(RenderedLessonDto::scheduleRuleSlotId).isEqualTo(101L);
}
private ScheduleQueryService service() {
return new ScheduleQueryService(
mock(ScheduleGeneratorService.class),
mock(GroupRepository.class),
mock(ScheduleOverrideRepository.class),
mock(StudentGroupLifecycleService.class)
);
}
private ScheduleOverride override(String action) {
ScheduleRuleSlot baseSlot = new ScheduleRuleSlot();
baseSlot.setId(101L);
ScheduleOverride override = new ScheduleOverride();
override.setBaseRuleSlot(baseSlot);
override.setLessonDate(DATE);
override.setAction(action);
return override;
}
private RenderedLessonDto lesson() {
return new RenderedLessonDto(
900L,
101L,
DATE,
2,
"Вторник",
1,
ScheduleParity.BOTH,
1L,
1,
LocalTime.of(9, 0),
LocalTime.of(10, 30),
501L,
"Тестовая дисциплина",
11L,
"Исходный преподаватель",
21L,
"А-101",
601L,
"Лекция",
"Очно",
null,
null,
List.of(),
List.of(),
List.of(31L),
List.of("Группа 31"),
"Т",
2,
0,
0,
ScheduleParity.BOTH
);
}
}

View File

@@ -0,0 +1,241 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
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.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("Поиск расписания преподавателя с точечными изменениями")
class ScheduleQueryServiceTeacherOverrideTest {
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
private static final long SOURCE_TEACHER_ID = 11L;
private static final long REPLACEMENT_TEACHER_ID = 12L;
private static final long GROUP_ID = 31L;
private final ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
private final GroupRepository groupRepository = mock(GroupRepository.class);
private final ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
private final StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
private final ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService
);
@Test
@DisplayName("Замена A на B видна преподавателю B и скрыта у преподавателя A")
void replacementIsVisibleOnlyToEffectiveTeacher() {
RenderedLessonDto sourceLesson = lesson(101L, SOURCE_TEACHER_ID, 1);
ScheduleOverride replacement = replacement(101L, REPLACEMENT_TEACHER_ID);
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
.thenReturn(List.of());
when(generatorService.buildScheduleForTeacher(SOURCE_TEACHER_ID, DATE, DATE))
.thenReturn(List.of(sourceLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(replacement));
stubBaseDay(List.of(sourceLesson));
List<RenderedLessonDto> replacementSchedule = searchTeacher(REPLACEMENT_TEACHER_ID);
List<RenderedLessonDto> sourceSchedule = searchTeacher(SOURCE_TEACHER_ID);
assertThat(replacementSchedule).singleElement().satisfies(lesson -> {
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L);
assertThat(lesson.teacherId()).isEqualTo(REPLACEMENT_TEACHER_ID);
});
assertThat(sourceSchedule).isEmpty();
verify(overrideRepository, times(2)).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
}
@Test
@DisplayName("Точечное изменение без базового occurrence не создаёт фантомную пару")
void missingBaseOccurrenceDoesNotCreatePhantomLesson() {
ScheduleOverride replacement = replacement(101L, REPLACEMENT_TEACHER_ID);
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
.thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(replacement));
stubBaseDay(List.of(lesson(102L, SOURCE_TEACHER_ID, 2)));
List<RenderedLessonDto> result = searchTeacher(REPLACEMENT_TEACHER_ID);
assertThat(result).isEmpty();
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
}
@Test
@DisplayName("Несколько замен одной даты генерируют базовый день только один раз")
void multipleReplacementsOnSameDateGenerateBaseDayOnce() {
RenderedLessonDto first = lesson(101L, SOURCE_TEACHER_ID, 1);
RenderedLessonDto second = lesson(102L, SOURCE_TEACHER_ID, 2);
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
.thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(
replacement(101L, REPLACEMENT_TEACHER_ID),
replacement(102L, REPLACEMENT_TEACHER_ID)
));
stubBaseDay(List.of(first, second));
List<RenderedLessonDto> result = searchTeacher(REPLACEMENT_TEACHER_ID);
assertThat(result)
.extracting(RenderedLessonDto::scheduleRuleSlotId)
.containsExactly(101L, 102L);
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
}
@Test
@DisplayName("Совпавшая базовая и достроенная пара остаётся в результате один раз")
void duplicateSourceOccurrenceIsRemovedAfterApplyingSnapshot() {
RenderedLessonDto existingLesson = lesson(101L, REPLACEMENT_TEACHER_ID, 1);
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
.thenReturn(List.of(existingLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(replacement(101L, REPLACEMENT_TEACHER_ID)));
stubBaseDay(List.of(existingLesson));
List<RenderedLessonDto> result = searchTeacher(REPLACEMENT_TEACHER_ID);
assertThat(result).singleElement().satisfies(lesson ->
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
}
@Test
@DisplayName("Отмена и перенос применяются из единого снимка изменений")
void cancelAndMoveContinueToApplyFromSingleSnapshot() {
RenderedLessonDto cancelledLesson = lesson(101L, SOURCE_TEACHER_ID, 1);
RenderedLessonDto movedLesson = lesson(102L, SOURCE_TEACHER_ID, 2);
ScheduleOverride cancellation = override(101L, "CANCEL");
ScheduleOverride move = override(102L, "MOVE");
TimeSlot newTimeSlot = new TimeSlot();
newTimeSlot.setId(3L);
newTimeSlot.setOrderNumber(3);
newTimeSlot.setStartTime(LocalTime.of(13, 0));
newTimeSlot.setEndTime(LocalTime.of(14, 30));
move.setNewTimeSlot(newTimeSlot);
when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group()));
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
when(generatorService.buildScheduleForGroup(GROUP_ID, DATE, DATE))
.thenReturn(List.of(cancelledLesson, movedLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(cancellation, move));
List<RenderedLessonDto> result = service.search(
GROUP_ID, null, null, null, null, null, null, null, DATE, DATE
);
assertThat(result).singleElement().satisfies(lesson -> {
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(102L);
assertThat(lesson.timeSlotId()).isEqualTo(3L);
assertThat(lesson.startTime()).isEqualTo(LocalTime.of(13, 0));
assertThat(lesson.endTime()).isEqualTo(LocalTime.of(14, 30));
});
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
}
private List<RenderedLessonDto> searchTeacher(long teacherId) {
return service.search(
null, teacherId, null, null, null, null, null, null, DATE, DATE
);
}
private void stubBaseDay(List<RenderedLessonDto> lessons) {
when(groupRepository.findAll()).thenReturn(List.of(group()));
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
when(generatorService.buildScheduleForGroup(GROUP_ID, DATE, DATE)).thenReturn(lessons);
}
private StudentGroup group() {
StudentGroup group = new StudentGroup();
group.setId(GROUP_ID);
group.setName("Группа 31");
return group;
}
private ScheduleOverride replacement(long baseRuleSlotId, long teacherId) {
ScheduleOverride override = override(baseRuleSlotId, "REPLACE");
User newTeacher = new User();
newTeacher.setId(teacherId);
newTeacher.setRole(Role.TEACHER);
newTeacher.setFullName("Замещающий преподаватель");
override.setNewTeacher(newTeacher);
return override;
}
private ScheduleOverride override(long baseRuleSlotId, String action) {
ScheduleRuleSlot baseRuleSlot = new ScheduleRuleSlot();
baseRuleSlot.setId(baseRuleSlotId);
ScheduleOverride override = new ScheduleOverride();
override.setBaseRuleSlot(baseRuleSlot);
override.setLessonDate(DATE);
override.setAction(action);
return override;
}
private RenderedLessonDto lesson(long baseRuleSlotId, long teacherId, int timeSlotOrder) {
return new RenderedLessonDto(
900L + baseRuleSlotId,
baseRuleSlotId,
DATE,
2,
"Вторник",
1,
ScheduleParity.BOTH,
(long) timeSlotOrder,
timeSlotOrder,
LocalTime.of(7 + timeSlotOrder * 2, 0),
LocalTime.of(8 + timeSlotOrder * 2, 30),
500L + baseRuleSlotId,
"Тестовая дисциплина " + baseRuleSlotId,
teacherId,
teacherId == SOURCE_TEACHER_ID ? "Исходный преподаватель" : "Замещающий преподаватель",
21L,
"А-101",
601L,
"Лекция",
"Очно",
null,
null,
List.of(),
List.of(),
List.of(GROUP_ID),
List.of("Группа 31"),
"Т",
2,
0,
0,
ScheduleParity.BOTH
);
}
}

View File

@@ -0,0 +1,365 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.repository.ScheduleRuleRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
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.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
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.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@Testcontainers
@SpringBootTest(
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
properties = "spring.jpa.open-in-view=false"
)
class ScheduleRuleServiceConcurrencyIntegrationTest {
@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 ScheduleRuleService service;
@Autowired
private ScheduleGeneratorService scheduleGeneratorService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
private ExecutorService executor;
@AfterEach
void stopExecutor() throws InterruptedException {
if (executor == null) {
return;
}
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
@Test
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
assertThat(AopUtils.isAopProxy(service)).isTrue();
SeedIds ids = createIsolatedSemesterAndLoadSeedIds();
ScheduleRuleDto request = request(ids);
executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<CreateOutcome>> futures = new ArrayList<>();
try (Connection lockConnection = dataSource.getConnection()) {
lockConnection.setAutoCommit(false);
int holderPid = lockSemesterRow(lockConnection, ids.semesterId());
futures.add(executor.submit(() -> createAfterSignal(request, ready, start)));
futures.add(executor.submit(() -> createAfterSignal(request, ready, start)));
awaitReady(ready);
start.countDown();
awaitTwoBlockedCreates(futures, holderPid);
lockConnection.commit();
}
List<CreateOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(outcome -> outcome.result() != null).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> outcome.failure() != null).singleElement()
.satisfies(outcome -> {
assertThat(outcome.failure()).isInstanceOf(ScheduleRuleConflictException.class);
assertThat(outcome.failure())
.hasMessage("Невозможно сохранить правило: слот занят");
});
assertThat(queryCount("SELECT count(*) FROM schedule_rules WHERE semester_id = ?", ids.semesterId()))
.isEqualTo(1L);
assertThat(queryCount("""
SELECT count(*)
FROM schedule_rule_slots slot
JOIN schedule_rules rule ON rule.id = slot.schedule_rule_id
WHERE rule.semester_id = ?
""", ids.semesterId())).isEqualTo(1L);
assertThat(queryCount("""
SELECT count(*)
FROM schedule_rule_groups rule_group
JOIN schedule_rules rule ON rule.id = rule_group.schedule_rule_id
WHERE rule.semester_id = ?
""", ids.semesterId())).isEqualTo(1L);
verify(scheduleGeneratorService, times(1)).clearCache();
}
private CreateOutcome createAfterSignal(ScheduleRuleDto request,
CountDownLatch ready,
CountDownLatch start) {
ready.countDown();
try {
if (!start.await(5, TimeUnit.SECONDS)) {
return new CreateOutcome(null, new IllegalStateException(
"Конкурентные операции не получили общий сигнал запуска"
));
}
return new CreateOutcome(service.create(request), null);
} catch (Throwable failure) {
return new CreateOutcome(null, failure);
}
}
private void awaitReady(CountDownLatch ready) {
try {
if (!ready.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Конкурентные операции не успели подготовиться");
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание конкурентных операций прервано", exception);
}
}
private int lockSemesterRow(Connection connection, long semesterId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT id, pg_backend_pid() FROM semesters WHERE id = ? FOR UPDATE"
)) {
statement.setLong(1, semesterId);
try (ResultSet result = statement.executeQuery()) {
assertThat(result.next()).as("Строка тестового семестра должна существовать").isTrue();
assertThat(result.getLong(1)).isEqualTo(semesterId);
int holderPid = result.getInt(2);
assertThat(holderPid).isPositive();
return holderPid;
}
}
}
private void awaitTwoBlockedCreates(List<Future<CreateOutcome>> futures, int holderPid) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
do {
failOnEarlyCompletion(futures);
Long blocked = jdbcTemplate.queryForObject("""
SELECT count(DISTINCT activity.pid)
FROM pg_stat_activity activity
WHERE activity.datname = current_database()
AND activity.pid <> ?
AND activity.state = 'active'
AND activity.wait_event_type = 'Lock'
AND lower(activity.query) LIKE '%semester%'
AND EXISTS (
SELECT 1
FROM pg_locks relation_lock
WHERE relation_lock.pid = activity.pid
AND relation_lock.relation = 'semesters'::regclass
)
""", Long.class, holderPid);
if (blocked != null && blocked >= 2L) {
return;
}
try {
Thread.sleep(25);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание блокировки операций прервано", exception);
}
} while (System.nanoTime() < deadline);
failOnEarlyCompletion(futures);
throw new IllegalStateException("Обе операции создания не заблокировались на строке одного семестра. "
+ "Снимок PostgreSQL: " + lockWaitSnapshot(holderPid));
}
private void failOnEarlyCompletion(List<Future<CreateOutcome>> futures) {
for (int index = 0; index < futures.size(); index++) {
Future<CreateOutcome> future = futures.get(index);
if (!future.isDone()) {
continue;
}
try {
CreateOutcome outcome = future.get(0, TimeUnit.MILLISECONDS);
String detail = outcome.failure() == null
? "операция неожиданно завершилась успешно"
: outcome.failure().getClass().getSimpleName() + ": " + outcome.failure().getMessage();
throw new IllegalStateException(
"Конкурентная операция " + (index + 1) + " завершилась до снятия внешнего барьера: " + detail
);
} catch (IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException(
"Не удалось прочитать ранний результат конкурентной операции " + (index + 1),
exception
);
}
}
}
private String lockWaitSnapshot(int holderPid) {
List<String> rows = jdbcTemplate.query("""
SELECT concat(
'pid=', activity.pid,
', state=', activity.state,
', wait=', coalesce(activity.wait_event_type, '-'), '/', coalesce(activity.wait_event, '-'),
', blockers=', pg_blocking_pids(activity.pid)::text,
', query=', left(regexp_replace(activity.query, '\\s+', ' ', 'g'), 180)
)
FROM pg_stat_activity activity
WHERE activity.datname = current_database()
AND activity.pid <> ?
ORDER BY activity.pid
""", (result, rowNumber) -> result.getString(1), holderPid);
return rows.isEmpty() ? "активные подключения не найдены" : String.join(" | ", rows);
}
private SeedIds createIsolatedSemesterAndLoadSeedIds() {
Long academicYearId = jdbcTemplate.queryForObject("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES ('2098-2099', DATE '2098-09-01', DATE '2099-06-30')
RETURNING id
""", Long.class);
Long semesterId = jdbcTemplate.queryForObject("""
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
VALUES (?, 'autumn', DATE '2098-09-01', DATE '2099-01-31')
RETURNING id
""", Long.class, academicYearId);
return new SeedIds(
semesterId,
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
queryId("""
SELECT slot.id
FROM time_slots slot
JOIN time_slot_scopes scope ON scope.id = slot.time_slot_scope_id
WHERE scope.code = 'default' AND slot.order_number = 1
"""),
queryId("SELECT id FROM users WHERE username = 'Тестовый преподаватель'"),
queryId("SELECT id FROM classrooms WHERE name = '101 Ленинская'"),
queryId("SELECT id FROM lesson_types WHERE name = 'Лекция'")
);
}
private long queryId(String sql) {
Long value = jdbcTemplate.queryForObject(sql, Long.class);
assertThat(value).as("Тестовый seed-идентификатор должен существовать: %s", sql).isNotNull();
return value;
}
private long queryCount(String sql, Object... arguments) {
Long value = jdbcTemplate.queryForObject(sql, Long.class, arguments);
assertThat(value).isNotNull();
return value;
}
private ScheduleRuleDto request(SeedIds ids) {
ScheduleRuleSlotDto slot = new ScheduleRuleSlotDto(
null,
1,
null,
ScheduleParity.BOTH,
ids.timeSlotId(),
null,
null,
null,
null,
List.of(),
List.of(),
ids.teacherId(),
null,
ids.classroomId(),
null,
ids.lessonTypeId(),
null,
"Очно"
);
return new ScheduleRuleDto(
null,
ids.subjectId(),
null,
ids.semesterId(),
null,
null,
2,
0,
0,
1,
1,
1,
List.of(ids.groupId()),
List.of(),
List.of(slot)
);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = ScheduleRule.class)
@EnableJpaRepositories(basePackageClasses = ScheduleRuleRepository.class)
@Import({ScheduleRuleService.class, TestDependencies.class})
static class TestApplication {
}
@TestConfiguration(proxyBeanMethods = false)
static class TestDependencies {
@Bean
ScheduleGeneratorService scheduleGeneratorService() {
return mock(ScheduleGeneratorService.class);
}
}
private record SeedIds(
long semesterId,
long subjectId,
long groupId,
long timeSlotId,
long teacherId,
long classroomId,
long lessonTypeId
) {
}
private record CreateOutcome(ScheduleRuleDto result, Throwable failure) {
}
}

View File

@@ -0,0 +1,600 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.LessonTypesRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
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.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
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 ScheduleRuleServiceTest {
private static final long RULE_ID = 100L;
private static final long SUBJECT_ID = 10L;
private static final long SEMESTER_ID = 20L;
private static final long GROUP_ID = 30L;
private static final long TIME_SLOT_ID = 40L;
private static final long TEACHER_ID = 50L;
private static final long OTHER_TEACHER_ID = 51L;
private static final long CLASSROOM_ID = 60L;
private static final long OTHER_CLASSROOM_ID = 61L;
private static final long LECTURE_TYPE_ID = 70L;
private static final long LABORATORY_TYPE_ID = 71L;
private static final long SUBGROUP_A_ID = 80L;
private static final long SUBGROUP_B_ID = 81L;
@Mock
private ScheduleRuleRepository scheduleRuleRepository;
@Mock
private SubjectRepository subjectRepository;
@Mock
private SemesterRepository semesterRepository;
@Mock
private GroupRepository groupRepository;
@Mock
private TimeSlotRepository timeSlotRepository;
@Mock
private UserRepository userRepository;
@Mock
private ClassroomRepository classroomRepository;
@Mock
private LessonTypesRepository lessonTypesRepository;
@Mock
private SubgroupRepository subgroupRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private ScheduleRuleService service;
private Subject subject;
private Semester semester;
private StudentGroup group;
private TimeSlot timeSlot;
private User teacher;
private User otherTeacher;
private Classroom classroom;
private Classroom otherClassroom;
private LessonType lectureType;
private LessonType laboratoryType;
private Subgroup subgroupA;
private Subgroup subgroupB;
@BeforeEach
void setUp() {
subject = subject();
semester = semester();
group = group();
timeSlot = timeSlot();
teacher = user(TEACHER_ID, Role.TEACHER, "Первый преподаватель");
otherTeacher = user(OTHER_TEACHER_ID, Role.TEACHER, "Второй преподаватель");
classroom = classroom(CLASSROOM_ID, "А-101");
otherClassroom = classroom(OTHER_CLASSROOM_ID, "Б-202");
lectureType = lessonType(LECTURE_TYPE_ID, "Лекция");
laboratoryType = lessonType(LABORATORY_TYPE_ID, "Лабораторная работа");
subgroupA = subgroup(SUBGROUP_A_ID, "Подгруппа 1");
subgroupB = subgroup(SUBGROUP_B_ID, "Подгруппа 2");
service = new ScheduleRuleService(
scheduleRuleRepository,
subjectRepository,
semesterRepository,
groupRepository,
timeSlotRepository,
userRepository,
classroomRepository,
lessonTypesRepository,
subgroupRepository,
scheduleGeneratorService
);
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester));
when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID)))
.thenReturn(List.of(semester));
when(subjectRepository.findById(SUBJECT_ID)).thenReturn(Optional.of(subject));
when(groupRepository.findAllById(List.of(GROUP_ID))).thenReturn(List.of(group));
when(timeSlotRepository.findById(TIME_SLOT_ID)).thenReturn(Optional.of(timeSlot));
when(userRepository.findById(TEACHER_ID)).thenReturn(Optional.of(teacher));
when(userRepository.findById(OTHER_TEACHER_ID)).thenReturn(Optional.of(otherTeacher));
when(classroomRepository.findById(CLASSROOM_ID)).thenReturn(Optional.of(classroom));
when(classroomRepository.findById(OTHER_CLASSROOM_ID)).thenReturn(Optional.of(otherClassroom));
when(lessonTypesRepository.findById(LECTURE_TYPE_ID)).thenReturn(Optional.of(lectureType));
when(lessonTypesRepository.findById(LABORATORY_TYPE_ID)).thenReturn(Optional.of(laboratoryType));
when(subgroupRepository.findById(SUBGROUP_A_ID)).thenReturn(Optional.of(subgroupA));
when(subgroupRepository.findById(SUBGROUP_B_ID)).thenReturn(Optional.of(subgroupB));
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, null))
.thenReturn(List.of());
when(scheduleRuleRepository.saveAndFlush(any(ScheduleRule.class)))
.thenAnswer(invocation -> assignGeneratedIds(invocation.getArgument(0)));
}
@Test
void rejectsOddHoursOfEveryLessonCategoryWithRussianMessages() {
assertThatThrownBy(() -> service.create(ruleRequest(1, 0, 0, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Количество часов для лекций должно быть чётным");
assertThatThrownBy(() -> service.create(ruleRequest(0, 3, 0, laboratorySlot(SUBGROUP_A_ID))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Количество часов для лабораторных занятий должно быть чётным");
assertThatThrownBy(() -> service.create(ruleRequest(0, 0, 5, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Количество часов для практических занятий должно быть чётным");
verify(semesterRepository, never()).findByIdForUpdate(any());
}
@Test
void validatesMissingNegativeZeroAndUnusedTypeHours() {
assertThatThrownBy(() -> service.create(ruleRequest(null, 0, 0, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Укажите количество часов для лекций");
assertThatThrownBy(() -> service.create(ruleRequest(-2, 0, 0, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Количество часов для лекций не может быть отрицательным");
assertThatThrownBy(() -> service.create(ruleRequest(0, 0, 0, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Укажите часы хотя бы для одного типа занятий");
ScheduleRuleDto saved = service.create(ruleRequest(2, 0, 0, lectureSlot()));
assertThat(saved.lectureAcademicHours()).isEqualTo(2);
assertThat(saved.laboratoryAcademicHours()).isZero();
assertThat(saved.practiceAcademicHours()).isZero();
}
@Test
void rejectsActiveUserWithoutTeacherRole() {
User administrator = user(TEACHER_ID, Role.ADMIN, "Администратор");
when(userRepository.findById(TEACHER_ID)).thenReturn(Optional.of(administrator));
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("В слот расписания можно назначить только пользователя с ролью преподавателя");
verify(scheduleRuleRepository, never()).saveAndFlush(any());
}
@Test
void treatsMissingReferencedTeacherAsInvalidPayload() {
when(userRepository.findById(TEACHER_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, lectureSlot())))
.isExactlyInstanceOf(IllegalArgumentException.class)
.hasMessage("Преподаватель не найден");
verify(scheduleRuleRepository, never()).saveAndFlush(any());
}
@Test
void rejectsUnsupportedFormatAndAcceptsTrimmedSupportedFormat() {
ScheduleRuleSlotDto invalid = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Гибрид"
);
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, invalid)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Формат занятия должен быть «Очно» или «Онлайн»");
ScheduleRuleSlotDto valid = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), " Онлайн "
);
ScheduleRuleDto saved = service.create(ruleRequest(2, 0, 0, valid));
assertThat(saved.slots()).singleElement().extracting(ScheduleRuleSlotDto::lessonFormat)
.isEqualTo("Онлайн");
}
@Test
void rejectsExactDuplicateInsideNewRule() {
ScheduleRuleSlotDto duplicate = lectureSlot();
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, duplicate, duplicate)))
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
assertThat(exception.getMessage())
.isEqualTo("Невозможно сохранить правило: в списке есть дублирующиеся слоты");
assertThat(exception.getConflictRule()).isNull();
assertThat(exception.getConflictFields()).containsExactly("slot");
assertThat(exception.getConflictReasons()).containsExactly("Дублирующийся слот");
});
verify(scheduleRuleRepository, never()).saveAndFlush(any());
}
@Test
void rejectsInternalConflictAndReportsAllSharedResourcesInStableOrder() {
ScheduleRuleSlotDto first = lectureSlot();
ScheduleRuleSlotDto second = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Онлайн"
);
assertThatThrownBy(() -> service.create(ruleRequest(4, 0, 0, first, second)))
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
assertThat(exception.getMessage())
.isEqualTo("Невозможно сохранить правило: слоты внутри правила конфликтуют");
assertThat(exception.getConflictRule()).isNull();
assertThat(exception.getConflictFields()).containsExactly("teacher", "classroom", "group");
assertThat(exception.getConflictReasons())
.containsExactly("Преподаватель", "Аудитория", "Группа");
});
}
@Test
void allowsOddAndEvenSlotsBecauseTheirActiveWeeksDoNotOverlap() {
ScheduleRuleSlotDto odd = slot(
ScheduleParity.ODD, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Очно"
);
ScheduleRuleSlotDto even = slot(
ScheduleParity.EVEN, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Очно"
);
ScheduleRuleDto saved = service.create(ruleRequest(4, 0, 0, odd, even));
assertThat(saved.slots()).extracting(ScheduleRuleSlotDto::parity)
.containsExactlyInAnyOrder(ScheduleParity.ODD, ScheduleParity.EVEN);
}
@Test
void rejectsBothParityWhenItOverlapsOddOrEvenSlot() {
ScheduleRuleSlotDto bothFirst = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Очно"
);
ScheduleRuleSlotDto oddSecond = slot(
ScheduleParity.ODD, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Онлайн"
);
assertThatThrownBy(() -> service.create(ruleRequest(6, 0, 0, bothFirst, oddSecond)))
.isInstanceOf(ScheduleRuleConflictException.class)
.hasMessage("Невозможно сохранить правило: слоты внутри правила конфликтуют");
ScheduleRuleSlotDto evenFirst = slot(
ScheduleParity.EVEN, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Очно"
);
ScheduleRuleSlotDto bothSecond = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Онлайн"
);
assertThatThrownBy(() -> service.create(ruleRequest(6, 0, 0, evenFirst, bothSecond)))
.isInstanceOf(ScheduleRuleConflictException.class)
.hasMessage("Невозможно сохранить правило: слоты внутри правила конфликтуют");
}
@Test
void rejectsWholeGroupAndSubgroupAtSameTime() {
ScheduleRuleSlotDto wholeGroup = laboratorySlot();
ScheduleRuleSlotDto subgroup = slot(
ScheduleParity.BOTH, OTHER_TEACHER_ID, OTHER_CLASSROOM_ID,
LABORATORY_TYPE_ID, List.of(SUBGROUP_A_ID), "Очно"
);
assertThatThrownBy(() -> service.create(ruleRequest(0, 4, 0, wholeGroup, subgroup)))
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
assertThat(exception.getConflictFields()).containsExactly("group");
assertThat(exception.getConflictReasons()).containsExactly("Группа");
});
}
@Test
void rejectsSameSubgroupAtSameTime() {
ScheduleRuleSlotDto first = laboratorySlot(SUBGROUP_A_ID);
ScheduleRuleSlotDto second = slot(
ScheduleParity.BOTH, OTHER_TEACHER_ID, OTHER_CLASSROOM_ID,
LABORATORY_TYPE_ID, List.of(SUBGROUP_A_ID), "Онлайн"
);
assertThatThrownBy(() -> service.create(ruleRequest(0, 4, 0, first, second)))
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception ->
assertThat(exception.getConflictFields()).containsExactly("group"));
}
@Test
void allowsDifferentSubgroupsOfSameGroupAtSameTime() {
ScheduleRuleSlotDto first = laboratorySlot(SUBGROUP_A_ID);
ScheduleRuleSlotDto second = slot(
ScheduleParity.BOTH, OTHER_TEACHER_ID, OTHER_CLASSROOM_ID,
LABORATORY_TYPE_ID, List.of(SUBGROUP_B_ID), "Очно"
);
ScheduleRuleDto saved = service.create(ruleRequest(0, 4, 0, first, second));
assertThat(saved.slots()).hasSize(2);
}
@Test
void ignoresArchivedExternalRuleButRejectsEquivalentActiveRule() {
ScheduleRule archived = persistedRule(200L, LifecycleEntity.STATUS_ARCHIVED, "Очно");
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, null))
.thenReturn(List.of(archived));
ScheduleRuleDto saved = service.create(ruleRequest(2, 0, 0, lectureSlot()));
assertThat(saved.id()).isNotNull();
ScheduleRule active = persistedRule(201L, LifecycleEntity.STATUS_ACTIVE, "Очно");
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, null))
.thenReturn(List.of(active));
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, lectureSlot())))
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
assertThat(exception.getMessage()).isEqualTo("Невозможно сохранить правило: слот занят");
assertThat(exception.getConflictRule()).isNotNull();
assertThat(exception.getConflictRule().id()).isEqualTo(201L);
assertThat(exception.getConflictFields()).containsExactly("teacher", "classroom", "group");
});
}
@Test
void updateExcludesCurrentRuleFromExternalConflictCheck() {
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, RULE_ID))
.thenReturn(List.of(managed));
ScheduleRuleSlotDto updatedSlot = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Онлайн"
);
ScheduleRuleDto updated = service.update(RULE_ID, ruleRequest(2, 0, 0, updatedSlot));
assertThat(updated.id()).isEqualTo(RULE_ID);
assertThat(updated.slots()).singleElement().extracting(ScheduleRuleSlotDto::lessonFormat)
.isEqualTo("Онлайн");
verify(scheduleRuleRepository).findActiveBySemesterIdWithDetails(SEMESTER_ID, RULE_ID);
}
@Test
void rejectedUpdateDoesNotMutateManagedRule() {
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
ScheduleRule externalConflict = persistedRule(201L, LifecycleEntity.STATUS_ACTIVE, "Онлайн");
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, RULE_ID))
.thenReturn(List.of(externalConflict));
ScheduleRuleSlotDto changedSlot = slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Онлайн"
);
assertThatThrownBy(() -> service.update(RULE_ID, ruleRequest(2, 0, 0, changedSlot)))
.isInstanceOf(ScheduleRuleConflictException.class)
.hasMessage("Невозможно сохранить правило: слот занят");
assertThat(managed.getSlots()).singleElement().satisfies(slot ->
assertThat(slot.getLessonFormat()).isEqualTo("Очно"));
verify(scheduleRuleRepository, never()).saveAndFlush(managed);
}
private ScheduleRuleDto ruleRequest(Integer lectureHours,
Integer laboratoryHours,
Integer practiceHours,
ScheduleRuleSlotDto... slots) {
return new ScheduleRuleDto(
null,
SUBJECT_ID,
null,
SEMESTER_ID,
null,
null,
lectureHours,
laboratoryHours,
practiceHours,
1,
1,
1,
List.of(GROUP_ID),
null,
List.of(slots)
);
}
private ScheduleRuleSlotDto lectureSlot() {
return slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
LECTURE_TYPE_ID, List.of(), "Очно"
);
}
private ScheduleRuleSlotDto laboratorySlot(Long... subgroupIds) {
return slot(
ScheduleParity.BOTH,
TEACHER_ID,
CLASSROOM_ID,
LABORATORY_TYPE_ID,
List.of(subgroupIds),
"Очно"
);
}
private ScheduleRuleSlotDto slot(ScheduleParity parity,
long teacherId,
long classroomId,
long lessonTypeId,
List<Long> subgroupIds,
String format) {
return new ScheduleRuleSlotDto(
null,
1,
null,
parity,
TIME_SLOT_ID,
null,
null,
null,
null,
subgroupIds,
null,
teacherId,
null,
classroomId,
null,
lessonTypeId,
null,
format
);
}
private ScheduleRule persistedRule(long id, String status, String format) {
ScheduleRule rule = new ScheduleRule();
rule.setId(id);
rule.setStatus(status);
rule.setSubject(subject);
rule.setSemester(semester);
rule.setLectureAcademicHours(2);
rule.setLaboratoryAcademicHours(0);
rule.setPracticeAcademicHours(0);
rule.setLectureStartWeek(1);
rule.setLaboratoryStartWeek(1);
rule.setPracticeStartWeek(1);
rule.setGroups(new LinkedHashSet<>(List.of(group)));
ScheduleRuleSlot slot = new ScheduleRuleSlot();
ReflectionTestUtils.setField(slot, "id", id * 10);
slot.setScheduleRule(rule);
slot.setDayOfWeek(1);
slot.setParity(ScheduleParity.BOTH);
slot.setTimeSlot(timeSlot);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lectureType);
slot.setLessonFormat(format);
rule.setSlots(new LinkedHashSet<>(List.of(slot)));
return rule;
}
private ScheduleRule assignGeneratedIds(ScheduleRule rule) {
if (rule.getId() == null) {
rule.setId(900L);
}
AtomicLong nextSlotId = new AtomicLong(1_000L);
rule.getSlots().stream()
.filter(slot -> slot.getId() == null)
.forEach(slot -> ReflectionTestUtils.setField(slot, "id", nextSlotId.getAndIncrement()));
return rule;
}
private Subject subject() {
Subject subject = new Subject();
subject.setId(SUBJECT_ID);
subject.setName("Тестовая дисциплина");
subject.setDepartmentId(1L);
return subject;
}
private Semester semester() {
AcademicYear year = new AcademicYear();
year.setId(1L);
year.setTitle("2026-2027");
year.setStartDate(LocalDate.of(2026, 9, 1));
year.setEndDate(LocalDate.of(2027, 6, 30));
Semester semester = new Semester();
semester.setId(SEMESTER_ID);
semester.setAcademicYear(year);
semester.setSemesterType(SemesterType.autumn);
semester.setStartDate(LocalDate.of(2026, 9, 1));
semester.setEndDate(LocalDate.of(2027, 1, 31));
return semester;
}
private StudentGroup group() {
StudentGroup group = new StudentGroup();
group.setId(GROUP_ID);
group.setName("Тестовая группа");
return group;
}
private TimeSlot timeSlot() {
TimeSlotScope scope = new TimeSlotScope();
scope.setId(1L);
scope.setApplyMode("DEFAULT");
TimeSlot slot = new TimeSlot();
slot.setId(TIME_SLOT_ID);
slot.setOrderNumber(1);
slot.setStartTime(LocalTime.of(9, 0));
slot.setEndTime(LocalTime.of(10, 30));
slot.setDurationMinutes(90);
slot.setTimeSlotScope(scope);
return slot;
}
private User user(long id, Role role, String name) {
User user = new User();
user.setId(id);
user.setRole(role);
user.setFullName(name);
user.setJobTitle("Преподаватель");
return user;
}
private Classroom classroom(long id, String name) {
Classroom classroom = new Classroom();
classroom.setId(id);
classroom.setName(name);
classroom.setIsAvailable(true);
return classroom;
}
private LessonType lessonType(long id, String name) {
LessonType type = new LessonType();
type.setId(id);
type.setLessonType(name);
return type;
}
private Subgroup subgroup(long id, String name) {
Subgroup subgroup = new Subgroup();
subgroup.setId(id);
subgroup.setName(name);
subgroup.setStudentGroup(group);
return subgroup;
}
}

View File

@@ -0,0 +1,244 @@
package com.magistr.app.service;
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
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.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Testcontainers
class TenantLifecyclePostgreSqlIntegrationTest {
private static final String DOMAIN = "alpha";
private static final String OLD_DATABASE = "tenant_old";
private static final String BAD_CHECKSUM_DATABASE = "tenant_bad_checksum";
private static final String VALID_DATABASE = "tenant_valid";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
private TenantRoutingDataSource routingDataSource;
private TenantDatabaseMigrationService migrationService;
private KubernetesTenantSecretUpdater tenantSecretUpdater;
private TenantLifecycleService lifecycleService;
private RetiredTenantPoolService retiredPoolService;
private HikariDataSource oldPool;
private TenantConfig oldConfig;
@BeforeEach
void setUp() throws Exception {
createDatabase(OLD_DATABASE);
createDatabase(BAD_CHECKSUM_DATABASE);
createDatabase(VALID_DATABASE);
routingDataSource = new TenantRoutingDataSource();
migrationService = new TenantDatabaseMigrationService();
tenantSecretUpdater = mock(KubernetesTenantSecretUpdater.class);
retiredPoolService = new RetiredTenantPoolService(Duration.ofSeconds(2), Duration.ofMillis(25));
lifecycleService = new TenantLifecycleService(
routingDataSource,
tenantSecretUpdater,
migrationService,
retiredPoolService
);
oldConfig = config("Старый tenant", jdbcUrl(OLD_DATABASE), POSTGRES.getPassword());
migrateDatabase(oldConfig);
TenantConfig badChecksumConfig = config(
"Tenant с повреждённой историей",
jdbcUrl(BAD_CHECKSUM_DATABASE),
POSTGRES.getPassword()
);
migrateDatabase(badChecksumConfig);
corruptFlywayChecksum(jdbcUrl(BAD_CHECKSUM_DATABASE));
oldPool = routingDataSource.prepareTenantDataSource(oldConfig);
assertThat(routingDataSource.swapTenant(oldConfig, oldPool)).isNull();
assertOldState();
}
@AfterEach
void closePools() {
TenantContext.clear();
if (routingDataSource != null) {
routingDataSource.getTenantDataSource(DOMAIN).ifPresent(this::closeIfHikari);
}
if (oldPool != null && !oldPool.isClosed()) {
oldPool.close();
}
if (retiredPoolService != null) {
retiredPoolService.shutdown();
}
}
@Test
void preservesOldPoolForCredentialsFlywayAndPersistenceFailuresThenSwapsSuccessfully() throws Exception {
TenantConfig wrongCredentials = config(
"Неверные credentials",
jdbcUrl(OLD_DATABASE),
POSTGRES.getPassword() + "-wrong"
);
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(wrongCredentials))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось подключиться к базе данных тенанта");
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
assertOldState();
reset(tenantSecretUpdater);
TenantConfig badChecksum = config(
"Повреждённый Flyway",
jdbcUrl(BAD_CHECKSUM_DATABASE),
POSTGRES.getPassword()
);
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(badChecksum))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
assertOldState();
reset(tenantSecretUpdater);
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
TenantConfig validCandidate = config(
"Новый tenant",
jdbcUrl(VALID_DATABASE),
POSTGRES.getPassword()
);
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(validCandidate))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
assertOldState();
reset(tenantSecretUpdater);
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
TenantConfig saved;
try (Connection inFlightOldConnection = oldPool.getConnection()) {
saved = lifecycleService.addOrUpdateTenant(validCandidate);
assertThat(oldPool.isClosed()).isFalse();
try (Statement statement = inFlightOldConnection.createStatement();
ResultSet result = statement.executeQuery("SELECT current_database()")) {
assertThat(result.next()).isTrue();
assertThat(result.getString(1)).isEqualTo(OLD_DATABASE);
}
}
assertThat(saved.getDomain()).isEqualTo(DOMAIN);
assertThat(saved.getUrl()).isEqualTo(jdbcUrl(VALID_DATABASE));
awaitPoolClosed(oldPool);
DataSource activePool = routingDataSource.getTenantDataSource(DOMAIN).orElseThrow();
assertThat(activePool).isNotSameAs(oldPool);
assertThat(activePool).isInstanceOfSatisfying(
HikariDataSource.class,
pool -> assertThat(pool.isClosed()).isFalse()
);
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE);
}
private void awaitPoolClosed(HikariDataSource pool) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (!pool.isClosed() && System.nanoTime() < deadline) {
try {
Thread.sleep(25);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Ожидание закрытия старого tenant pool прервано", exception);
}
}
assertThat(pool.isClosed()).as("Старый tenant pool должен закрыться после drain").isTrue();
}
private void assertOldState() {
assertThat(routingDataSource.getTenantDataSource(DOMAIN)).containsSame(oldPool);
TenantConfig storedConfig = routingDataSource.snapshotTenantConfigs().get(DOMAIN);
assertThat(storedConfig).isNotNull();
assertThat(storedConfig.getUrl()).isEqualTo(jdbcUrl(OLD_DATABASE));
assertThat(storedConfig.getUsername()).isEqualTo(POSTGRES.getUsername());
assertThat(oldPool.isClosed()).isFalse();
assertThat(currentDatabaseThroughRouter()).isEqualTo(OLD_DATABASE);
}
private String currentDatabaseThroughRouter() {
TenantContext.setCurrentTenant(DOMAIN);
try (Connection connection = routingDataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT current_database()")) {
assertThat(result.next()).isTrue();
return result.getString(1);
} catch (Exception failure) {
throw new IllegalStateException("Старое tenant-подключение перестало работать", failure);
} finally {
TenantContext.clear();
}
}
private void createDatabase(String databaseName) throws Exception {
try (Connection connection = POSTGRES.createConnection("");
Statement statement = connection.createStatement()) {
statement.execute("CREATE DATABASE " + databaseName);
}
}
private void migrateDatabase(TenantConfig config) {
HikariDataSource dataSource = routingDataSource.prepareTenantDataSource(config);
try {
migrationService.migrate(dataSource);
} finally {
dataSource.close();
}
}
private void corruptFlywayChecksum(String url) throws Exception {
try (Connection connection = DriverManager.getConnection(
url,
POSTGRES.getUsername(),
POSTGRES.getPassword()
); Statement statement = connection.createStatement()) {
int changed = statement.executeUpdate("""
UPDATE flyway_schema_history
SET checksum = checksum + 1
WHERE version = '4'
""");
assertThat(changed).isEqualTo(1);
}
}
private TenantConfig config(String name, String url, String password) {
return new TenantConfig(name, DOMAIN, url, POSTGRES.getUsername(), password);
}
private String jdbcUrl(String databaseName) {
return "jdbc:postgresql://" + POSTGRES.getHost() + ":"
+ POSTGRES.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT) + "/" + databaseName;
}
private void closeIfHikari(DataSource dataSource) {
if (dataSource instanceof HikariDataSource hikariDataSource && !hikariDataSource.isClosed()) {
hikariDataSource.close();
}
}
}

View File

@@ -0,0 +1,323 @@
package com.magistr.app.service;
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.inOrder;
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 TenantLifecycleServiceTest {
@Mock
private TenantRoutingDataSource routingDataSource;
@Mock
private KubernetesTenantSecretUpdater tenantSecretUpdater;
@Mock
private TenantDatabaseMigrationService migrationService;
@Mock
private RetiredTenantPoolService retiredPoolService;
@Mock
private HikariDataSource candidate;
@Mock
private HikariDataSource oldDataSource;
@Mock
private Connection candidateConnection;
private TenantLifecycleService service;
private TenantConfig oldAlpha;
private TenantConfig beta;
private Map<String, TenantConfig> oldSnapshot;
@BeforeEach
void setUp() throws Exception {
service = new TenantLifecycleService(
routingDataSource,
tenantSecretUpdater,
migrationService,
retiredPoolService
);
oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
beta = config("Beta", "beta", "jdbc:beta", "beta-user", "beta-password");
oldSnapshot = new LinkedHashMap<>();
oldSnapshot.put("beta", beta);
oldSnapshot.put("alpha", oldAlpha);
when(routingDataSource.snapshotTenantConfigs()).thenReturn(oldSnapshot);
when(routingDataSource.prepareTenantDataSource(any(TenantConfig.class))).thenReturn(candidate);
when(candidate.getConnection()).thenReturn(candidateConnection);
when(candidateConnection.isValid(5)).thenReturn(true);
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
when(routingDataSource.swapTenant(any(TenantConfig.class), any()))
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
}
@Test
void appliesSuccessfulReplacementInSafeOrderAndClosesOnlyOldPool() throws Exception {
TenantConfig requested = config(
" Новый Alpha ", " ALPHA ", " jdbc:new-alpha ", "new-user", "new-password"
);
TenantConfig saved = service.addOrUpdateTenant(requested);
assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
ArgumentCaptor<List<TenantConfig>> persistedConfigs = listCaptor();
InOrder order = inOrder(
routingDataSource,
candidate,
candidateConnection,
migrationService,
tenantSecretUpdater,
retiredPoolService
);
order.verify(routingDataSource).snapshotTenantConfigs();
order.verify(routingDataSource).prepareTenantDataSource(preparedConfig.capture());
order.verify(candidate).getConnection();
order.verify(candidateConnection).isValid(5);
order.verify(candidateConnection).close();
order.verify(migrationService).migrate(candidate);
order.verify(tenantSecretUpdater).updateTenantsConfig(persistedConfigs.capture());
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
order.verify(retiredPoolService).retire(oldDataSource);
assertConfig(preparedConfig.getValue(),
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
assertThat(persistedConfigs.getValue()).extracting(TenantConfig::getDomain)
.containsExactly("alpha", "beta");
assertConfig(persistedConfigs.getValue().get(0),
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
verify(candidate, never()).close();
}
@Test
void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception {
when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации"));
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось подключиться к базе данных тенанта");
verify(candidate).close();
verify(migrationService, never()).migrate(any());
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
verify(routingDataSource, never()).swapTenant(any(), any());
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void closesCandidateAndKeepsOldStateWhenFlywayFails() {
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
.when(migrationService).migrate(candidate);
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
verify(candidate).close();
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
verify(routingDataSource, never()).swapTenant(any(), any());
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void closesCandidateAndRestoresOldSnapshotWhenPersistenceReturnsFalse() {
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
.containsExactly("alpha", "beta");
assertConfig(snapshots.getAllValues().get(1).get(0),
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
verify(candidate).close();
verify(routingDataSource, never()).swapTenant(any(), any());
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void restoresOldSnapshotWhenFirstPersistenceThrows() {
when(tenantSecretUpdater.updateTenantsConfig(any()))
.thenThrow(new IllegalStateException("неопределённый исход PATCH"))
.thenReturn(true);
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
verify(candidate).close();
verify(routingDataSource, never()).swapTenant(any(), any());
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void reportsDesynchronizationWhenPersistenceAndCompensationFail() {
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, false);
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret");
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
verify(candidate).close();
verify(routingDataSource, never()).swapTenant(any(), any());
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void compensatesPersistedSnapshotWhenActivationFails() {
when(routingDataSource.swapTenant(any(), any()))
.thenThrow(new IllegalStateException("тестовый отказ активации"));
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена");
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain)
.containsExactly("alpha", "beta");
assertConfig(snapshots.getAllValues().get(0).get(0),
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
.containsExactly("alpha", "beta");
assertConfig(snapshots.getAllValues().get(1).get(0),
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
verify(candidate).close();
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void reportsSecretDesynchronizationWhenActivationAndCompensationFail() {
when(routingDataSource.swapTenant(any(), any()))
.thenThrow(new IllegalStateException("тестовый отказ активации"));
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, false);
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось активировать подключение тенанта; требуется проверить tenant Secret");
verify(candidate).close();
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void removalPersistsDesiredStateBeforeAtomicRemovalAndClosesRemovedPool() {
when(routingDataSource.removeTenantAtomically("alpha"))
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
TenantConfig removed = service.removeTenant(" ALPHA ");
assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
ArgumentCaptor<List<TenantConfig>> desired = listCaptor();
InOrder order = inOrder(routingDataSource, tenantSecretUpdater, retiredPoolService);
order.verify(routingDataSource).snapshotTenantConfigs();
order.verify(tenantSecretUpdater).updateTenantsConfig(desired.capture());
order.verify(routingDataSource).removeTenantAtomically("alpha");
order.verify(retiredPoolService).retire(oldDataSource);
assertThat(desired.getValue()).extracting(TenantConfig::getDomain).containsExactly("beta");
}
@Test
void removalFailureRestoresOldPersistedSnapshotAndKeepsPoolOpen() {
when(routingDataSource.removeTenantAtomically("alpha"))
.thenThrow(new IllegalStateException("тестовый отказ удаления"));
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
assertThatThrownBy(() -> service.removeTenant("alpha"))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена");
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain)
.containsExactly("beta");
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
.containsExactly("alpha", "beta");
verify(retiredPoolService, never()).retire(oldDataSource);
}
@Test
void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() {
String maxLengthDomain = "a".repeat(63);
TenantConfig shortest = service.addOrUpdateTenant(config(
"Короткий", " A ", "jdbc:short", "user", "password"
));
TenantConfig longest = service.addOrUpdateTenant(config(
"Длинный", maxLengthDomain, "jdbc:long", "user", "password"
));
assertThat(shortest.getDomain()).isEqualTo("a");
assertThat(longest.getDomain()).isEqualTo(maxLengthDomain);
assertThatThrownBy(() -> service.addOrUpdateTenant(config(
"Слишком длинный", "a".repeat(64), "jdbc:too-long", "user", "password"
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Домен содержит недопустимые символы");
assertThatThrownBy(() -> service.addOrUpdateTenant(config(
"С точкой", "alpha.example", "jdbc:dotted", "user", "password"
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Домен содержит недопустимые символы");
}
private TenantConfig newAlpha() {
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
}
private TenantConfig config(String name,
String domain,
String url,
String username,
String password) {
return new TenantConfig(name, domain, url, username, password);
}
private void assertConfig(TenantConfig actual,
String name,
String domain,
String url,
String username,
String password) {
assertThat(actual.getName()).isEqualTo(name);
assertThat(actual.getDomain()).isEqualTo(domain);
assertThat(actual.getUrl()).isEqualTo(url);
assertThat(actual.getUsername()).isEqualTo(username);
assertThat(actual.getPassword()).isEqualTo(password);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private ArgumentCaptor<List<TenantConfig>> listCaptor() {
return ArgumentCaptor.forClass((Class) List.class);
}
}

View File

@@ -93,6 +93,9 @@ Redirect по ролям:
```
Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается.
Один refresh-токен можно успешно использовать только один раз, в том числе при параллельных
запросах: первый запрос получает новую пару токенов, остальные получают `401`, а их cookie
очищается.
### `POST /api/auth/logout`
@@ -426,6 +429,14 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
}
```
`PUT /api/admin/academic-calendars/{id}/grid` выполняет атомарную полную замену. До
удаления прежних строк backend проверяет весь список: он должен быть непустым и не
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber`с номером семидневного периода
от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
**Привязка дисциплин к графику:**
```json
[
@@ -476,15 +487,32 @@ CRUD доступен по:
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
| `POST` | `/api/admin/schedule-rules` | Создать правило |
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило |
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит
часов обязателен, неотрицателен и кратен двум; ноль разрешён для неиспользуемого типа, но
суммарно хотя бы один тип должен иметь положительный лимит. Если лимит типа ненулевой, в
правиле должен быть хотя бы один слот этого типа; слот типа не принимается при нулевом
лимите. Вложенные идентификаторы, которых нет в БД, считаются ошибкой payload и дают `400`.
`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
`teacherId` должен ссылаться на активного пользователя с ролью `TEACHER`, а
`lessonFormat` принимает только `Очно` или `Онлайн`. `parity` принимает только `BOTH`,
`ODD` или `EVEN`.
До сохранения backend попарно проверяет все слоты нового payload: точные дубли и
пересечения преподавателя, аудитории или аудитории обучающихся отклоняются. `ODD` и `EVEN`
не пересекаются; `BOTH` пересекается с обеими чётностями. Затем выполняется та же проверка
с активными правилами семестра. Активные недели рассчитываются по лимиту часов типа
занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое
фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных
слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один
слот, но слот для всей группы конфликтует с любой её подгруппой. Создание правил одного
семестра сериализуется блокировкой строки семестра в PostgreSQL. Конфликт с уже сохранённым
правилом возвращает `409 Conflict`:
```json
{
@@ -501,7 +529,19 @@ CRUD доступен по:
}
```
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или
`group`. Для точного дубля используется `slot`. При конфликте внутри нового payload
`conflictRule` отсутствует, потому что конфликтующей сохранённой записи ещё нет:
```json
{
"message": "Невозможно сохранить правило: слоты внутри правила конфликтуют",
"conflictFields": ["teacher", "group"],
"conflictReasons": ["Преподаватель", "Группа"]
}
```
`conflictReasons` содержит те же причины в русских подписях для интерфейса.
### `GET /api/lesson-types`
@@ -532,7 +572,17 @@ CRUD доступен по:
| `timeSlotId` | Временной слот |
| `parity` | `BOTH`, `ODD`, `EVEN` |
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
базовый день, из него добавляются только указанные `baseRuleSlotId`, после чего применяются
все overrides и выполняется окончательный фильтр преподавателя. Поэтому новый преподаватель
видит назначенную замену, а исходный больше её не видит. Если релевантных замен нет, обход
всех групп не выполняется.
Широкий пользовательский поиск без `groupId`, `departmentId` и teacher-only режима
разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с
просьбой уточнить группу или кафедру.
Пример:
@@ -562,7 +612,24 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
}
```
`action=CANCEL` отменяет конкретную пару. `MOVE` и `REPLACE` могут менять аудиторию, преподавателя, формат и временной слот.
Правила payload:
- `CANCEL` отменяет конкретную пару; поля `newTimeSlotId`, `newClassroomId`,
`newTeacherId` и `newLessonFormat` должны отсутствовать;
- `MOVE` требует новый временной слот или аудиторию; преподавателя и формат можно изменить
в том же запросе;
- `REPLACE` требует нового преподавателя, аудиторию или формат; временной слот можно
изменить в том же запросе;
- формат принимает только `Очно` или `Онлайн`;
- `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID
временного слота с тем же интервалом не считается переносом.
До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное
расписание: семестр, календарный график, чётность, активность сущностей и остаток часов.
Если пара не формируется или нарушена матрица действия, API возвращает `400` с русским
сообщением. Если результирующее время пересекается с занятым преподавателем, аудиторией,
группой или той же подгруппой, API возвращает `409 Conflict`; соседние интервалы и разные
подгруппы одной группы не конфликтуют.
## Загруженность
@@ -1059,7 +1126,12 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
**Логика:**
1. Создаёт HikariCP пул для нового тенанта
2. Запускает Flyway миграции на его БД
3. Обновляет Kubernetes ConfigMap
3. Обновляет внешний Kubernetes Secret `tenants-secret`
Backend соединяется с Kubernetes API только через проверенный service-account CA и
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
Kubernetes updater.
### `DELETE /api/database/tenants/{domain}`

View File

@@ -82,7 +82,7 @@ sequenceDiagram
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
| `KubernetesTenantSecretUpdater` | Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
### Определение тенанта
@@ -101,7 +101,10 @@ sequenceDiagram
Список тенантов хранится в JSON-файле:
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
- **Продакшн:** внешний Kubernetes Secret `tenants-secret`, ключ `tenants.json` монтируется в `/config/tenants.json`
`tenants-secret` не создаётся отслеживаемыми production-манифестами. Его подготавливает
оператор через secret manager по [`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md).
Формат:
```json
@@ -118,9 +121,14 @@ sequenceDiagram
### Жизненный цикл тенанта
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret`
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret`
Для обращения к Kubernetes API backend загружает
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом.
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
@@ -134,6 +142,47 @@ sequenceDiagram
---
## Транзакционные изменения расписания
`ScheduleRuleAdminController` является тонким HTTP-адаптером. Чтение, создание, изменение
и архивация правил выполняются через `ScheduleRuleService`; публичные write-методы сервиса
образуют транзакционные границы, поэтому ошибки валидации и конфликты выходят за Spring
proxy и приводят к rollback.
Создание правила до остальных запросов к БД захватывает `PESSIMISTIC_WRITE` на строке
целевого семестра. Update сначала блокирует строку правила, затем старый и новый семестры в
порядке ID; архивация блокирует правило и его семестр. После блокировок сервис заново
проверяет слоты payload и активные правила семестра, поэтому конкурентные запросы разных
pod не сохраняют два конфликтующих правила после проверки одного снимка.
`AcademicCalendarController` делегирует полную замену дневной сетки
`AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются
`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает
rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется
только после успешного commit.
`ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет
`ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`.
Сервис строит базовый день через `ScheduleQueryService`, накладывает сохранённые overrides и
кандидат общей функцией и только затем проверяет результирующие интервалы и ресурсы.
До чтения снимка даты сервис захватывает `pg_advisory_xact_lock` с отдельным namespace и
точным `LocalDate.toEpochDay()`. Блокировка живёт до commit/rollback, работает между pod и
изолирована по tenant-БД. При `PUT` и `DELETE` дополнительно блокируется ID override;
при смене даты старая и новая даты захватываются в стабильном порядке. После блокировок
строка перечитывается с `PESSIMISTIC_WRITE`, поэтому параллельное изменение не приводит к
lost update или проверке устаревшего снимка.
Teacher-only чтение строит базовое расписание напрямую через
`ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно
находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой
релевантной даты и добавляет только целевые `baseRuleSlotId`. Все overrides применяются до
финального teacher-фильтра и дедупликации; без релевантных замен полный обход групп не
выполняется.
---
## Аутентификация
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
@@ -145,6 +194,23 @@ sequenceDiagram
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
Ротация refresh-токена имеет single-use семантику. `RefreshTokenService.rotate()` выполняется
в транзакции, а `AuthRefreshTokenRepository` захватывает исходную строку через
`PESSIMISTIC_WRITE`. Поэтому два одновременных запроса с одним cookie сериализуются:
только первый создаёт следующий refresh-токен, второй видит уже отозванную строку и
завершается без выпуска новой сессии.
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
проверки роли и повторно очищает контекст в `afterCompletion`. Поэтому отказ с `401`/`403`
или публичный endpoint не может получить пользователя от предыдущего запроса того же
потока контейнера.
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
JWT-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле
`prod` или `production` startup-проверка дополнительно отклоняет известные legacy/placeholder
значения и требует `JWT_REFRESH_COOKIE_SECURE=true`. Production deployment явно включает
профиль `production` и получает `JWT_SECRET` только через `secretKeyRef`.

View File

@@ -135,6 +135,13 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Полная замена `academic_calendar_days` выполняется через транзакционный
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
расписания очищается только после commit.
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона.
2. Вычисляет номер недели и чётность.
@@ -152,7 +159,13 @@ Bearer-токен проверяется на backend. Frontend-скрытие
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию
базового расписания преподавателя. Для overrides с совпадающим `newTeacher` сервис один раз
на каждую релевантную дату достраивает базовый день, выбирает только целевые слоты, применяет
единый снимок изменений и затем фильтрует итогового преподавателя. При отсутствии таких
замен полный список групп не загружается.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
@@ -178,26 +191,47 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Валидация правил расписания
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
- **Правило:** обязательны дисциплина, семестр, положительные недели начала и хотя бы одна группа; каждый лимит часов неотрицателен и чётен, а сумма лимитов положительна. Ноль разрешён для неиспользуемого типа занятия.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД; преподавателем может быть только активный пользователь с ролью `TEACHER`.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Формат:** `lessonFormat` обязателен и принимает только `Очно` или `Онлайн`.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание сначала блокирует строку семестра, а update блокирует правило и старый/новый семестры в стабильном порядке, поэтому два backend-pod не могут одновременно пройти проверку одного семестра по устаревшему снимку.
### Точечные изменения расписания
Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другой временной слот или в другую аудиторию;
- `MOVE` — перенести пару на другой фактический интервал или в другую аудиторию;
- `REPLACE` — заменить преподавателя, аудиторию или формат.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату.
`CANCEL` не принимает новые ресурсы. Для `MOVE` обязателен новый временной слот или
аудитория, для `REPLACE` — преподаватель, аудитория или формат. Дополнительные изменения
можно объединять в одном payload, но основное действие должно фактически менять свою
часть пары. Формат ограничен значениями `Очно` и `Онлайн`.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного
расписания на конкретную дату. Перед записью `ScheduleOverrideService`:
1. захватывает transaction advisory lock PostgreSQL для tenant-БД и даты;
2. строит базовый день для всех групп без интерактивного лимита широкого поиска;
3. доказывает существование исходной пары с учётом семестра, календарного графика,
чётности, недели начала, лимита часов и lifecycle;
4. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
5. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы;
6. сохраняет изменение только при отсутствии конфликта.
Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом.
Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные
подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях.
Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая
для backend-pod, поэтому два конкурентных изменения одной даты проверяются последовательно.
---

View File

@@ -709,6 +709,10 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
Миграция V4 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
нули допустимы только как лимиты неиспользуемых типов занятия.
#### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -734,6 +738,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
| `lock_comment` | TEXT | Комментарий к закреплению |
V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
одинаковые день, чётность, базовый временной слот, преподавателя, аудиторию, тип и формат
занятия. Более широкие ресурсные пересечения и семантика подгрупп проверяются сервисом,
поскольку зависят от нескольких таблиц и фактических активных недель.
#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -757,7 +766,16 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `created_by` | BIGINT FK → users | Автор изменения |
| `created_at` | TIMESTAMP | Дата создания |
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для одной и той же пары.
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
одной и той же пары. Миграция V3 добавляет структурные инварианты:
- `CANCEL` не содержит новых ресурсов;
- `MOVE` содержит новый временной слот или аудиторию;
- `REPLACE` содержит нового преподавателя, аудиторию или формат;
- `new_lesson_format` равен `Очно`, `Онлайн` либо `NULL`.
Фактическое существование пары, реальность изменения и ресурсные конфликты зависят от
календаря конкретной даты и проверяются транзакционным сервисом, а не SQL CHECK.
---
@@ -771,23 +789,30 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
| `V2__subgroups_active_unique_name.sql` | Уникальность имени среди активных подгрупп одной группы |
| `V3__schedule_override_invariants.sql` | Матрица payload и допустимый формат точечных изменений расписания |
| `V4__schedule_rule_even_hours.sql` | Чётность лимитов академических часов и уникальность точного payload слота правила |
### Накатывание на существующих тенантов
Для применения новой базовой схемы к уже существующим тенантам нужен полный сброс БД. Перезапуск backend без сброса не изменит уже применённую `V1__init.sql`.
V2V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1V3.
Перед добавлением ограничений V3 считает нарушения `CANCEL`, `MOVE`, `REPLACE` и формата.
Если найдены legacy-строки, миграция полностью откатывается и сообщает только количества
нарушений. Оператор должен исправить бизнес-данные tenant-БД и повторить миграцию;
автоматическое удаление или переписывание overrides не выполняется.
Перед ограничениями V4 отдельно считаются нечётные лимиты лекций, лабораторных и практик,
а также группы точных дублей слотов. Любое нарушение останавливает V4 с русским сообщением;
ограничения не остаются частично применёнными и legacy-данные автоматически не меняются.
```bash
# Kubernetes
kubectl rollout restart deployment backend -n magistr
# Docker Compose (локально)
# После исправления legacy-данных перезапустите backend,
# чтобы TenantConfigWatcher повторил Flyway migrate для tenant-БД.
docker compose restart backend
```

View File

@@ -257,7 +257,7 @@ com.magistr.app/
│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
│ └── KubernetesTenantSecretUpdater.java # Обновление tenant Secret с проверкой TLS
├── controller/ # REST-контроллеры
│ ├── AuthController.java
│ ├── GlobalExceptionHandler.java

View File

@@ -34,6 +34,9 @@ JWT_REFRESH_TOKEN_TTL=7d
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
### Dockerfile (Backend)
@@ -64,7 +67,7 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|--------|-----|----------|
| `backend` | Deployment | Spring Boot приложение |
| `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов |
| `tenants-secret` | Внешний Secret | JSON-список tenant-подключений; значения отсутствуют в манифестах |
### JWT настройки
@@ -74,16 +77,35 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
- `JWT_REFRESH_TOKEN_TTL=7d`
- `JWT_REFRESH_COOKIE_SECURE=true`
`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
`app-secret` задаёт `JWT_SECRET`. Этот Secret не создаётся файлами `../k8s/`: его заранее
предоставляет внешний secret manager или оператор. Deployment явно включает профиль
`production`, поэтому небезопасная cookie-конфигурация останавливает startup.
### ConfigMap для тенантов
### Secrets для приложения и тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
Обязательные внешние объекты:
| Secret | Назначение |
|---|---|
| `app-secret` | JWT и fallback credentials backend |
| `tenants-secret` | Полный `tenants.json` с credentials tenant-БД |
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
| `gitea-registry` | Доступ Kubernetes к registry |
Secret `tenants-secret` монтируется в pod backend по пути `/config/tenants.json`.
При добавлении тенанта через API:
1. `DatabaseController` обновляет in-memory DataSource
2. `ConfigMapUpdater` обновляет ConfigMap через Kubernetes API
3. `TenantConfigWatcher` на остальных подах подхватывает изменения (каждые 30 сек)
2. `KubernetesTenantSecretUpdater` обновляет Secret через Kubernetes API
3. `TenantConfigWatcher` на остальных pod подхватывает смонтированное изменение (каждые 30 сек)
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
RBAC ограничен объектом `tenants-secret`. Скрипт `../k8s/deploy.sh` проверяет наличие
объектов и ключей до rollout, не читая и не печатая их значения.
Создание, ротация, отзыв скомпрометированных значений и очистка истории описаны в
[`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md). Эти действия требуют полномочий оператора и
не выполняются автоматически.
### Обновление backend
@@ -148,6 +170,9 @@ Tenant ID добавляется в:
- Метрики производительности страниц
- Трейсы пользовательских действий
PostgreSQL receivers collector получают endpoint и credentials только из
`otel-postgres-secret`; ConfigMap collector содержит лишь `${env:...}` ссылки.
### Дашборды SigNoz
- JVM Dashboard (Heap, GC, Threads)

View File

@@ -92,7 +92,7 @@ MDC.remove("tenant.id");
| `TenantDataSourceConfig` | INFO, WARN, ERROR | Загрузка тенантов, fallback на H2 |
| `TenantRoutingDataSource` | INFO, WARN | Добавление/удаление тенантов, тест соединения |
| `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции |
| `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s |
| `KubernetesTenantSecretUpdater` | INFO, WARN, ERROR | Безопасное обновление tenant Secret без вывода его содержимого |
| `DataInitializer` | INFO | Инициализация БД при старте |
| `ScheduleController` | INFO, ERROR | Запросы динамического расписания и ошибки генерации |

View File

@@ -112,5 +112,6 @@ magistr/
| [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции |
| [REST API](API.md) | Все эндпоинты с примерами запросов и ответов |
| [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг |
| [Runbook безопасности](SECURITY_RUNBOOK.md) | Production-секреты, ротация и очистка истории |
| [Разработка](DEVELOPMENT.md) | Code Style, соглашения, инструкции для разработчиков |
| [Frontend](FRONTEND.md) | Архитектура фронтенда, модули, стили |

77
docs/SECURITY_RUNBOOK.md Normal file
View File

@@ -0,0 +1,77 @@
# Runbook безопасности production-секретов
Этот документ описывает действия оператора, которые нельзя выполнять автоматически из репозитория. Он не содержит и не должен содержать значения секретов, паролей, токенов, cookie или строк подключения.
## Обязательные Kubernetes Secrets
Production-манифесты только ссылаются на заранее созданные объекты:
| Secret | Обязательные ключи | Потребитель |
|---|---|---|
| `app-secret` | `JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Backend |
| `tenants-secret` | `tenants.json` | Backend, файл `/config/tenants.json` |
| `otel-postgres-secret` | `MAGISTR_DB_ENDPOINT`, `MAGISTR_DB_USERNAME`, `MAGISTR_DB_PASSWORD`, `MAGISTR_DB_NAME`, `N8N_DB_ENDPOINT`, `N8N_DB_USERNAME`, `N8N_DB_PASSWORD`, `N8N_DB_NAME` | OTel Collector |
| `gitea-registry` | стандартный ключ Docker registry | Kubernetes image pull |
Предпочтительный источник — External Secrets, SOPS/Sealed Secrets либо корпоративный secret manager. Kubernetes Secret, созданный вручную, допустим как переходный вариант, если в кластере включено шифрование etcd и значения никогда не сохраняются в Git.
Для переходного создания из защищённых файлов на рабочей станции оператора:
```bash
kubectl create namespace magistr --dry-run=client -o yaml | kubectl apply -f -
kubectl -n magistr create secret generic app-secret \
--from-env-file=/secure/path/app-secret.env \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n magistr create secret generic tenants-secret \
--from-file=tenants.json=/secure/path/tenants.json \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n magistr create secret generic otel-postgres-secret \
--from-env-file=/secure/path/otel-postgres-secret.env \
--dry-run=client -o yaml | kubectl apply -f -
```
Не перенаправляйте YAML из этих команд в файл и не используйте `kubectl get secret -o yaml` в логах CI/CD.
## Порядок ротации
1. Зафиксировать владельца, область и версию каждого скомпрометированного значения в закрытой системе управления инцидентами.
2. Создать новые значения средствами secret manager. JWT-ключ должен быть криптографически случайным и содержать не менее 32 байт.
3. Сначала сменить пароли в каждой tenant-БД, затем атомарно обновить соответствующую версию `tenants-secret`.
4. Обновить OTel credentials и `otel-postgres-secret`, после чего перезапустить только collector и проверить поступление метрик.
5. Обновить `JWT_SECRET` в `app-secret` и выполнить контролируемый rollout backend. Смена ключа немедленно аннулирует ранее выданные access JWT.
6. Если политика инцидента требует завершить все пользовательские сессии, очистить `auth_refresh_tokens` отдельно в каждой tenant-БД через утверждённую DBA-процедуру. Эта операция не должна затрагивать пользователей или демонстрационные данные.
7. Проверить readiness, вход, refresh, logout и доступ каждого tenant. Только после этого отозвать предыдущие версии DB/OTel credentials в secret manager.
8. Хранить предыдущую версию секрета только в защищённом менеджере на период согласованного rollback window; не создавать резервные YAML-файлы.
Команда `../k8s/deploy.sh` проверяет наличие объектов и обязательных ключей, но намеренно не читает и не выводит их значения.
## Очистка истории
Переписывание истории и force-push выполняются только после отдельного согласования со всеми владельцами клонов и CI/CD:
1. Создать закрытый mirror-клон и резервную копию refs.
2. Подготовить вне репозитория файл замен для `git filter-repo`; не помещать исходные или новые значения в аргументы shell, issue или CI-логи.
3. Запустить в mirror-клоне:
```bash
git filter-repo --replace-text /secure/path/replacements.txt --force
```
4. Выполнить secret scan переписанной истории и проверить теги/ветви.
5. В согласованное окно выполнить force-push, инвалидировать старые CI caches/artifacts и потребовать повторное клонирование.
6. Отдельно проверить историю репозитория, которому принадлежат файлы `../k8s/`: эта директория не входит в Git-корень Magistr.
Переписывание истории не заменяет ротацию: опубликованные значения считаются скомпрометированными даже после удаления из Git.
## Проверка после rollout
```bash
bash ../k8s/deploy.sh status
kubectl rollout status deployment/backend -n magistr --timeout=300s
kubectl rollout status deployment/otel-collector-db -n magistr --timeout=180s
```
Дополнительно оператор должен проверить audit-события secret manager, шифрование etcd, минимальные RBAC-права и отсутствие значений секретов в логах, событиях pod и артефактах CI.

View File

@@ -1,19 +1,45 @@
{
"generated_at": "2026-07-10T17:12:35.296680+00:00",
"generated_at": "2026-07-12T21:52:36.954007+00:00",
"nodes": {
"backend_src_main_java_com_magistr_app_config_auth_authorizationinterceptor_authorizationinterceptor": {
"code_fingerprint": "9f11d3686fe75ecd9f0b8abaa42702ed900baa8cc253a4025e377fe527fc3b81",
"code_fingerprint": "70b01b3fa22e058ee997250c52cc0affee54ab37aa5f8cc7eb74411487f1bbbf",
"label": "AuthorizationInterceptor",
"last": "2026-07-04T10:57:57.276777+00:00",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
{
"date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
},
{
"date": "2026-07-10T18:14:03.213067+00:00",
"outcome": "useful",
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
},
{
"date": "2026-07-04T10:57:57.276777+00:00",
"outcome": "useful",
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
}
],
"score": 0.865333358,
"score": 2.75167804,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
"status": "preferred",
"uses": 3
},
"backend_src_main_java_com_magistr_app_config_auth_jwttokenservice_jwttokenservice": {
"code_fingerprint": "e83a6b07c917e4aaf5ffa40228bef4f4e1fa0ad7fabfd617fbc6b80d5ff5a4ec",
"label": "JwtTokenService",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
{
"date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.977626859,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
"status": "tentative",
"uses": 1
},
@@ -28,24 +54,210 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
}
],
"score": 0.865333358,
"score": 0.822552197,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_config_tenant_configmapupdater_configmapupdater": {
"code_fingerprint": "",
"label": "ConfigMapUpdater",
"last": "2026-07-10T18:14:03.213067+00:00",
"provenance": [
{
"date": "2026-07-10T18:14:03.213067+00:00",
"outcome": "useful",
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
}
],
"score": 0.951498983,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_config_tenant_tenantconfigwatcher_tenantconfigwatcher": {
"code_fingerprint": "83dcf36dd5d7c969fa4d454909cc245145949738b462f569eb1367663e33492d",
"label": "TenantConfigWatcher",
"last": "2026-07-10T18:14:03.213067+00:00",
"provenance": [
{
"date": "2026-07-10T18:14:03.213067+00:00",
"outcome": "useful",
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
}
],
"score": 0.951498983,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_config_tenant_tenantroutingdatasource_tenantroutingdatasource": {
"code_fingerprint": "2efea217f4236134681ee86d870bd0d5ffcd653e312637e115b50768ada975f0",
"label": "TenantRoutingDataSource",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
{
"date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.977626859,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_controller_databasecontroller": {
"code_fingerprint": "d4e6c182c5a40917feb56354fde6f6c9764d95fd79dc72d4bc760b1fd2d24cdc",
"label": "DatabaseController.java",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
{
"date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.977626859,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_controller_globalexceptionhandler_globalexceptionhandler": {
"code_fingerprint": "3fc674c29337df18a9494ada4a6a150b522f6f5fc1f044d003c33eb4272e380d",
"label": "GlobalExceptionHandler",
"last": "2026-07-10T18:14:03.213067+00:00",
"provenance": [
{
"date": "2026-07-10T18:14:03.213067+00:00",
"outcome": "useful",
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
}
],
"score": 0.951498983,
"source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_controller_schedulecontroller": {
"code_fingerprint": "74f022a62ea5440fb0c4c66b86df005e5e77f7f1fda8a4b718d95bd55104ca13",
"label": "ScheduleController.java",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
{
"date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.977626859,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_controller_scheduleoverridecontroller_scheduleoverridecontroller": {
"code_fingerprint": "b9f25c272cab98280e15616873c10e65380ba04b5f3dc4cbfa50f2c16b5429f1",
"label": "ScheduleOverrideController",
"last": "2026-07-12T14:52:07.844079+00:00",
"provenance": [
{
"date": "2026-07-12T14:52:07.844079+00:00",
"outcome": "useful",
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
}
],
"score": 0.993275993,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_controller_scheduleruleadmincontroller_scheduleruleadmincontroller": {
"code_fingerprint": "88244c8ce05d55ac3581c4295a7213294389d1245b56c73a0ad5df2da4ecf000",
"label": "ScheduleRuleAdminController",
"last": "2026-07-12T21:48:38.060218+00:00",
"provenance": [
{
"date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful",
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
},
{
"date": "2026-07-10T18:14:03.213067+00:00",
"outcome": "useful",
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
}
],
"score": 1.951435101,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java",
"status": "preferred",
"uses": 2
},
"backend_src_main_java_com_magistr_app_model_schedulerule_schedulerule": {
"code_fingerprint": "692d61c1bfd5b46f5865174dde8cf2d962d2ccd124b5e4320e2ba8535bb29605",
"label": "ScheduleRule",
"last": "2026-07-04T12:07:28.781712+00:00",
"last": "2026-07-12T21:48:38.060218+00:00",
"provenance": [
{
"date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful",
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
},
{
"date": "2026-07-04T12:07:28.781712+00:00",
"outcome": "useful",
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
}
],
"score": 0.866299207,
"score": 1.823406413,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
"status": "preferred",
"uses": 2
},
"backend_src_main_java_com_magistr_app_model_scheduleruleslot_scheduleruleslot": {
"code_fingerprint": "0b1cef627b98558752cc70624d74cd54eb09d9dbff8ca985f81f56d5b3dd34ff",
"label": "ScheduleRuleSlot",
"last": "2026-07-12T21:48:38.060218+00:00",
"provenance": [
{
"date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful",
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
}
],
"score": 0.999936118,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_repository_semesterrepository_semesterrepository": {
"code_fingerprint": "8a39b28d2b7d0d2ca60b188a5c9b97c71187b5845dd8bdc85206f117b9d4984b",
"label": "SemesterRepository",
"last": "2026-07-12T21:48:38.060218+00:00",
"provenance": [
{
"date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful",
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
}
],
"score": 0.999936118,
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_service_schedulequeryservice_schedulequeryservice": {
"code_fingerprint": "47d0183b1249d194c9825046e0942a1867ad5c781ed90f376b975095e8807f68",
"label": "ScheduleQueryService",
"last": "2026-07-12T14:52:07.844079+00:00",
"provenance": [
{
"date": "2026-07-12T14:52:07.844079+00:00",
"outcome": "useful",
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
}
],
"score": 0.993275993,
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
"status": "tentative",
"uses": 1
},
@@ -60,10 +272,26 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
}
],
"score": 0.866299207,
"score": 0.823470295,
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
"status": "tentative",
"uses": 1
},
"compose_backend_service": {
"code_fingerprint": "a175ccc6d907386f120574edb6296a6af416da683b869c7da71f60835f2daaa6",
"label": "Backend-сервис Docker Compose",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
{
"date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.977626859,
"source_file": "compose.yaml",
"status": "tentative",
"uses": 1
}
},
"version": 1

View File

@@ -0,0 +1,27 @@
---
type: "query"
date: "2026-07-11T22:22:23.259230+00:00"
question: "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["AuthorizationInterceptor", "JwtTokenService", "TenantRoutingDataSource", "DatabaseController.java", "ScheduleController.java", "Backend-сервис Docker Compose"]
---
# Q: Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?
## Answer
Расширение по словарю графа: [auth, authorization, refresh, token, tenant, config, database, schedule, calendar, frontend, backend, kubernetes]. Граф выделил AuthorizationInterceptor/JwtTokenService/AuthController для авторизации, TenantRoutingDataSource/TenantWebMvcConfig/DatabaseController для мультитенантности, ScheduleController и календарные контроллеры для расписания, а compose.yaml и Kubernetes-манифесты для поставки. Связи подтверждены исходниками; граф используется только как карта навигации.
## Outcome
- Signal: useful
## Source Nodes
- AuthorizationInterceptor
- JwtTokenService
- TenantRoutingDataSource
- DatabaseController.java
- ScheduleController.java
- Backend-сервис Docker Compose

View File

@@ -0,0 +1,22 @@
---
type: "query"
date: "2026-07-12T14:13:03.313831+00:00"
question: "Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?"
contributor: "graphify"
outcome: "dead_end"
source_nodes: ["Override"]
---
# Q: Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?
## Answer
Расширение по словарю графа: schedule, override, conflict, effective, teacher, classroom, subgroup, group. Обход оказался тупиковым: токен override сопоставился с Java-аннотацией Override, а новых production-классов ещё нет в графе. Для тестов нужно опираться на актуальные исходники после появления сервиса.
## Outcome
- Signal: dead_end
## Source Nodes
- Override

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-07-12T14:52:07.844079+00:00"
question: "Как безопасно валидировать и сохранять точечные изменения расписания?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["ScheduleOverrideController", "ScheduleQueryService", "ScheduleGeneratorService"]
---
# Q: Как безопасно валидировать и сохранять точечные изменения расписания?
## Answer
Расширение по словарю графа: [schedule, override, generator, query, conflict, classroom, teacher, group, rule, slot]. ScheduleOverrideController делегирует транзакционному ScheduleOverrideService; базовый и effective-день строятся через ScheduleQueryService и ScheduleGeneratorService. Проверка подтверждена по исходникам и дополнена PostgreSQL advisory lock, action matrix и конфликтами ресурсов.
## Outcome
- Signal: useful
## Source Nodes
- ScheduleOverrideController
- ScheduleQueryService
- ScheduleGeneratorService

View File

@@ -0,0 +1,26 @@
---
type: "query"
date: "2026-07-12T21:48:38.060218+00:00"
question: "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["ScheduleRuleAdminController", "ScheduleRule", "ScheduleRuleSlot", "SemesterRepository", "ScheduleGeneratorService"]
---
# Q: Какие связи затрагивает полная валидация правил расписания в исправлении №7?
## Answer
Expanded from original query via vocab: [schedule, rule, semester, repository, controller, conflict, slot, validation, teacher, classroom]. Граф указал на ScheduleRuleAdminController, ScheduleRule, ScheduleRuleSlot, SemesterRepository и ScheduleGeneratorService. Актуальные исходники подтвердили, что HTTP-слой делегирует ScheduleRuleService, блокировка семестра сериализует запись, а DB-инварианты закрепляются V4.
## Outcome
- Signal: useful
## Source Nodes
- ScheduleRuleAdminController
- ScheduleRule
- ScheduleRuleSlot
- SemesterRepository
- ScheduleGeneratorService

View File

@@ -0,0 +1,25 @@
---
type: "query"
date: "2026-07-12T22:09:47.294749+00:00"
question: "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["DatabaseController", "TenantRoutingDataSource", "TenantConfigWatcher", "TenantConfig"]
---
# Q: Как безопасно заменить tenant DataSource без потери рабочего подключения?
## Answer
Expanded from original query via vocab: [database, tenant, routing, flyway, hikari, secret, config, updater, watcher, controller]. Граф связал DatabaseController с TenantRoutingDataSource и Hikari/Flyway. Актуальные исходники подтвердили destructive remove старого pool, ленивый candidate, проглоченную ошибку Flyway и отсутствие rollback при отказе Secret. Безопасный локальный протокол: prepare, explicit connection validation, Flyway на candidate, persistence prospective snapshot, atomic swap, compensation и закрытие old только после успеха. ResourceVersion между pod относится к отдельному пункту №10.
## Outcome
- Signal: useful
## Source Nodes
- DatabaseController
- TenantRoutingDataSource
- TenantConfigWatcher
- TenantConfig

View File

@@ -0,0 +1,25 @@
---
type: "query"
date: "2026-07-12T22:20:21.382611+00:00"
question: "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["TenantRoutingDataSource", "DatabaseController", "TenantConfigWatcher", "DataInitializer"]
---
# Q: Как безопасно обновлять tenant DataSource без потери рабочего подключения?
## Answer
Расширение по словарю графа: tenant, routing, data, source, database, controller, config, watcher, initializer, hikari, flyway, secret. Граф указал на TenantRoutingDataSource, DatabaseController, TenantConfigWatcher и DataInitializer; актуальные исходники подтвердили destructive remove-before-add и проглатывание Flyway. Реализация использует проверенный временный Hikari pool, миграцию до персистенции, immutable runtime snapshot, Secret-before-swap и compensating restore.
## Outcome
- Signal: useful
## Source Nodes
- TenantRoutingDataSource
- DatabaseController
- TenantConfigWatcher
- DataInitializer

View File

@@ -1,16 +1,37 @@
# Lessons
_Auto-generated by `graphify reflect` from 2 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._
_Auto-generated by `graphify reflect` from 7 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._
## Summary
- 2 useful · 0 dead ends · 0 corrected · 0 unmarked
- 6 useful · 1 dead ends · 0 corrected · 0 unmarked
## Lessons
**Preferred sources** — corroborated by ≥2 useful results; start here.
- `AuthorizationInterceptor` (3× useful)
- `ScheduleGeneratorService` (2× useful)
- `ScheduleRuleAdminController` (2× useful)
- `ScheduleRule` (2× useful)
**Tentative** — useful in fewer than 2 results; verify before relying.
- `ScheduleRule` (1× useful)
- `ScheduleRuleSlot` (1× useful)
- `SemesterRepository` (1× useful)
- `ScheduleOverrideController` (1× useful)
- `ScheduleQueryService` (1× useful)
- `Backend-сервис Docker Compose` (1× useful)
- `DatabaseController.java` (1× useful)
- `JwtTokenService` (1× useful)
- `ScheduleController.java` (1× useful)
- `TenantRoutingDataSource` (1× useful)
- `ConfigMapUpdater` (1× useful)
- `GlobalExceptionHandler` (1× useful)
- `TenantConfigWatcher` (1× useful)
- `ScheduleRuleTest` (1× useful)
- `AuthorizationInterceptor` (1× useful)
- `RequireRoles` (1× useful)
**Known dead ends** — led nowhere; don't re-derive.
- "Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?" — `Override`

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
K8S_DIR=${K8S_DIR:-"$ROOT_DIR/../k8s"}
fail() {
echo "Ошибка проверки production-секретов: $1" >&2
exit 1
}
require_pattern() {
local pattern="$1"
local file="$2"
local description="$3"
grep -Eq "$pattern" "$file" || fail "$description"
}
forbid_pattern() {
local pattern="$1"
local file="$2"
local description="$3"
if grep -Eq "$pattern" "$file"; then
fail "$description"
fi
}
[[ -d "$K8S_DIR" ]] || fail "не найдена директория Kubernetes: $K8S_DIR"
require_pattern '^app\.jwt\.secret=\$\{JWT_SECRET:\}$' \
"$ROOT_DIR/backend/src/main/resources/application.properties" \
"JWT_SECRET должен быть обязательной переменной без встроенного значения"
require_pattern '^[[:space:]]*private String secret;$' \
"$ROOT_DIR/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java" \
"JwtProperties не должен содержать встроенный секрет"
forbid_pattern '^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' \
"$K8S_DIR/config.yaml" \
"config.yaml не должен создавать Secret с отслеживаемыми значениями"
for yaml in "$K8S_DIR"/*.yaml; do
forbid_pattern '^[[:space:]]*stringData:[[:space:]]*$' "$yaml" \
"production-манифесты не должны содержать stringData"
done
require_pattern 'secretName:[[:space:]]*tenants-secret' "$K8S_DIR/backend.yaml" \
"tenant-конфигурация должна монтироваться из tenants-secret"
require_pattern 'name:[[:space:]]*app-secret' "$K8S_DIR/backend.yaml" \
"backend должен получать JWT_SECRET через app-secret"
require_pattern 'SPRING_PROFILES_ACTIVE:[[:space:]]*"production"' "$K8S_DIR/config.yaml" \
"Kubernetes должен явно включать production-профиль"
require_pattern 'JWT_REFRESH_COOKIE_SECURE:[[:space:]]*"true"' "$K8S_DIR/config.yaml" \
"production refresh-cookie должен быть Secure"
require_pattern 'name:[[:space:]]*otel-postgres-secret' "$K8S_DIR/otel-collector.yaml" \
"OTel Collector должен получать credentials из Secret"
if awk '
/^[[:space:]]*password:[[:space:]]*/ && $0 !~ /\$\{env:[A-Z0-9_]+\}/ { unsafe=1 }
END { exit unsafe ? 1 : 0 }
' "$K8S_DIR/otel-collector.yaml"; then
:
else
fail "пароли OTel Collector должны задаваться только через env-ссылки"
fi
if grep -REq 'createInsecureClient|trustAll|X509TrustManager' \
"$ROOT_DIR/backend/src/main/java/com/magistr/app/config/tenant"; then
fail "в Kubernetes-клиенте обнаружен небезопасный TLS trust manager"
fi
echo "Проверка production-секретов завершена успешно"