diff --git a/BUG_FIX_PROGRESS.md b/BUG_FIX_PROGRESS.md new file mode 100644 index 0000000..e03bdcf --- /dev/null +++ b/BUG_FIX_PROGRESS.md @@ -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. diff --git a/backend/pom.xml b/backend/pom.xml index 6759af9..2cdbae9 100755 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -76,6 +76,28 @@ spring-boot-starter-test test + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + test + + + com.squareup.okhttp3 + okhttp-tls + 4.12.0 + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + diff --git a/backend/src/main/java/com/magistr/app/config/DataInitializer.java b/backend/src/main/java/com/magistr/app/config/DataInitializer.java deleted file mode 100755 index 06405e7..0000000 --- a/backend/src/main/java/com/magistr/app/config/DataInitializer.java +++ /dev/null @@ -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"); - } -} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java index 8dcd92f..142b701 100644 --- a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java @@ -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; } diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java b/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java index aab5abd..f142bf7 100644 --- a/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java +++ b/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java @@ -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 LEGACY_INSECURE_SECRET_FINGERPRINTS = Set.of( + "884e52f1fbb3b0caf409bc76a14ba8e601d6117be5a9188319f2e84f9e19a853", + "54f5809ada311ba1e12146a14db39ede2082b6f73ca7749bae8cd8450726eaf9" + ); + + private static final Set 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); + } + } } diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtSecurityStartupValidator.java b/backend/src/main/java/com/magistr/app/config/auth/JwtSecurityStartupValidator.java new file mode 100644 index 0000000..07900b5 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/JwtSecurityStartupValidator.java @@ -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); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java index e4dff9e..044b461 100644 --- a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java +++ b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java @@ -55,7 +55,7 @@ public class RefreshTokenService { LocalDateTime now = LocalDateTime.now(); String currentHash = hashToken(rawToken); - Optional storedOpt = repository.findByTokenHash(currentHash); + Optional storedOpt = repository.findByTokenHashForUpdate(currentHash); if (storedOpt.isEmpty()) { return Optional.empty(); } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java deleted file mode 100755 index a69042e..0000000 --- a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java +++ /dev/null @@ -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 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 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(); - } - } -} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java new file mode 100644 index 0000000..0a6cf61 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java @@ -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. + * + *

Работает только внутри Kubernetes pod и использует service-account token и + * service-account CA. В локальной разработке персистенция пропускается.

+ */ +@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 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 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 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(); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java index e27fb62..a22539d 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java @@ -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 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 newTenants) { - Map current = routingDataSource.getTenantConfigs(); - Set 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); - } - } - - // Удалить тенанты, которых больше нет в конфиге - for (String existingDomain : new ArrayList<>(current.keySet())) { - if (!newDomains.contains(existingDomain)) { - log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain); - routingDataSource.removeTenant(existingDomain); - } - } + tenantLifecycleService.synchronizeFromPersistedConfig(newTenants); } - /** - * Выполняет миграции 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(); - } - } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java index 16d9ff6..134cc03 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -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 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 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); + } + } + } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java old mode 100755 new mode 100644 index ea152b0..d06ae19 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java @@ -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 tenantConfigs = new ConcurrentHashMap<>(); - private final Map dataSources = new ConcurrentHashMap<>(); - private boolean initialized = false; + private final Object lifecycleMonitor = new Object(); + private volatile Map 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(); + return determineLookupKey(tenantStates); + } - if (tenant == null) { - // Нет HTTP контекста (JPA init, background tasks) — берём первый доступный - if (!dataSources.isEmpty()) { - return dataSources.keySet().iterator().next().toString(); + @Override + protected DataSource determineTargetDataSource() { + Map snapshot = tenantStates; + String lookupKey = determineLookupKey(snapshot); + TenantState state = snapshot.get(lookupKey); + if (state == null || state.dataSource() == null) { + throw new IllegalStateException("Подключение для выбранного тенанта не настроено"); + } + return state.dataSource(); + } + + /** + * Создаёт Hikari pool, но не включает его в маршрутизацию. + */ + 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 TenantState swapTenant(TenantConfig config, DataSource candidate) { + Objects.requireNonNull(candidate, "Подготовленный DataSource обязателен"); + TenantConfig normalized = normalizeConfig(config); + String domain = normalized.getDomain(); + + synchronized (lifecycleMonitor) { + Map current = tenantStates; + TenantState previous = copyState(current.get(domain)); + Map updated = new LinkedHashMap<>(current); + updated.put(domain, new TenantState(domain, normalized, candidate)); + tenantStates = immutableStates(updated); + return previous; + } + } + + /** + * Атомарно исключает тенанта из маршрутизации, но не закрывает возвращённый DataSource. + */ + public TenantState removeTenantAtomically(String domain) { + String normalizedDomain = normalizeDomain(domain); + synchronized (lifecycleMonitor) { + Map current = tenantStates; + TenantState previous = current.get(normalizedDomain); + if (previous == null) { + return null; } - return "default"; + Map updated = new LinkedHashMap<>(current); + updated.remove(normalizedDomain); + tenantStates = immutableStates(updated); + return copyState(previous); } - - // HTTP запрос — возвращаем точный ключ тенанта - // Если тенанта нет — TenantInterceptor уже вернул 404 - return tenant; } - /** - * Добавляет тенант и создаёт для него HikariCP пул. - */ - 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 void removeTenant(String domain) { - domain = domain.toLowerCase(); - Object removed = dataSources.remove(domain); - tenantConfigs.remove(domain); - - if (removed instanceof HikariDataSource ds) { - ds.close(); - log.info("Removed and closed tenant '{}'", domain); + public Optional snapshotTenant(String domain) { + if (domain == null || domain.isBlank()) { + return Optional.empty(); } + return Optional.ofNullable(copyState(tenantStates.get(normalizeDomain(domain)))); + } - setTargetDataSources(dataSources); - afterPropertiesSet(); + public Map snapshotTenantConfigs() { + Map snapshot = tenantStates; + Map configs = new LinkedHashMap<>(); + snapshot.forEach((domain, state) -> configs.put(domain, copyConfig(state.config()))); + return Collections.unmodifiableMap(configs); + } + + public Optional 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 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 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 snapshot) { + String tenant = TenantContext.getCurrentTenant(); + if (tenant != null) { + return tenant; + } + return snapshot.isEmpty() ? "default" : snapshot.keySet().iterator().next(); + } + + private Map immutableStates(Map source) { + Map 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) { } } diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 97d624f..7f5a460 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -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 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(); - return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); - } + public ResponseEntity saveGrid(@PathVariable("id") Long id, + @RequestBody List rows) { + calendarGridService.replaceGrid(id, rows); + return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён")); } @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 rows) { if (rows.size() > 1000) { throw new IllegalArgumentException("Слишком много дисциплин для одного графика"); diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java index 27e2173..48ecbc1 100755 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java @@ -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> addTenant(@RequestBody TenantConfig config) { - Map 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(); - - result.put("success", true); - result.put("message", "Тенант '" + config.getDomain() + "' добавлен"); - return ResponseEntity.ok(result); - } catch (Exception e) { - result.put("success", false); - result.put("message", "Ошибка: " + e.getMessage()); - return ResponseEntity.internalServerError().body(result); + return tenantLifecycleService.executeSerialized(() -> { + TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config); + tenantConfigWatcher.refreshHash(); + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "Тенант '" + saved.getDomain() + "' добавлен"); + return ResponseEntity.ok(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> removeTenant(@PathVariable String domain) { - Map result = new HashMap<>(); - - if (!routingDataSource.hasTenant(domain)) { - result.put("success", false); - result.put("message", "Тенант '" + domain + "' не найден"); - return ResponseEntity.status(404).body(result); + public ResponseEntity> removeTenant(@PathVariable("domain") String domain) { + try { + return tenantLifecycleService.executeSerialized(() -> { + TenantConfig removed = tenantLifecycleService.removeTenant(domain); + tenantConfigWatcher.refreshHash(); + Map result = new HashMap<>(); + result.put("success", true); + 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()); } - - routingDataSource.removeTenant(domain); - persistToConfigMap(); - - result.put("success", true); - result.put("message", "Тенант '" + domain + "' удалён"); - return ResponseEntity.ok(result); } /** @@ -171,14 +152,10 @@ public class DatabaseController { return ResponseEntity.ok(result); } - /** - * Сохраняет текущий список тенантов в K8s ConfigMap. - */ - private void persistToConfigMap() { - List tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values()); - boolean ok = configMapUpdater.updateTenantsConfig(tenants); - if (ok) { - configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные - } + private ResponseEntity> lifecycleError(HttpStatus status, String message) { + Map result = new HashMap<>(); + result.put("success", false); + result.put("message", message); + return ResponseEntity.status(status).body(result); } } diff --git a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java index 5b459d5..2886f48 100644 --- a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java @@ -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> 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> 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(); diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java index 12c2f78..0500e88 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java @@ -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 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(); - } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 346a7e4..224a7ea 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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 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 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) - .>map(rule -> ResponseEntity.ok(toDto(rule))) - .orElseGet(() -> ResponseEntity.notFound().build()); + public ResponseEntity 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> delete(@PathVariable("id") Long id) { + scheduleRuleService.archive(id); return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано")); } - private String validateRule(ScheduleRuleDto request) { - if (request.subjectId() == null) { - return "Дисциплина обязательна"; + private ResponseEntity> conflictResponse(ScheduleRuleConflictException exception) { + Map body = new LinkedHashMap<>(); + body.put("message", exception.getMessage()); + if (exception.getConflictRule() != null) { + body.put("conflictRule", exception.getConflictRule()); } - 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 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 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 newSlots, Long excludeRuleId) { - Map> 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 newSlots, - Map> newActiveWeeks, - Collection existingSlots) { - Map> existingActiveWeeks = activeWeeksBySlot(existingSlots); - LinkedHashSet 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 conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) { - LinkedHashSet 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 firstGroupIds = groupIds(first); - Set secondGroupIds = groupIds(second); - firstGroupIds.retainAll(secondGroupIds); - - for (Long groupId : firstGroupIds) { - if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) { - return true; - } - Set firstSubgroupIds = subgroupIdsForGroup(first, groupId); - Set secondSubgroupIds = subgroupIdsForGroup(second, groupId); - firstSubgroupIds.retainAll(secondSubgroupIds); - if (!firstSubgroupIds.isEmpty()) { - return true; - } - } - return false; - } - - private Set groupIds(ScheduleRuleSlot slot) { - Set 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 subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) { - Set 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> activeWeeksBySlot(Collection slots) { - Map> 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 categorySlots = slots.stream() - .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) - .sorted(this::compareSlotsForGeneration) - .toList(); - if (categorySlots.isEmpty()) { - continue; - } - - Map 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 first, Set second) { - if (first == null || second == null || first.isEmpty() || second.isEmpty()) { - return false; - } - Set 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 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 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 resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) { - LinkedHashSet 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 ruleGroupIds = rule.getGroups().stream() - .map(StudentGroup::getId) - .collect(java.util.stream.Collectors.toSet()); - Set subgroupGroupIds = new HashSet<>(); - LinkedHashSet 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 slots) { - EnumSet 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 groups = rule.getGroups().stream() - .sorted(Comparator.comparing(StudentGroup::getName)) - .toList(); - List 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 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 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 fields) { + body.put("conflictFields", exception.getConflictFields()); + body.put("conflictReasons", exception.getConflictReasons()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); } } diff --git a/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java index 5d9599b..828e474 100644 --- a/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java @@ -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 { Optional findByTokenHash(String tokenHash); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT token + FROM AuthRefreshToken token + JOIN FETCH token.user + WHERE token.tokenHash = :tokenHash + """) + Optional findByTokenHashForUpdate(@Param("tokenHash") String tokenHash); } diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java index 2b48bc0..4028ca2 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java @@ -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 findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate); + + @Query("select o.lessonDate from ScheduleOverride o where o.id = :id") + Optional findLessonDateById(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select o from ScheduleOverride o where o.id = :id") + Optional 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 findByBaseRuleSlotIdAndLessonDateForUpdate( + @Param("baseRuleSlotId") Long baseRuleSlotId, + @Param("lessonDate") LocalDate lessonDate + ); } diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index 94ca17a..f4fdfe3 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -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 { @@ -76,6 +79,34 @@ public interface ScheduleRuleRepository extends JpaRepository findAllWithDetails(); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select rule from ScheduleRule rule where rule.id = :id") + Optional 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 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 findByIdWithDetails(@Param("id") Long id); + Optional findByIdWithDetails(@Param("id") Long id); } diff --git a/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java index d347f61..0f27097 100644 --- a/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java @@ -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 { List findByAcademicYearIdOrderByStartDateAsc(Long academicYearId); Optional findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select semester from Semester semester where semester.id = :id") + Optional 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 findAllByIdForUpdateOrderById(@Param("ids") Collection ids); } diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java index ec2a34e..a1524d9 100644 --- a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -10,6 +10,12 @@ import java.util.Optional; public interface SubgroupRepository extends JpaRepository { + interface SubgroupGroupReference { + Long getSubgroupId(); + + Long getGroupId(); + } + @Query(""" select subgroup from Subgroup subgroup @@ -74,4 +80,11 @@ public interface SubgroupRepository extends JpaRepository { """) 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 findGroupReferencesByIdIn(@Param("subgroupIds") List subgroupIds); } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java b/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java new file mode 100644 index 0000000..7489da0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java @@ -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 rows) { + AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id) + .orElseThrow(() -> new NoSuchElementException("Календарный график не найден")); + if (rows == null || rows.isEmpty()) { + throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика"); + } + + List replacement = buildReplacement(calendar, rows); + + calendarDayRepository.deleteByCalendarId(id); + calendarDayRepository.flush(); + calendarDayRepository.saveAll(replacement); + calendarDayRepository.flush(); + clearScheduleCacheAfterCommit(); + } + + private List buildReplacement(AcademicCalendar calendar, + List rows) { + List replacement = new ArrayList<>(rows.size()); + Set 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) { + } +} diff --git a/backend/src/main/java/com/magistr/app/service/PostgreSqlAdvisoryLock.java b/backend/src/main/java/com/magistr/app/service/PostgreSqlAdvisoryLock.java new file mode 100644 index 0000000..bc949c3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/PostgreSqlAdvisoryLock.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/RetiredTenantPoolService.java b/backend/src/main/java/com/magistr/app/service/RetiredTenantPoolService.java new file mode 100644 index 0000000..e62e2cf --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/RetiredTenantPoolService.java @@ -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 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) { + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleConflictException.java b/backend/src/main/java/com/magistr/app/service/ScheduleConflictException.java new file mode 100644 index 0000000..a118ae3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleConflictException.java @@ -0,0 +1,8 @@ +package com.magistr.app.service; + +public class ScheduleConflictException extends RuntimeException { + + public ScheduleConflictException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java new file mode 100644 index 0000000..e9a2f21 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java @@ -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 ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE"); + private static final Set 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 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 baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate()); + List 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 baseDay, + List sourceOccurrences) { + Long currentId = current == null ? null : current.getId(); + List 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 effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups( + baseDay, + command.lessonDate(), + effectiveOverrides + ); + List 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 sourceTuples = sourceOccurrences.stream() + .map(EffectiveSourceTuple::from) + .collect(Collectors.toSet()); + Set candidateTuples = candidateOccurrences.stream() + .map(EffectiveSourceTuple::from) + .collect(Collectors.toSet()); + validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences); + if (sourceTuples.equals(candidateTuples)) { + throw new IllegalArgumentException( + "Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары" + ); + } + + List otherOccurrences = effectiveDay.stream() + .filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())) + .toList(); + Map subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences); + validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup); + } + + private void validateActionSpecificChange(String action, + List sourceOccurrences, + List candidateOccurrences) { + if ("MOVE".equals(action)) { + Set source = sourceOccurrences.stream() + .map(MoveTuple::from) + .collect(Collectors.toSet()); + Set candidate = candidateOccurrences.stream() + .map(MoveTuple::from) + .collect(Collectors.toSet()); + if (source.equals(candidate)) { + throw new IllegalArgumentException( + "Перенос должен фактически менять время пары или аудиторию" + ); + } + } + if ("REPLACE".equals(action)) { + Set source = sourceOccurrences.stream() + .map(ReplacementTuple::from) + .collect(Collectors.toSet()); + Set candidate = candidateOccurrences.stream() + .map(ReplacementTuple::from) + .collect(Collectors.toSet()); + if (source.equals(candidate)) { + throw new IllegalArgumentException( + "Замена должна фактически менять преподавателя, аудиторию или формат пары" + ); + } + } + } + + private void validateResourceConflicts(List candidates, + List others, + Map 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 subgroupToGroup) { + Set commonGroupIds = new HashSet<>(safeIds(first.groupIds())); + commonGroupIds.retainAll(safeIds(second.groupIds())); + for (Long groupId : commonGroupIds) { + Set firstSubgroups = subgroupsForGroup(first, groupId, subgroupToGroup); + Set 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 subgroupsForGroup(RenderedLessonDto lesson, + Long groupId, + Map subgroupToGroup) { + return safeIds(lesson.subgroupIds()).stream() + .filter(subgroupId -> Objects.equals(subgroupToGroup.get(subgroupId), groupId)) + .collect(Collectors.toSet()); + } + + private Map loadSubgroupGroupMap(Collection first, + Collection second) { + LinkedHashSet 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 result = new HashMap<>(); + subgroupRepository.findGroupReferencesByIdIn(List.copyOf(subgroupIds)) + .forEach(reference -> result.put(reference.getSubgroupId(), reference.getGroupId())); + return result; + } + + private Set safeIds(List 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()); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java index 89ffb85..af5cc6a 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -33,6 +33,48 @@ public class ScheduleQueryService { this.groupLifecycleService = groupLifecycleService; } + /** + * Строит фактическое базовое расписание на дату для всех групп без UI-ограничения + * количества групп, применяемого к широким пользовательским поисковым запросам. + */ + public List buildBaseDayForAllGroups(LocalDate date) { + if (date == null) { + throw new IllegalArgumentException("Дата расписания обязательна"); + } + List 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 buildEffectiveDayForAllGroups( + LocalDate date, + Collection overrides + ) { + return buildEffectiveDayForAllGroups(buildBaseDayForAllGroups(date), date, overrides); + } + + /** + * Вариант без повторной генерации базового дня для транзакционных проверок. + */ + public List buildEffectiveDayForAllGroups( + List baseDay, + LocalDate date, + Collection overrides + ) { + if (date == null) { + throw new IllegalArgumentException("Дата расписания обязательна"); + } + List safeBaseDay = baseDay == null ? List.of() : baseDay; + return deduplicate(applyOverrides(safeBaseDay, date, overrides)); + } + public List search(Long groupId, Long teacherId, Long classroomId, @@ -45,7 +87,8 @@ public class ScheduleQueryService { LocalDate endDate) { validateRange(startDate, endDate); List 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 groups = resolveGroups(groupId, departmentId, startDate, endDate); @@ -53,7 +96,16 @@ public class ScheduleQueryService { .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) .toList(); } - List lessons = applyOverrides(generatedLessons, startDate, endDate).stream() + List overrideSnapshot = scheduleOverrideRepository + .findByLessonDateBetweenWithDetails(startDate, endDate); + if (teacherOnlySearch) { + generatedLessons = addOverrideSourceOccurrencesForTeacher( + generatedLessons, + teacherId, + overrideSnapshot + ); + } + List 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 applyOverrides(List lessons, LocalDate startDate, LocalDate endDate) { - Map overrides = scheduleOverrideRepository - .findByLessonDateBetweenWithDetails(startDate, endDate) + private List addOverrideSourceOccurrencesForTeacher( + List generatedLessons, + Long teacherId, + Collection overrideSnapshot + ) { + Map> 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 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 applyOverrides(List lessons, + LocalDate exactDate, + Collection suppliedOverrides) { + Map 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, diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleRuleConflictException.java b/backend/src/main/java/com/magistr/app/service/ScheduleRuleConflictException.java new file mode 100644 index 0000000..791eacf --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleRuleConflictException.java @@ -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 conflictFields; + private final List conflictReasons; + + public ScheduleRuleConflictException(String message, + ScheduleRuleDto conflictRule, + List conflictFields, + List conflictReasons) { + super(message); + this.conflictRule = conflictRule; + this.conflictFields = List.copyOf(conflictFields); + this.conflictReasons = List.copyOf(conflictReasons); + } + + public ScheduleRuleDto getConflictRule() { + return conflictRule; + } + + public List getConflictFields() { + return conflictFields; + } + + public List getConflictReasons() { + return conflictReasons; + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java new file mode 100644 index 0000000..2334fdb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java @@ -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 VALID_LESSON_FORMATS = Set.of("Очно", "Онлайн"); + private static final Set 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 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 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 lockSemestersInStableOrder(Collection semesterIds) { + List 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 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 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 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 resolveSubgroups(ScheduleRule rule, + ScheduleRuleSlotDto slotDto, + ScheduleLessonCategory category) { + LinkedHashSet 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 ruleGroupIds = rule.getGroups().stream() + .map(StudentGroup::getId) + .collect(Collectors.toSet()); + Set subgroupGroupIds = new HashSet<>(); + LinkedHashSet 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 slots) { + EnumSet 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 slots = new ArrayList<>(candidate.getSlots()); + validateExactDuplicates(slots); + validateInternalConflicts(slots); + } + + private void validateExactDuplicates(List slots) { + Set 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 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 slots) { + Map> 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 fields = new ArrayList<>(conflictFields(first, second)); + if (!fields.isEmpty()) { + throw new ScheduleRuleConflictException( + "Невозможно сохранить правило: слоты внутри правила конфликтуют", + null, + fields, + conflictReasons(fields) + ); + } + } + } + } + + private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) { + Map> 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 fields = new ArrayList<>(conflict.fields()); + throw new ScheduleRuleConflictException( + "Невозможно сохранить правило: слот занят", + toDto(conflict.rule()), + fields, + conflictReasons(fields) + ); + } + + private ScheduleRuleConflict toConflict(ScheduleRule existingRule, + Collection candidateSlots, + Map> candidateActiveWeeks, + Collection existingSlots) { + Map> existingActiveWeeks = activeWeeksBySlot(existingSlots); + LinkedHashSet 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 conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) { + LinkedHashSet 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 conflictReasons(Collection 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 commonGroupIds = groupIds(first); + commonGroupIds.retainAll(groupIds(second)); + + for (Long groupId : commonGroupIds) { + if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) { + return true; + } + Set commonSubgroupIds = subgroupIdsForGroup(first, groupId); + commonSubgroupIds.retainAll(subgroupIdsForGroup(second, groupId)); + if (!commonSubgroupIds.isEmpty()) { + return true; + } + } + return false; + } + + private Set 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 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> activeWeeksBySlot(Collection slots) { + Map> 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 categorySlots = slots.stream() + .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) + .sorted(this::compareSlotsForGeneration) + .toList(); + if (categorySlots.isEmpty()) { + continue; + } + + Map 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 first, Set second) { + if (first == null || second == null || first.isEmpty() || second.isEmpty()) { + return false; + } + Set 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 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 groups = rule.getGroups().stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + List 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 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 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 subgroupIds, + Long teacherId, + Long classroomId, + Long lessonTypeId, + String lessonFormat) { + } + + private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet fields) { + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TenantDatabaseMigrationService.java b/backend/src/main/java/com/magistr/app/service/TenantDatabaseMigrationService.java new file mode 100644 index 0000000..f482a43 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TenantDatabaseMigrationService.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TenantLifecycleException.java b/backend/src/main/java/com/magistr/app/service/TenantLifecycleException.java new file mode 100644 index 0000000..be5fc83 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TenantLifecycleException.java @@ -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); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java b/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java new file mode 100644 index 0000000..ed3c26f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java @@ -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 executeSerialized(Supplier 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 oldConfigs = routingDataSource.snapshotTenantConfigs(); + Map 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 oldConfigs = routingDataSource.snapshotTenantConfigs(); + TenantConfig existing = oldConfigs.get(domain); + if (existing == null) { + throw new NoSuchElementException("Тенант '" + domain + "' не найден"); + } + + Map 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 requestedConfigs) { + if (requestedConfigs == null) { + throw new IllegalArgumentException("Список конфигураций тенантов обязателен"); + } + List configs = requestedConfigs.stream() + .map(this::validateAndNormalize) + .toList(); + Set 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 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 desiredConfigs, + Map 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 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 oldConfigs, + RuntimeException removalFailure) { + boolean restored = restorePersistedSnapshot(oldConfigs); + logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure); + String message = restored + ? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена" + : "Не удалось отключить тенанта; требуется проверить tenant Secret"; + return new TenantLifecycleException(message, removalFailure); + } + + private boolean restorePersistedSnapshot(Map 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 sortedConfigs(Map configs) { + List 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); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index ed1e51f..9ceec05 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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} diff --git a/backend/src/main/resources/db/migration/V3__schedule_override_invariants.sql b/backend/src/main/resources/db/migration/V3__schedule_override_invariants.sql new file mode 100644 index 0000000..ca5b5e6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__schedule_override_invariants.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V4__schedule_rule_even_hours.sql b/backend/src/main/resources/db/migration/V4__schedule_rule_even_hours.sql new file mode 100644 index 0000000..ee2d767 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__schedule_rule_even_hours.sql @@ -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 + ); diff --git a/backend/src/test/java/com/magistr/app/config/auth/AuthorizationInterceptorTest.java b/backend/src/test/java/com/magistr/app/config/auth/AuthorizationInterceptorTest.java new file mode 100644 index 0000000..50dae7c --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/AuthorizationInterceptorTest.java @@ -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(); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/JwtPropertiesTest.java b/backend/src/test/java/com/magistr/app/config/auth/JwtPropertiesTest.java new file mode 100644 index 0000000..510cb7d --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/JwtPropertiesTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/JwtSecurityStartupValidatorTest.java b/backend/src/test/java/com/magistr/app/config/auth/JwtSecurityStartupValidatorTest.java new file mode 100644 index 0000000..74b1aaa --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/JwtSecurityStartupValidatorTest.java @@ -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); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java new file mode 100644 index 0000000..039b53c --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java @@ -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>> 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> 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 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 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 { + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java index 068f1db..439b23a 100644 --- a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java @@ -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 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(); diff --git a/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java b/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java new file mode 100644 index 0000000..afea29b --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java @@ -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) { + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java index 3820475..7c9409f 100644 --- a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java @@ -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 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.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 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"); + } } diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantRoutingDataSourceTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantRoutingDataSourceTest.java new file mode 100644 index 0000000..11b4e90 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantRoutingDataSourceTest.java @@ -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 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"); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java new file mode 100644 index 0000000..13c169c --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java @@ -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("Конфликт расписания: преподаватель уже занят в указанное время")); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java new file mode 100644 index 0000000..600e772 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java @@ -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": "Очно" + } + ] + } + """; + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java new file mode 100644 index 0000000..05e5f89 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java @@ -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 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) { + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java new file mode 100644 index 0000000..2ac8f1e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java @@ -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 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) { + } +} diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java new file mode 100644 index 0000000..e287f24 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java @@ -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) { + } +} diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java new file mode 100644 index 0000000..b996c66 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java @@ -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 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> 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(); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java new file mode 100644 index 0000000..56f58c4 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java @@ -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 { + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java new file mode 100644 index 0000000..cdd8440 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java @@ -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 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> 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> 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> 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 groupIds, List 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 groupIds, + List 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> collectionCaptor() { + return ArgumentCaptor.forClass((Class) Collection.class); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java new file mode 100644 index 0000000..b414e49 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java @@ -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 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 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 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 + ); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java new file mode 100644 index 0000000..b564073 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java @@ -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 replacementSchedule = searchTeacher(REPLACEMENT_TEACHER_ID); + List 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 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 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 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 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 searchTeacher(long teacherId) { + return service.search( + null, teacherId, null, null, null, null, null, null, DATE, DATE + ); + } + + private void stubBaseDay(List 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 + ); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java new file mode 100644 index 0000000..8f5bb68 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java @@ -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> 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 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> 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> futures) { + for (int index = 0; index < futures.size(); index++) { + Future 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 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) { + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java new file mode 100644 index 0000000..72b1371 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java @@ -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 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; + } +} diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java new file mode 100644 index 0000000..12cfeae --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java @@ -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(); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java new file mode 100644 index 0000000..5b6e7f2 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java @@ -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 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 preparedConfig = ArgumentCaptor.forClass(TenantConfig.class); + ArgumentCaptor> 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> 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> 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> 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> 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> listCaptor() { + return ArgumentCaptor.forClass((Class) List.class); + } +} diff --git a/docs/API.md b/docs/API.md index fb4570d..b2750fa 100644 --- a/docs/API.md +++ b/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}` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce6e9b4..03dd79c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index b9bcb54..9130f65 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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, поэтому два конкурентных изменения одной даты проверяются последовательно. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 146d48d..56421de 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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 ``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e3a3a74..7477086 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 0a23cea..795eb45 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -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) diff --git a/docs/LOGGING.md b/docs/LOGGING.md index f26455a..757385a 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -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 | Запросы динамического расписания и ошибки генерации | diff --git a/docs/README.md b/docs/README.md index 1cb77ea..a2ee0d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | Архитектура фронтенда, модули, стили | diff --git a/docs/SECURITY_RUNBOOK.md b/docs/SECURITY_RUNBOOK.md new file mode 100644 index 0000000..d51c149 --- /dev/null +++ b/docs/SECURITY_RUNBOOK.md @@ -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. diff --git a/graphify-out/.graphify_learning.json b/graphify-out/.graphify_learning.json index 3dd8c8c..ac73e7b 100644 --- a/graphify-out/.graphify_learning.json +++ b/graphify-out/.graphify_learning.json @@ -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 diff --git a/graphify-out/memory/query_20260711_222223_какие_подсистемы_связывают_исправления_аутентифика.md b/graphify-out/memory/query_20260711_222223_какие_подсистемы_связывают_исправления_аутентифика.md new file mode 100644 index 0000000..6e99cf1 --- /dev/null +++ b/graphify-out/memory/query_20260711_222223_какие_подсистемы_связывают_исправления_аутентифика.md @@ -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 \ No newline at end of file diff --git a/graphify-out/memory/query_20260712_141303_как_scheduleoverrideservice_должен_выявлять_конфли.md b/graphify-out/memory/query_20260712_141303_как_scheduleoverrideservice_должен_выявлять_конфли.md new file mode 100644 index 0000000..7ebf30f --- /dev/null +++ b/graphify-out/memory/query_20260712_141303_как_scheduleoverrideservice_должен_выявлять_конфли.md @@ -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 \ No newline at end of file diff --git a/graphify-out/memory/query_20260712_145207_как_безопасно_валидировать_и_сохранять_точечные_из.md b/graphify-out/memory/query_20260712_145207_как_безопасно_валидировать_и_сохранять_точечные_из.md new file mode 100644 index 0000000..4363c2a --- /dev/null +++ b/graphify-out/memory/query_20260712_145207_как_безопасно_валидировать_и_сохранять_точечные_из.md @@ -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 \ No newline at end of file diff --git a/graphify-out/memory/query_20260712_214838_какие_связи_затрагивает_полная_валидация_правил_ра.md b/graphify-out/memory/query_20260712_214838_какие_связи_затрагивает_полная_валидация_правил_ра.md new file mode 100644 index 0000000..5325c6d --- /dev/null +++ b/graphify-out/memory/query_20260712_214838_какие_связи_затрагивает_полная_валидация_правил_ра.md @@ -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 \ No newline at end of file diff --git a/graphify-out/memory/query_20260712_220947_как_безопасно_заменить_tenant_datasource_без_потер.md b/graphify-out/memory/query_20260712_220947_как_безопасно_заменить_tenant_datasource_без_потер.md new file mode 100644 index 0000000..f57cfc9 --- /dev/null +++ b/graphify-out/memory/query_20260712_220947_как_безопасно_заменить_tenant_datasource_без_потер.md @@ -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 \ No newline at end of file diff --git a/graphify-out/memory/query_20260712_222021_как_безопасно_обновлять_tenant_datasource_без_поте.md b/graphify-out/memory/query_20260712_222021_как_безопасно_обновлять_tenant_datasource_без_поте.md new file mode 100644 index 0000000..a7276be --- /dev/null +++ b/graphify-out/memory/query_20260712_222021_как_безопасно_обновлять_tenant_datasource_без_поте.md @@ -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 \ No newline at end of file diff --git a/graphify-out/reflections/LESSONS.md b/graphify-out/reflections/LESSONS.md index ec1f82c..a1af700 100644 --- a/graphify-out/reflections/LESSONS.md +++ b/graphify-out/reflections/LESSONS.md @@ -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` diff --git a/scripts/check-production-secrets.sh b/scripts/check-production-secrets.sh new file mode 100644 index 0000000..5808e4d --- /dev/null +++ b/scripts/check-production-secrets.sh @@ -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-секретов завершена успешно"