исправление багов
This commit is contained in:
278
BUG_FIX_PROGRESS.md
Normal file
278
BUG_FIX_PROGRESS.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Журнал исправления проблем
|
||||
|
||||
Обновлено: 2026-07-13 (Europe/Moscow).
|
||||
|
||||
## Область и неизменяемые данные
|
||||
|
||||
- В работе ровно 33 проблемы: `1` и `3–34`.
|
||||
- Проблема № 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. Высокоприоритетные инварианты расписания: № 5–8.
|
||||
4. Tenant lifecycle и Kubernetes coordination: № 9, 10, 13, 14.
|
||||
5. Frontend-сессия и достоверность UI: № 11, 12, 33, 34.
|
||||
6. Календарные и DB-инварианты: № 15–17, 21, 31, 32.
|
||||
7. История, права, масштаб и производительность: № 18–23.
|
||||
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:00–03:00 и первые дни месяца. |
|
||||
| 30 | не начато | Образы/скачиваемые инструменты используют mutable tags/latest без checksum/SBOM/scan. | Pin versions+digests, SHA verification, SBOM и image scan gates. | Static scan production refs и CI configuration. |
|
||||
| 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
265
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file → Normal file
265
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file → Normal 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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("Слишком много дисциплин для одного графика");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
public class ScheduleConflictException extends RuntimeException {
|
||||
|
||||
public ScheduleConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("Конфликт расписания: преподаватель уже занят в указанное время"));
|
||||
}
|
||||
}
|
||||
@@ -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": "Очно"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
86
docs/API.md
86
docs/API.md
@@ -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}`
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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, поэтому два конкурентных изменения одной даты проверяются последовательно.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`.
|
||||
V2–V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1–V3.
|
||||
Перед добавлением ограничений 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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 | Запросы динамического расписания и ошибки генерации |
|
||||
|
||||
|
||||
@@ -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
77
docs/SECURITY_RUNBOOK.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
73
scripts/check-production-secrets.sh
Normal file
73
scripts/check-production-secrets.sh
Normal 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-секретов завершена успешно"
|
||||
Reference in New Issue
Block a user