From bc0e1ab1b4afe856282daefeba89ca9be1ec36a4 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 19 Jul 2026 14:40:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B0=D0=B3-=D1=84=D0=B8=D0=BA=D1=81=203?= =?UTF-8?q?0/34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 40 + .gitea/workflows/docker-build.yaml | 124 ++- BUG_FIX_PROGRESS.md | 567 +++++++++- backend/Dockerfile | 10 +- .../app/config/auth/ClientIpResolver.java | 146 +++ .../auth/LoginAttemptAuditCleanupJob.java | 167 +++ .../config/auth/LoginRateLimitService.java | 226 ++++ .../config/auth/LoginSecurityProperties.java | 70 ++ .../config/auth/RefreshTokenCleanupJob.java | 163 +++ .../app/config/auth/RefreshTokenService.java | 17 +- .../config/tenant/TenantDataSourceConfig.java | 18 +- .../app/config/tenant/TenantInterceptor.java | 6 +- .../tenant/TenantRoutingDataSource.java | 4 +- .../health/TenantDatabaseHealthMonitor.java | 2 + .../AcademicCalendarAdminController.java | 107 +- .../AcademicCalendarController.java | 29 +- .../app/controller/AuthController.java | 68 +- .../DatabaseConstraintViolationMapper.java | 190 ++++ .../app/controller/DepartmentController.java | 11 +- .../DepartmentWorkspaceController.java | 76 +- .../controller/EducationFormController.java | 2 +- .../controller/GlobalExceptionHandler.java | 24 +- .../app/controller/GroupController.java | 138 +-- .../app/controller/SpecialityController.java | 24 +- .../app/controller/SubgroupController.java | 13 +- .../app/controller/SubjectController.java | 10 +- .../TeacherCreationRequestController.java | 15 +- .../controller/TeacherSubjectController.java | 26 +- .../controller/TimeSlotAdminController.java | 81 +- .../app/controller/UserController.java | 125 +-- .../app/controller/WorkloadController.java | 165 ++- .../magistr/app/dto/ClassroomResponse.java | 10 +- .../magistr/app/dto/ScheduleOverrideDto.java | 4 +- .../magistr/app/dto/SubjectCommentDto.java | 4 +- .../dto/TeacherCreationRequestResponse.java | 6 +- .../app/model/AuthLoginAttemptAudit.java | 78 ++ .../magistr/app/model/AuthLoginRateLimit.java | 83 ++ .../magistr/app/model/AuthRefreshToken.java | 22 +- .../magistr/app/model/LifecycleEntity.java | 17 +- .../magistr/app/model/ScheduleOverride.java | 8 +- .../java/com/magistr/app/model/Subject.java | 2 +- .../com/magistr/app/model/SubjectComment.java | 8 +- .../app/model/TeacherCreationRequest.java | 22 +- .../model/TeacherDepartmentAssignment.java | 8 +- .../AcademicCalendarDayRepository.java | 28 + .../AcademicCalendarRepository.java | 14 + .../AcademicCalendarSubjectRepository.java | 2 + .../repository/AcademicYearRepository.java | 33 + .../AuthLoginAttemptAuditRepository.java | 31 + .../AuthLoginRateLimitRepository.java | 61 ++ .../AuthRefreshTokenRepository.java | 26 + .../app/repository/GroupRepository.java | 16 + .../repository/ScheduleRuleRepository.java | 49 + .../ScheduleRuleSlotRepository.java | 2 + .../app/repository/SemesterRepository.java | 41 + ...dentGroupCalendarAssignmentRepository.java | 51 + .../app/repository/SubjectRepository.java | 2 +- ...TeacherDepartmentAssignmentRepository.java | 45 + .../TimeSlotDateAssignmentRepository.java | 12 + .../app/repository/TimeSlotRepository.java | 46 + .../repository/TimeSlotScopeRepository.java | 10 + .../app/repository/UserRepository.java | 8 + .../app/service/AcademicDateService.java | 186 +++- .../app/service/AcademicPeriodService.java | 251 +++++ .../app/service/AcademicStructureService.java | 421 ++++++++ .../app/service/BusinessTimeService.java | 56 + .../app/service/ScheduleGeneratorService.java | 339 +++++- .../app/service/ScheduleQueryService.java | 76 +- .../app/service/SubjectImportService.java | 105 ++ .../app/service/TeacherDepartmentService.java | 234 +++++ .../magistr/app/service/TimeSlotService.java | 161 +++ .../utils/CourseAndSemesterCalculator.java | 5 +- .../src/main/resources/application.properties | 24 + .../main/resources/db/migration/V1__init.sql | 986 +++++++++++++++++- .../V2__subgroups_active_unique_name.sql | 6 - .../V3__schedule_override_invariants.sql | 94 -- .../V4__schedule_rule_even_hours.sql | 97 -- .../app/config/auth/ClientIpResolverTest.java | 51 + ...inRateLimitConcurrencyIntegrationTest.java | 200 ++++ .../auth/LoginRateLimitServiceTest.java | 149 +++ .../auth/RefreshTokenCleanupJobTest.java | 195 ++++ ...okenServiceConcurrencyIntegrationTest.java | 78 ++ .../config/auth/RefreshTokenServiceTest.java | 9 +- .../app/controller/AuthControllerTest.java | 41 + .../DepartmentWorkspaceControllerTest.java | 23 +- .../EducationOfficeRoleMatrixTest.java | 119 +++ ...obalExceptionHandlerDataIntegrityTest.java | 111 ++ .../TeacherSubjectControllerTest.java | 78 ++ .../app/controller/UserControllerTest.java | 12 +- .../controller/WorkloadControllerTest.java | 185 +++- ...riodInvariantMigrationIntegrationTest.java | 292 ++++++ ...emicStructureInvariantIntegrationTest.java | 393 +++++++ ...eduleOverrideMigrationIntegrationTest.java | 107 +- ...duleRuleHoursMigrationIntegrationTest.java | 148 +-- ...jectOwnershipInvariantIntegrationTest.java | 146 +++ ...herDepartmentInvariantIntegrationTest.java | 257 +++++ ...SlotInvariantMigrationIntegrationTest.java | 316 ++++++ .../app/model/LifecycleEntityTest.java | 14 + .../service/AcademicPeriodServiceTest.java | 245 +++++ .../service/AcademicStructureServiceTest.java | 335 ++++++ .../app/service/BusinessTimeServiceTest.java | 36 + .../ScheduleGenerationQueryCountTest.java | 265 +++++ ...heduleQueryServiceTeacherOverrideTest.java | 22 +- .../app/service/ScheduleQueryServiceTest.java | 39 + .../app/service/SubjectImportServiceTest.java | 110 ++ .../service/TeacherDepartmentServiceTest.java | 209 ++++ ...antLifecyclePostgreSqlIntegrationTest.java | 2 +- .../app/service/TimeSlotServiceTest.java | 226 ++++ compose.yaml | 70 +- docs/API.md | 148 ++- docs/ARCHITECTURE.md | 115 +- docs/BUSINESS_LOGIC.md | 65 +- docs/DATABASE.md | 152 ++- docs/DEVELOPMENT.md | 14 +- docs/FRONTEND.md | 140 +-- docs/INFRASTRUCTURE.md | 160 ++- docs/LOGGING.md | 10 +- docs/README.md | 12 +- frontend/.dockerignore | 2 + frontend/Dockerfile | 30 +- frontend/admin/css/components.css | 59 +- frontend/admin/css/layout.css | 13 + frontend/admin/css/main.css | 10 + frontend/admin/index.html | 17 - frontend/admin/js/api.js | 88 +- frontend/admin/js/dropdown.js | 10 +- frontend/admin/js/main.js | 59 +- frontend/admin/js/otel.js | 47 - frontend/admin/js/role-capabilities.js | 46 + frontend/admin/js/utils.js | 42 +- frontend/admin/js/views/academic-calendar.js | 41 +- frontend/admin/js/views/classrooms.js | 2 +- frontend/admin/js/views/dashboard.js | 12 +- .../admin/js/views/department-workspace.js | 19 +- frontend/admin/js/views/groups.js | 6 +- frontend/admin/js/views/schedule-view.js | 7 +- frontend/admin/js/views/schedule.js | 8 - frontend/admin/js/views/teacher-requests.js | 10 +- .../admin/js/views/university-structure.js | 8 +- frontend/admin/settings/index.html | 4 - frontend/admin/settings/js/main.js | 30 +- frontend/admin/settings/js/views/database.js | 22 +- frontend/admin/settings/js/views/edu-forms.js | 2 +- .../admin/settings/js/views/time-slots.js | 12 +- frontend/admin/settings/views/database.html | 4 +- frontend/admin/settings/views/time-slots.html | 4 +- frontend/admin/views/users.html | 4 +- frontend/auth-session.js | 119 +++ frontend/department/index.html | 4 +- frontend/edu-office/index.html | 4 +- frontend/index.html | 5 +- frontend/package-lock.json | 949 +++++++++++++++++ frontend/package.json | 17 +- frontend/proxy.conf | 10 + frontend/script.js | 50 +- frontend/scripts/build-vendor.mjs | 10 + frontend/security.conf | 5 + frontend/student/app.js | 222 ++++ frontend/student/index.html | 570 +--------- frontend/student/style.css | 297 ++++++ frontend/style.css | 14 + frontend/teacher/app.js | 246 +++++ frontend/teacher/index.html | 600 +---------- frontend/teacher/style.css | 303 ++++++ frontend/telemetry.js | 13 + frontend/telemetry/otel-entry.js | 40 + frontend/tests/auth-session.test.mjs | 174 ++++ frontend/tests/local-date-format.test.mjs | 18 + frontend/tests/security-policy.test.mjs | 169 +++ frontend/theme-toggle.js | 11 +- scripts/deploy-images.sh | 64 ++ scripts/test-deploy-images.sh | 56 + 172 files changed, 13431 insertions(+), 2910 deletions(-) create mode 100644 .env.example mode change 100755 => 100644 .gitea/workflows/docker-build.yaml create mode 100644 backend/src/main/java/com/magistr/app/config/auth/ClientIpResolver.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/LoginAttemptAuditCleanupJob.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/LoginRateLimitService.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/LoginSecurityProperties.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/RefreshTokenCleanupJob.java create mode 100644 backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java create mode 100644 backend/src/main/java/com/magistr/app/model/AuthLoginAttemptAudit.java create mode 100644 backend/src/main/java/com/magistr/app/model/AuthLoginRateLimit.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AuthLoginAttemptAuditRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AuthLoginRateLimitRepository.java create mode 100644 backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java create mode 100644 backend/src/main/java/com/magistr/app/service/AcademicStructureService.java create mode 100644 backend/src/main/java/com/magistr/app/service/BusinessTimeService.java create mode 100644 backend/src/main/java/com/magistr/app/service/SubjectImportService.java create mode 100644 backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java create mode 100644 backend/src/main/java/com/magistr/app/service/TimeSlotService.java delete mode 100644 backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql delete mode 100644 backend/src/main/resources/db/migration/V3__schedule_override_invariants.sql delete mode 100644 backend/src/main/resources/db/migration/V4__schedule_rule_even_hours.sql create mode 100644 backend/src/test/java/com/magistr/app/config/auth/ClientIpResolverTest.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitConcurrencyIntegrationTest.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/RefreshTokenCleanupJobTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/EducationOfficeRoleMatrixTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/GlobalExceptionHandlerDataIntegrityTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java create mode 100644 backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java create mode 100644 backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java create mode 100644 backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java create mode 100644 backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java create mode 100644 backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/BusinessTimeServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/SubjectImportServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/TimeSlotServiceTest.java delete mode 100644 frontend/admin/js/otel.js create mode 100644 frontend/admin/js/role-capabilities.js create mode 100644 frontend/auth-session.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/proxy.conf create mode 100644 frontend/scripts/build-vendor.mjs create mode 100644 frontend/security.conf create mode 100644 frontend/student/app.js create mode 100644 frontend/student/style.css create mode 100644 frontend/teacher/app.js create mode 100644 frontend/teacher/style.css create mode 100644 frontend/telemetry.js create mode 100644 frontend/telemetry/otel-entry.js create mode 100644 frontend/tests/auth-session.test.mjs create mode 100644 frontend/tests/local-date-format.test.mjs create mode 100644 frontend/tests/security-policy.test.mjs create mode 100644 scripts/deploy-images.sh create mode 100644 scripts/test-deploy-images.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae07dab --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Скопируйте файл в .env и замените пустые значения локальными секретами. +POSTGRES_USER=myuser +POSTGRES_PASSWORD= +POSTGRES_DB=app_db + +# Сгенерировать: openssl rand -base64 48 +JWT_SECRET= +JWT_ACCESS_TOKEN_TTL=15m +JWT_REFRESH_TOKEN_TTL=7d +JWT_REFRESH_COOKIE_NAME=magistr_refresh +JWT_REFRESH_COOKIE_SECURE=false +JWT_REFRESH_CLEANUP_ENABLED=true +JWT_REFRESH_CLEANUP_RETENTION=30d +JWT_REFRESH_CLEANUP_BATCH_SIZE=500 +JWT_REFRESH_CLEANUP_MAX_BATCHES=20 +JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000 +JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000 + +# Защита входа. Указывайте только адреса/сети фактических reverse proxy. +LOGIN_RATE_MAX_FAILURES=5 +LOGIN_RATE_ATTEMPT_WINDOW=15m +LOGIN_RATE_BASE_BLOCK_DURATION=1m +LOGIN_RATE_MAX_BLOCK_DURATION=15m +LOGIN_AUDIT_CLEANUP_ENABLED=true +LOGIN_AUDIT_RETENTION=90d +LOGIN_AUDIT_CLEANUP_BATCH_SIZE=500 +LOGIN_AUDIT_CLEANUP_MAX_BATCHES=20 +LOGIN_AUDIT_CLEANUP_INTERVAL_MS=86400000 +LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS=120000 +TRUSTED_PROXY_CIDRS=172.16.0.0/12 + +# Календарные бизнес-даты приложения. +BUSINESS_TIME_ZONE=Europe/Moscow +TZ=Europe/Moscow + +# Локально Collector не входит в Compose. Укажите false при подключённом OTLP Collector. +OTEL_SDK_DISABLED=true + +# Измените, если порт 80 занят. +HTTP_PORT=80 diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml old mode 100755 new mode 100644 index b23c642..917fa20 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -1,4 +1,4 @@ -name: Build and Push Docker Images +name: Проверка, сборка и развёртывание Magistr on: push: @@ -7,32 +7,79 @@ on: tags: - 'v*' +concurrency: + group: magistr-production + cancel-in-progress: false + env: - REGISTRY: gitea.zuev.company # Замените на реальный домен вашего Gitea + REGISTRY: gitea.zuev.company BACKEND_IMAGE: zuev/magistr-backend FRONTEND_IMAGE: zuev/magistr-frontend jobs: - build-and-push-backend: + checks: + name: Обязательные проверки runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - name: Checkout repository + - name: Получить исходный код uses: actions/checkout@v4 - - name: Log in to the Container registry + - name: Backend unit и component tests + run: >- + docker run --rm + -v "${{ github.workspace }}/backend:/workspace" + -w /workspace + maven:3.9.9-eclipse-temurin-17 + mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test + + - name: Frontend static и unit tests + run: >- + docker run --rm + -v "${{ github.workspace }}/frontend:/workspace" + -w /workspace + node:22-alpine3.23 + sh -c "npm ci && npm run check" + + - name: Проверить Docker Compose + env: + POSTGRES_PASSWORD: ci-config-only + JWT_SECRET: ci-only-secret-not-for-runtime-0123456789abcdef0123456789abcdef + run: docker compose config --quiet + + - name: Проверить сценарий immutable rollout и rollback + run: bash scripts/test-deploy-images.sh + + build-and-push-backend: + name: Собрать backend image + needs: checks + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + digest: ${{ steps.build.outputs.digest }} + steps: + - name: Получить исходный код + uses: actions/checkout@v4 + + - name: Войти в Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} - password: ${{ secrets.ZUEV_TOKEN }} # Нужно создать секрет ZUEV_TOKEN в настройках репозитория (Personal Access Token) + password: ${{ secrets.ZUEV_TOKEN }} - - name: Extract metadata (tags, labels) for Docker + - name: Сформировать immutable tags и labels id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }} + flavor: latest=false + tags: | + type=sha,format=long,prefix=sha- + type=ref,event=tag - - name: Build and push Docker image + - name: Собрать и опубликовать backend image + id: build uses: docker/build-push-action@v6 with: context: ./backend @@ -41,26 +88,38 @@ jobs: labels: | ${{ steps.meta.outputs.labels }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + build-and-push-frontend: + name: Собрать frontend image + needs: checks runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + digest: ${{ steps.build.outputs.digest }} steps: - - name: Checkout repository + - name: Получить исходный код uses: actions/checkout@v4 - - name: Log in to the Container registry + - name: Войти в Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.ZUEV_TOKEN }} - - name: Extract metadata (tags, labels) for Docker + - name: Сформировать immutable tags и labels id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }} + flavor: latest=false + tags: | + type=sha,format=long,prefix=sha- + type=ref,event=tag - - name: Build and push Docker image + - name: Собрать и опубликовать frontend image + id: build uses: docker/build-push-action@v6 with: context: ./frontend @@ -69,29 +128,38 @@ jobs: labels: | ${{ steps.meta.outputs.labels }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} deploy-to-k8s: + name: Развернуть проверенные digests needs: [build-and-push-backend, build-and-push-frontend] runs-on: ubuntu-latest + timeout-minutes: 15 + environment: production + env: + BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }} + FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }} + KUBECTL_VERSION: v1.33.12 + KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e steps: - - name: Create kubeconfig - run: | - mkdir -p ~/.kube - echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config - chmod 600 ~/.kube/config + - name: Получить deploy-скрипт + uses: actions/checkout@v4 - - name: Install kubectl + - name: Создать kubeconfig + env: + KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }} run: | - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - chmod +x kubectl - mv kubectl /usr/local/bin/ + install -d -m 700 "$HOME/.kube" + printf '%s' "$KUBECONFIG_DATA" | base64 --decode > "$HOME/.kube/config" + chmod 600 "$HOME/.kube/config" - - name: Trigger Kubernetes Rollout + - name: Установить проверенный kubectl run: | - # Перезапускаем поды, чтобы они скачали свежий :main образ - kubectl rollout restart deployment backend frontend -n magistr - - # Ждём успешного обновления (5 минут на backend из-за Spring Boot) - kubectl rollout status deployment/frontend -n magistr --timeout=120s - kubectl rollout status deployment/backend -n magistr --timeout=300s + curl --fail --show-error --silent --location --retry 3 \ + --output kubectl \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + echo "${KUBECTL_SHA256} kubectl" | sha256sum --check --strict + sudo install -m 0755 kubectl /usr/local/bin/kubectl + - name: Применить digests и проверить rollout + run: bash scripts/deploy-images.sh "$BACKEND_REF" "$FRONTEND_REF" diff --git a/BUG_FIX_PROGRESS.md b/BUG_FIX_PROGRESS.md index c3f724b..f32f010 100644 --- a/BUG_FIX_PROGRESS.md +++ b/BUG_FIX_PROGRESS.md @@ -1,23 +1,30 @@ # Журнал исправления проблем -Обновлено: 2026-07-17 (Europe/Moscow). +Обновлено: 2026-07-19 (Europe/Moscow). ## Область и неизменяемые данные - В работе ровно 33 проблемы: `1` и `3–34`. - Проблема № 2 исключена: демонстрационные учётные записи, seed-данные и документация по ним не изменяются. - `BUG_REPORT.md` не редактируется. -- Существующие Flyway-миграции не редактируются. +- По прямому разрешению владельца проекта миграции V2–V7 удалены, а их итоговая схема + объединена в `V1__init.sql`: проект находится на этапе разработки, клиентских tenant-БД нет. + Для этой редакции требуется полный сброс локальной БД. Исходное рабочее дерево перед началом реализации было чистым (`git status --short` без вывода). -Контрольные суммы исходных миграций: +Контрольные суммы исходных миграций до разрешённой консолидации (историческая справка): | Файл | SHA-256 | |---|---| | `V1__init.sql` | `404f65f270dd87f4db996495fc970456c4fd0f762e7c32638199dffaa812ea7b` | | `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` | +Текущая единая baseline-миграция: `V1__init.sql`, SHA-256 +`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`. +Упоминания V2–V7 в исторических записях ниже описывают последовательность разработки до +консолидации и не означают наличие этих файлов в текущем дереве. + ## Исходная линия проверок | Проверка | Результат до изменений | @@ -50,36 +57,36 @@ | 1 | требуется внешнее действие | В коде и production YAML были встроенные/placeholder JWT и DB credentials; tenant и OTel credentials находились в отслеживаемой конфигурации; Secure-cookie не был обязательным для production. | Встроенный JWT default удалён; добавлен fail-fast validator; production использует только внешние `app-secret`, `tenants-secret`, `otel-postgres-secret`; создан `docs/SECURITY_RUNBOOK.md`. Репозиторная часть исправлена. Оператору остаются ротация, provisioning, rollout и очистка истории. | 11 JWT unit/context tests; общий backend: 45/0/0; `scripts/check-production-secrets.sh`; Kustomize и shell syntax — успешно. | | 3 | исправлено и проверено | Ротация refresh-токена выполняла read-check-write без блокировки/условного update. | `RefreshTokenService.rotate()` использует блокирующий `findByTokenHashForUpdate()` с `PESSIMISTIC_WRITE` и `JOIN FETCH` внутри транзакции. | Два синхронных запроса к Spring proxy и PostgreSQL: один успех, один отказ, один активный новый токен. Полный backend: 46/0/0/0. | | 4 | исправлено и проверено | `AuthContext` устанавливался до role-check и сохранялся в servlet-потоке, когда `preHandle=false` не приводил к `afterCompletion`. | `AuthorizationInterceptor` очищает контекст в начале каждого запроса и устанавливает пользователя только после успешной проверки ролей; финальная очистка сохранена. | Один MockMvc-поток: `403`, публичный запрос, разрешённый запрос и очистка после завершения. Полный backend: 47/0/0/0. | -| 5 | исправлено и проверено | Override сохранялся без доказательства исходной пары, action-specific полей и ресурсных конфликтов. | `ScheduleOverrideService` строит base/effective day, валидирует матрицу и фактическое изменение, проверяет ресурсы и сериализует CRUD через PostgreSQL advisory locks; V3 закрепляет структурные инварианты. | 27 целевых unit/MockMvc/PostgreSQL tests; полный backend: 74/0/0/0. | +| 5 | исправлено и проверено | Override сохранялся без доказательства исходной пары, action-specific полей и ресурсных конфликтов. | `ScheduleOverrideService` строит base/effective day, валидирует матрицу и фактическое изменение, проверяет ресурсы и сериализует CRUD через PostgreSQL advisory locks; структурные CHECK включены в V1. | 27 целевых unit/MockMvc/PostgreSQL tests; полный backend: 74/0/0/0. | | 6 | исправлено и проверено | Teacher-only поиск загружал только базовые правила исходного преподавателя до применения override. | Один snapshot overrides; релевантные `newTeacher`-замены достраивают exact base occurrences по уникальным датам, затем применяются overrides, финальный teacher-фильтр и deduplicate. | 5 новых A→B/phantom/cache/dedup/regression тестов; узкий прогон 10/0/0/0, полный backend 79/0/0/0. | -| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. V4 закрепляет чётность и точный дубль в БД. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. | +| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. Чётность и точный дубль закреплены в V1. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. | | 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. | | 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ persistence мог сочетаться с изменённым локальным состоянием. | `TenantLifecycleService` выполняет prepare → connection validation → Flyway → Secret persistence → atomic snapshot swap; candidate закрывается при отказе, прежний route сохраняется, Secret компенсируется, старый pool закрывается после drain. | 18 целевых unit/MockMvc/PostgreSQL tests; create/update, credentials, invalid connection, Flyway, persistence, swap, HTTP `503` и in-flight connection. Полный backend: 132/0/0/0. | | 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. | | 11 | исправлено и проверено | Воскресенье вычислялось повторным мутированием одной даты, date-only строился через UTC, а ошибки кафедральных запросов превращались в пустой успешный результат. | `dashboard-conflicts.js` формирует локальный диапазон на отдельных объектах, загружает кафедры через `Promise.allSettled` и fail-closed различает `COMPLETE`/`PARTIAL`/`NOT_RUN`; зелёная карточка разрешена только для полного результата без конфликтов. | 12 `node:test`: 02.07.2026 00:15 Europe/Moscow, переход года, полный/частичный/нулевой успех и строгий UI-контракт; `npm run check` и синтаксис 25 JS/MJS-файлов — успешно. | -| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. | +| 12 | исправлено и проверено | Четыре frontend-клиента хранили access JWT и профиль в Web Storage; login/admin исполняли OTel с `esm.sh`, а inline JS/styles не позволяли включить строгий CSP. | Общий `auth-session.js` держит access JWT только в памяти и восстанавливает его через HttpOnly refresh-cookie; OTel собирается из exact npm-зависимостей в same-origin bundle; Apache выдаёт CSP без `unsafe-inline`/`unsafe-eval`. | 21 frontend test, `npm audit` 0 vulnerabilities, Docker build и live HTTP/CSP/bundle check — успешно. | | 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30–300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. | | 14 | исправлено и проверено | Kubernetes HTTP-клиент имел trust-all TLS fallback. | `KubernetesTenantSecretUpdater` загружает service-account CA, строит PKIX trust store, включает HTTPS hostname verification и не имеет небезопасного fallback. | Mock HTTPS: доверенный CA принят; чужой CA, неверный hostname и отсутствующий CA отклонены. Backend 45/0/0. | -| 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. | -| 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. | -| 17 | не начато | Изменение измерений группы/календаря оставляет несовместимые assignments/grid/subjects. | Отклонять несовместимое изменение транзакционно с `409`; контролировать `courseCount`. | Assignment сохранён после отказа; старшие строки не остаются. | -| 18 | не начато | Бизнес-решения смешивают `users.department_id` с датированными assignments и фильтруют архивные записи по текущему состоянию. | Date-aware resolver кафедры и единое использование истории. | Будущий перевод, прошлый период, архивный преподаватель, совместительство. | -| 19 | не начато | Workload принимает произвольный `departmentId` для роли `DEPARTMENT`. | Принудительный scope из `AuthContext`; глобальный доступ только разрешённым ролям. | Межкафедральный запрос кафедры не раскрывает данные. | -| 20 | не начато | Агрегаты переиспользуют интерактивный широкий поиск с лимитом 50 групп. | Специализированная batch/aggregate генерация без UI-лимита. | 51 и 100 активных групп. | -| 21 | не начато | Импорт ищет дисциплину по глобальному имени и меняет её владельца. | `409` для чужой записи, идемпотентность своей; scoped uniqueness при миграции. | Повторный свой импорт и совпадение чужой кафедры. | -| 22 | не начато | Видимые EDUCATION_OFFICE экраны вызывают backend endpoints с ADMIN-only правами. | Единая role matrix для backend/frontend; согласовать read/write capabilities. | Интеграционный сценарий календаря и форм обучения без штатного `403`. | -| 23 | не начато | Генератор повторно запрашивает semester/assignment/calendar day на каждой комбинации. | Батч-загрузка и request-scoped lookup maps. | Query-count test на 120 датах и десятках групп. | -| 24 | не начато | Истёкшие/отозванные refresh rows не удаляются. | Tenant-aware scheduled cleanup с retention и безопасностью двух pod. | Active/fresh rows сохраняются; cleanup идемпотентен и конкурентно безопасен. | -| 25 | не начато | Нет общего mapping DB constraints; контроллеры возвращают клиенту `getMessage()`. | Централизованный sanitizer/mapping `400`/`409`, убрать raw exception body. | Известный/неизвестный constraint без JDBC details. | -| 26 | не начато | Compose требует внешнюю сеть, не публикует HTTP, рассогласует DB/JWT и не имеет named volume. | Самодостаточная сеть/reverse proxy, согласованные env и volume. | Чистый `docker compose up -d --build`, HTTP `/` и `/api`. | -| 27 | не начато | Pipeline собирает/деплоит без обязательных тестов и для tag перезапускает mutable `main`. | Test gates, concurrency, immutable tag/digest rollout, rollback check. | Workflow lint/static inspection и shell tests. | -| 28 | не начато | Login не имеет shared rate limit; IP безусловно доверяет forwarded header. | PostgreSQL-backed tenant+trusted-IP+username limiter, lockout/audit, `429`/`Retry-After`. | Multi-instance integration tests, отсутствие user enumeration. | +| 15 | исправлено и проверено | CRUD слотов жил в контроллере, проверял только номер пары и принимал клиентскую длительность; DB не запрещала overlap и перенос используемого базового слота. | Транзакционный `TimeSlotService` блокирует сетки, валидирует границы, вычисляет duration и отклоняет overlap; V1 содержит duration CHECK, GiST exclusion и триггеры привязки правил к `DEFAULT`; UI показывает расчётное readonly-поле. | 6 unit; общий backend без Testcontainers 209/0/0/0; чистая V1 и конкурентные DB-сценарии проверены на PostgreSQL 16.3. | +| 16 | исправлено и проверено | CRUD проверял только порядок дат; update не проверял duplicate title/type, годы и семестры пересекались, а семестр мог выходить за год. | `AcademicPeriodService` централизует inclusive-range validation и блокирует родительский год; V1 содержит GiST exclusion constraints и взаимные триггеры границ года/семестра. | 8 unit; общий backend без Testcontainers 209/0/0/0; чистая V1, adjacent/overlap/bounds и конкурентные годы проверены на PostgreSQL 16.3. | +| 17 | исправлено и проверено | Изменение измерений группы/календаря оставляло несовместимые assignments/grid/subjects. | `AcademicStructureService` транзакционно блокирует группу/график, перевалидирует назначения, курсы, сетку и дисциплины; V1 дублирует инварианты конкурентно безопасными триггерами. Несовместимое изменение даёт русский `409` без мутации данных. | 7 unit; общий backend без Testcontainers 209/0/0/0; чистая V1, DB-отказы и гонка двух транзакций проверены на PostgreSQL 16.3. | +| 18 | исправлено и проверено | Бизнес-решения смешивали `users.department_id` с датированными assignments и фильтровали архивные записи по текущему состоянию. | `TeacherDepartmentService` централизует разрешение кафедры на дату; списки, права и нагрузка используют историю, будущий перевод не меняет текущую принадлежность; V1 запрещает overlap основных периодов. | 5 service + 7 controller tests; полный backend 216/0/0/0; чистая V1, overlap и конкурентная запись проверены на PostgreSQL 16.3. | +| 19 | исправлено и проверено | Workload принимал произвольный `departmentId` или глобальный scope для роли `DEPARTMENT`. | Все пять workload-сценариев вычисляют scope через `AuthContext`: кафедра получает только свою выборку, чужой ID даёт русский `403`; глобальный scope сохранён уполномоченным ролям. | 5 controller tests; полный backend 219/0/0/0; compose и diff-check успешны. | +| 20 | исправлено и проверено | Workload и free-classrooms переиспользовали интерактивный широкий поиск, который отклонял tenant с более чем 50 активными группами. | Добавлен узкий `searchForAggregation`: общие validation/overrides/filter/dedup сохранены, UI-лимит отключён только для агрегатов; обычный search не изменён. | 51 и 100 активных групп обработаны полностью; целевые 9/0/0/0, полный backend 221/0/0/0. | +| 21 | исправлено и проверено | Импорт находил дисциплину по глобальному имени и без проверки менял её `department_id`. | Транзакционный `SubjectImportService` сохраняет глобальное владение: свой повтор обновляет тот же ID, чужой даёт `409`; V1 содержит case-insensitive уникальность названия. | 4 unit; полный backend 225/0/0/0; чистая V1, регистровый дубль и конкурентная вставка проверены на PostgreSQL 16.3. | +| 22 | исправлено и проверено | Календарный график учебного отдела получал `403` на specialties/profiles, а видимый CRUD форм обучения был ADMIN-only. | EDUCATION_OFFICE получила read-only specialities/profiles и CRUD education forms; frontend admin/settings используют единый `role-capabilities.js`. | 2 MockMvc role-сценария через реальный interceptor; frontend role test; полный backend 227/0/0/0, npm check успешен. | +| 23 | исправлено и проверено | Генератор искал semester внутри дат и правил, а assignment/calendar day и эффективную сетку времени — внутри комбинаций дат и групп. | `AcademicDateService.ScheduleSnapshot`, batch-репозитории и `buildScheduleForGroups()` загружают данные один раз на запрос; lookup-карты используются во всех внутренних циклах. | 120 дат, 30 групп и 20 правил: ровно 7 repository reads, 30 корректных занятий; полный backend 228/0/0/0. | +| 24 | исправлено и проверено | Login/refresh добавляли строки, а revoke/rotate только ставили `revoked_at`; cleanup отсутствовал. | `RefreshTokenCleanupJob` обходит snapshot tenant-БД, удаляет старые строки ограниченными транзакционными пачками с `FOR UPDATE SKIP LOCKED`; retention и расписание настраиваются, cleanup-индексы включены в V1. | 3 unit; полный backend 231/0/0/0; точная V1 и две конкурентные PostgreSQL-транзакции удалили разные пачки, fresh/active сохранены, повтор удалил 0. | +| 25 | исправлено и проверено | Не было общего mapping DB constraints; несколько контроллеров включали `Exception.getMessage()` в HTTP-body. | `GlobalExceptionHandler` и `DatabaseConstraintViolationMapper` централизованно преобразуют CHECK в безопасный русский `400`, UNIQUE/FK/GiST и неизвестные нарушения — в `409`; raw exception body удалён. | 4 новых теста известных и неизвестных constraints без JDBC/SQL details; полный backend 235/0/0/0. | +| 26 | исправлено и проверено | Compose требовал внешнюю сеть, не публиковал HTTP, рассогласовывал DB/JWT и использовал anonymous volume. Live startup также выявил два неоднозначных Spring-конструктора. | Внутренняя bridge-сеть, Apache reverse proxy и `HTTP_PORT`; согласованные datasource/JWT env, healthchecks, named volume; production-конструкторы явно помечены для DI. | `up --build --wait`; PostgreSQL/backend/frontend healthy, `/` и liveness `200`, API `401`, named volume; полный backend 235/0/0/0. | +| 27 | исправлено и проверено | Pipeline собирал и деплоил без тестов, а release tag только перезапускал Deployment на mutable `:main`. | Обязательный checks job, production concurrency lock, SHA/release build tags и deploy только digest из build outputs; общий shell rollback обоих Deployment. | Workflow YAML/static scan, shell success/failure/rollback, production-secret scan и Kustomize — успешно. | +| 28 | исправлено и проверено | Login не имел shared rate limit, различал архивного пользователя и безусловно доверял forwarded header. | Транзакционный PostgreSQL limiter по tenant + NFKC username + проверенному IP; прогрессивная блокировка, audit/cleanup, единый `401`, `429`/`Retry-After` и trusted proxy chain. | 12 целевых unit/PostgreSQL tests; два service instance дали ровно один `401` и один `429`, общий failure count 2; V1 и cleanup проверены. | | 29 | не начато | Business dates зависят от timezone процесса; frontend date-only строится через UTC. | Инъекция `Clock`/`Europe/Moscow`, UTC timestamps, локальное форматирование date-only, TZ контейнеров. | Boundary tests 00: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. | +| 33 | исправлено и проверено | Несколько `e.message` вставлялись как HTML; парольные поля имели `type=text`. | Ошибки вкладок, БД и заявок рендерятся фиксированным текстом через `textContent`/`replaceChildren`; поля создания пользователя, tenant и одобрения заявки используют `type=password` и `autocomplete=new-password`. | 3 новых static regression tests; полный frontend: 24 проверки, 0 ошибок; CSP-хэши синхронизированы. | +| 34 | исправлено и проверено | В затронутых UI/log paths оставались английские метки/логи и raw exception text. | Статусы БД и пользовательские ошибки русифицированы; начальная tenant-конфигурация, маршрутизация и interceptor пишут русские production-сообщения с безопасным типом исключения. | Static-проверка исходных английских сообщений; полный backend 200/0/0/0; live Docker HTTP/CSP — успешно. | ## Завершённые этапы @@ -418,17 +425,515 @@ | `git diff --check` | Успешно. | | Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. | +### № 12 — access JWT только в памяти и same-origin JavaScript + +- Добавлен единый `frontend/auth-session.js` для login, admin/settings, teacher и student. + Access JWT, роль, кафедра и ID пользователя существуют только в памяти ES-модуля; + legacy-ключи удаляются из `localStorage` и `sessionStorage`, но больше не читаются и не + записываются. После navigation/reload новый документ восстанавливает профиль через + `POST /api/auth/refresh` и `HttpOnly` cookie. +- `fetchWithAuth()` добавляет bearer-токен из памяти, дедуплицирует параллельные refresh- + запросы одним Promise и повторяет исходный запрос не более одного раза. Logout отзывает + серверную cookie-сессию и очищает память даже при сетевой ошибке. +- Inline CSS/JS кабинетов преподавателя и студента вынесены в `style.css`/`app.js`; inline- + редиректы кафедры и учебного отдела удалены. Admin/settings запускают UI только после + успешного async session bootstrap и проверяют роль из общего профиля. +- Удалён `admin/js/otel.js` с runtime-import из `esm.sh`. OpenTelemetry 2.9.0/0.220.0 и + auto-instrumentations 0.65.0 зафиксированы exact-версиями в `package-lock.json`, собираются + esbuild в `/vendor/otel.js` на первом этапе Dockerfile и загружаются только с текущего + origin. `npm audit` не обнаруживает уязвимостей. +- Apache подключает `security.conf`: `script-src 'self'`, `script-src-attr 'none'`, + `style-src 'self'`, exact SHA-256 для статических style-атрибутов, `connect-src 'self'`, + запрет frame/object и дополнительные защитные заголовки. `unsafe-inline`, `unsafe-eval`, + удалённые JS imports и динамические inline-стили отсутствуют; тест проверяет полный набор + CSP-хэшей. Базовые frontend-образы зафиксированы digest; остальные пункты № 30 остаются в + его собственном объёме. +- AutoUpdateDocs синхронизировал `docs/FRONTEND.md`, `docs/INFRASTRUCTURE.md`, + `docs/ARCHITECTURE.md`, `docs/API.md` и устаревший пример в `docs/DEVELOPMENT.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `npm run check` в `frontend/` | 21 тест: 12 dashboard + 5 auth session + 4 CSP/security; синтаксис критических модулей и все тесты успешны. | +| `npm audit --audit-level=moderate` | `found 0 vulnerabilities`. | +| Локальный `npm run build:vendor` | Same-origin bundle `dist/vendor/otel.js`, 327471 байт; удалённых imports нет. | +| `docker build -t magistr-frontend-codex:bug12 frontend` | Multi-stage npm/esbuild → Apache завершён успешно. | +| Live container HTTP check | `/` и `/vendor/otel.js` вернули `200`; CSP и дополнительные headers присутствуют, `unsafe-inline`/`unsafe-eval` отсутствуют. Временный контейнер остановлен и удалён. | +| `git diff --check` | Успешно. | +| Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. | + +### № 33 и № 34 — безопасный DOM, скрытые пароли и языковой регламент + +- Для ошибок загрузки SPA-вкладок, статуса/списка tenant-БД и заявок преподавателей добавлены + общие `renderStatusMessage()`/`renderTableMessage()`. Они создают DOM-узлы, записывают + фиксированный русский текст через `textContent` и атомарно заменяют содержимое через + `replaceChildren`; `exception.message` больше не попадает в эти UI-границы. +- Поля пароля при создании пользователя, настройке tenant-БД и одобрении заявки преподавателя + используют `type="password"` и `autocomplete="new-password"`. Поля логина помечены + `autocomplete="username"`. +- Метки доступности БД `Online`/`Offline` заменены на `Доступно`/`Недоступно`. Затронутые + production-логи `TenantDataSourceConfig`, `TenantRoutingDataSource` и `TenantInterceptor` + переведены на русский; вместо текста исключения фиксируется только безопасный технический + тип, а стек остаётся в отдельной русскоязычной записи `DEBUG`. Англоязычные frontend-логи + загрузки оборудования, форм обучения и подгрупп также русифицированы; raw `error.message` + больше не дописывается в собственные browser-console сообщения. +- CSP пересчитан после удаления двух неиспользуемых style-атрибутов. Статический тест требует + точного равенства множества SHA-256-хэшей реальным атрибутам и не допускает ослабления + `unsafe-inline`/`unsafe-eval`. +- AutoUpdateDocs синхронизировал `docs/FRONTEND.md`, `docs/ARCHITECTURE.md` и + `docs/LOGGING.md` с безопасным DOM, парольными полями и правилами журналирования. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `npm run check` в `frontend/` | 24 проверки: 12 dashboard + 5 auth session + 7 security; все успешны. | +| `npm audit --audit-level=moderate` | `found 0 vulnerabilities`. | +| Полный контейнерный `mvn -Dapi.version=1.44 test` | 200 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `docker build -t magistr-frontend-codex:bug12-34 frontend` | Multi-stage npm/esbuild → Apache завершён успешно. | +| Live container HTTP check | `/` и `/vendor/otel.js` вернули `200`; актуальный CSP и дополнительные headers присутствуют. Временный контейнер остановлен и удалён. | +| `git diff --check` | Успешно. | +| Защищённые файлы | `BUG_REPORT.md` и Flyway V1–V4 не изменялись; SHA-256 совпадают с ранее зафиксированными значениями. | + +### № 15 — инварианты временных слотов + +- CRUD слотов перенесён из `TimeSlotAdminController` в публичный транзакционный + `TimeSlotService`. Сервис проверяет положительный номер пары, существование сетки, + `startTime < endTime`, длительность не меньше минуты и вычисляет `durationMinutes` только + по границам времени, игнорируя одноимённое поле входного DTO. +- Создание блокирует строку целевой сетки, update — строку слота и старую/новую сетки в + стабильном порядке. Внутри сетки запрещены повторный номер и пересечение по условию + `start < other.end AND end > other.start`; касающиеся границами интервалы разрешены. +- Используемый правилом слот нельзя перенести из `DEFAULT`; удаление используемого слота + также отклоняется до SQL-ошибки. Конфликт номера или интервала возвращается как русский + `409 Conflict` через общий `ScheduleConflictException`. +- Раздел инвариантов временных слотов в `V1__init.sql` проверяет seed, добавляет + `btree_gist`, CHECK вычисленной длительности, GiST exclusion constraint с + полуоткрытыми интервалами и триггеры, сериализующие создание правила с переносом слота + или изменением режима сетки. +- В настройках поле минут стало `readonly`, всегда пересчитывается при изменении границ и + больше не отправляется как доверенное значение. AutoUpdateDocs синхронизировал + `docs/API.md`, `docs/BUSINESS_LOGIC.md`, `docs/DATABASE.md` и `docs/FRONTEND.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=TimeSlotServiceTest test` без Docker socket | 6 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 194 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `TimeSlotInvariantMigrationIntegrationTest` | 2 PostgreSQL-сценария базовой V1 добавлены и успешно скомпилированы; запуск Testcontainers из Maven-контейнера среда отклонила из-за запрета передачи Docker socket. | +| Изолированный PostgreSQL 16.3 без томов | Единая V1 применилась с нуля; соседние слоты приняты; overlap (`23P01`), неверная длительность (`23514`) и три нарушения базовой сетки (`23514`) отклонены. | +| Две параллельные пересекающиеся транзакции | Ровно один `COMMIT`; вторая операция отклонена `ex_time_slots_scope_no_overlap`. Одноразовый контейнер удалён. | +| `npm run check` в `frontend/` | 24 проверки, все успешны. | +| `git diff --check` | Успешно. | +| Консолидация Flyway | По прямому разрешению владельца V2–V7 удалены, их итоговая схема включена в V1. | + +### № 16 — инварианты учебных годов и семестров + +- CRUD годов и семестров перенесён из `AcademicCalendarAdminController` в публичный + транзакционный `AcademicPeriodService`. Названия годов нормализуются до duplicate-check, + даты обязательны и упорядочены, а календарные диапазоны трактуются как включительные. +- Учебные годы не могут пересекаться. Семестр должен полностью находиться внутри своего + года, не пересекаться с другим семестром этого года и иметь уникальный тип. Update года + отклоняет границы, которые исключат любой сохранённый семестр; update повторно проверяет + title/type и возвращает безопасный русский `409`, а не JDBC `500`. +- Semester create/update блокируют строку родительского года; update затем перечитывает + строку семестра с `PESSIMISTIC_WRITE`. Инвалидация кэша расписания регистрируется только + после успешного commit. +- Раздел учебных периодов в `V1__init.sql` проверяет seed, добавляет GiST exclusion + constraints годов и семестров с + `daterange(..., '[]')` и два триггера. Блокировка родительской строки в триггере закрывает + гонку между изменением года и конкурентной записью семестра. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`, + `docs/DATABASE.md` и `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=AcademicPeriodServiceTest test` без Docker socket | 8 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 202 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `AcademicPeriodInvariantMigrationIntegrationTest` | 2 PostgreSQL-сценария базовой V1 добавлены и успешно скомпилированы; Testcontainers-прогон из Maven-контейнера недоступен из-за запрета передачи Docker socket. | +| Изолированный PostgreSQL 16.3 без томов | Единая V1 применилась с нуля; соседние периоды приняты; overlap года/семестра (`23P01`), выход семестра и сужение года (`23514`) отклонены. | +| Две параллельные пересекающиеся транзакции годов | Ровно один `COMMIT`; вторая операция отклонена `ex_academic_years_no_overlap`. Одноразовый контейнер удалён. | +| `git diff --check` | Успешно. | +| Текущая baseline V1 | SHA-256 `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. | + +### № 17 — совместимость сохранённых календарных назначений и сеток + +- Добавлен публичный транзакционный `AcademicStructureService`. Обновление графика + блокирует его строку и до мутации проверяет все назначения, старшие курсы сетки, + дисциплины старших семестров и даты сетки относительно нового учебного года. +- Обновление группы блокирует её строку и повторно проверяет год начала обучения, + специальность, профиль и форму для всех назначений. При несовместимости + `ScheduleConflictException` преобразуется в русский `409 Conflict`; сущность и назначения + остаются без изменений. +- Сохранение назначения блокирует группу, график, учебный год и существующую запись, + проверяет совпадение измерений и вычисленный курс. Инвалидация кэша выполняется только + после commit. +- V1 содержит триггеры совместимости назначения, обратной защиты группы и графика, + размерности строк сетки и дисциплин, а также защиты зависимостей при изменении границ + учебного года. Seed назначения теперь сразу связывает календарь по году, специальности, + профилю и форме обучения. +- Добавлены 7 unit-тестов сервиса и 2 Testcontainers-сценария базовой схемы, включая гонку + изменения группы с созданием назначения. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=AcademicStructureServiceTest test` | 7 тестов, 0 failures, 0 errors, BUILD SUCCESS. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` | 209 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Контейнерный `mvn -DskipTests test` | Все 46 test source-файлов, включая DB-интеграционные, успешно скомпилированы. | +| Чистая PostgreSQL 16.3 без томов | Единая V1 применилась; 0 несовместимых seed-назначений; созданы 4 ключевых constraint и 5 dependency-trigger. | +| Прямые DB-изменения | Изменения формы графика/группы, уменьшение `course_count`, строка курса 5 и дисциплина семестра 9 отклонены с `23514`; назначение сохранено. | +| Две конкурентные транзакции | Ровно один `COMMIT`; несовместимое назначение отклонено с `23514`, итоговое состояние согласовано. | +| `git diff --check` | Успешно. | + +### № 18 — историческая модель кафедр преподавателя + +- Добавлен публичный транзакционный `TeacherDepartmentService`. Он разрешает основную и + дополнительную кафедру на целевую дату, пакетно загружает назначения за диапазон и + возвращает исторических преподавателей по периоду действия пользователя и назначения. +- `UserController`, `DepartmentWorkspaceController`, `TeacherSubjectController` и + `WorkloadController` больше не используют `users.department_id` как источник бизнес- + решений. Legacy-поле остаётся зеркалом основной кафедры на текущую дату. +- Будущий перевод закрывает прежний основной период днём перед `validFrom`, но сохраняет + текущую принадлежность до вступления новой записи в силу. Перевод на текущую или прошлую + дату синхронизирует legacy-зеркало. Пользователь и история блокируются до изменения. +- Нагрузка определяет кафедру отдельно на дату каждого занятия: сначала по основной, затем + по дополнительной связи. Перевод внутри диапазона разделяет агрегат одного преподавателя + между кафедрами, а прошлый отчёт не зависит от его текущей кафедры. +- V1 содержит GiST exclusion constraint + `ex_teacher_primary_department_no_overlap`, запрещающий пересечение любых основных + периодов одного преподавателя, включая конкурентную запись из нескольких pod. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`, + `docs/DATABASE.md` и `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 216 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; все 49 test source-файлов скомпилированы. | +| Целевые service/controller tests | 12 тестов: будущий и текущий перевод, архивная история, приоритет основной связи, fallback на дополнительную, доступ к дисциплине и разделение нагрузки; все успешны. | +| Чистая PostgreSQL 16.3 без томов | Точная текущая V1 применилась с нуля; constraint `ex_teacher_primary_department_no_overlap` создан. | +| Прямые DB-изменения | Соседние основные периоды и дополнительное назначение приняты; пересекающийся основной период отклонён с `23P01`. | +| Две конкурентные транзакции | Ровно один `COMMIT`; вторая пересекающаяся основная запись отклонена с `23P01`. | +| `npm run check` в `frontend/` | 3 test suite и синтаксические проверки завершены успешно. | +| `docker compose config --quiet` | Успешно. | +| `git diff --check` | Успешно. | +| Текущая baseline V1 | SHA-256 `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. | + +### № 19 — ограничение кафедрального доступа к workload + +- `WorkloadController` вычисляет разрешённый `departmentId` до любого вызова + `ScheduleQueryService`. Для роли `DEPARTMENT` отсутствие фильтра означает кафедру из + `AuthContext`, а несовпадающий ID отклоняется с русским `403 Forbidden`. +- Единое правило применено к отчётам преподавателей, аудиторий, кафедр, временных слотов и + свободным аудиториям. Последний endpoint не принимает `departmentId`, но его внутренняя + выборка для кафедры также больше не является глобальной. +- `ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER` сохраняют явно документированное право на + глобальный workload или выбор конкретной кафедры. Отсутствующий `AuthContext` обрабатывается + fail-closed с русским `401`. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md` и + `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=WorkloadControllerTest test` без Docker socket | 5 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Межкафедральный сценарий | Чужой `departmentId` даёт `403` до вызова поиска; отсутствие ID и все пять endpoints используют кафедру из `AuthContext`. | +| Привилегированный сценарий | `EDUCATION_OFFICE` сохраняет глобальный scope без `departmentId`. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 219 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `docker compose config --quiet` | Успешно. | +| `git diff --check` | Успешно. | + +### № 20 — workload без интерактивного лимита 50 групп + +- В `ScheduleQueryService` выделен узкий публичный `searchForAggregation(departmentId, + timeSlotId, startDate, endDate)`. Он не принимает остальные UI-фильтры и поэтому не + становится вторым общим поисковым API. +- Обычный `search()` продолжает отклонять широкий пользовательский запрос, если тот затрагивает + больше 50 активных групп. Оба пути используют единый внутренний алгоритм проверки диапазона, + выбора групп, генерации, применения одного снимка overrides, фильтрации и дедупликации. +- Все workload-отчёты и free-classrooms переведены на aggregate-путь. Кафедральный scope из + №19 вычисляется до генерации и сохранён без изменений. +- Параметризованный тест доказывает полный вызов генератора для 51 и 100 активных групп; + отдельный regression-test сохраняет отказ интерактивного поиска на 51-й группе. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md` и + `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=ScheduleQueryServiceTest,WorkloadControllerTest test` | 9 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Aggregate 51/100 групп | Все активные группы переданы генератору, overrides прочитаны один раз, исключение лимита не возникает. | +| Интерактивный regression | Обычный широкий `search()` с 51 активной группой по-прежнему отклонён с просьбой уточнить scope. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 221 тест, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `docker compose config --quiet` | Успешно. | +| `git diff --check` | Успешно. | + +### № 21 — безопасный импорт дисциплин кафедры + +- Импорт вынесен из `DepartmentWorkspaceController` в транзакционный + `SubjectImportService`. Весь payload нормализуется и дедуплицируется по названию без учёта + регистра до мутации; повтор внутри payload обрабатывается один раз. +- Глобальная уникальность названия, уже заявленная в бизнес-документации, сохранена. Если + запись принадлежит текущей кафедре, повторный импорт обновляет её код, восстанавливает из + архива и сохраняет прежний ID. Совпадение с другой кафедрой даёт русский `409 Conflict`, + не меняя владельца или код чужой записи. +- В V1 прежний case-sensitive `UNIQUE(name)` заменён функциональным уникальным индексом + `uq_subjects_name_ci` на `lower(name)`. Он закрывает регистровые дубли и гонку импортов + разных кафедр; admin-create также проверяет имя без учёта регистра. +- Ошибка уникальности при `saveAllAndFlush` преобразуется в безопасный доменный конфликт. + Новых миграционных файлов не создано. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`, + `docs/DATABASE.md` и `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=SubjectImportServiceTest,DepartmentWorkspaceControllerTest test` | 7 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Повторный собственный импорт | Два последовательных импорта возвращают тот же объект/ID, обновляют код и восстанавливают архивную запись. | +| Совпадение чужой кафедры | Получен доменный `409`; чужие `departmentId` и код не изменены, `saveAllAndFlush` не вызван. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 225 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 51 test source-файл скомпилирован. | +| Чистая PostgreSQL 16.3 без томов | Точная текущая V1 применилась с нуля; `информатика` отклонена как дубль seed `Информатика` по `uq_subjects_name_ci` (`23505`). | +| Две конкурентные транзакции | Первая запись `Теория систем` закоммичена; параллельная `теория систем` после ожидания отклонена `uq_subjects_name_ci` (`23505`). | +| `docker compose config --quiet` | Успешно. | +| `git diff --check` | Успешно. | +| Текущая baseline V1 | SHA-256 `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. | + +### № 22 — согласованная матрица прав учебного отдела + +- `SpecialityController` сохранил class-level `ADMIN` для всех write-операций, но обязательные + GET специальностей, общего списка профилей и профилей специальности явно разрешены + `EDUCATION_OFFICE`. +- `EducationFormController` разрешает `ADMIN` и `EDUCATION_OFFICE` создавать и удалять формы + обучения. Широкий read-only GET для остальных ранее разрешённых ролей сохранён method-level + аннотацией; защита используемых группами/графиками форм не ослаблена. +- Дубли матриц `ROLE_NAVIGATION` и `ROLE_TABS` удалены. Основной admin SPA и settings SPA + используют единый `frontend/admin/js/role-capabilities.js`, где для учебного отдела явно + перечислены календарный график, временные слоты и формы обучения. +- MockMvc-тест проходит через реальный `AuthorizationInterceptor`: GET specialties/profiles и + GET/POST/DELETE education forms возвращают `200`, а POST специальности остаётся `403`. +- Frontend-тест импортирует матрицу как ES-модуль, проверяет capabilities учебного отдела и + отсутствие локальных дубликатов в двух entrypoint-файлах. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/BUSINESS_LOGIC.md`, + `docs/FRONTEND.md` и `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| Контейнерный `mvn -Dtest=EducationOfficeRoleMatrixTest test` | 2 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| MockMvc role matrix | Штатная инициализация справочников календаря и CRUD форм обучения не получают `403`; изменение специальности учебным отделом отклонено. | +| `npm run check` в `frontend/` | Синтаксис entrypoints и 3 test suite, включая единую role matrix, завершены успешно. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 227 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 52 test source-файла скомпилированы. | +| `docker compose config --quiet` | Успешно. | +| `git diff --check` | Успешно. | + +### № 23 — batch-снимок генерации расписания + +- `ScheduleQueryService` больше не вызывает генератор отдельно для каждой группы: обычный + поиск, workload-агрегаты и построение полного базового дня передают весь набор групп в + `ScheduleGeneratorService.buildScheduleForGroups()`. +- `AcademicDateService.ScheduleSnapshot` одним набором запросов загружает пересекающиеся + семестры, назначения календарей групп и `academic_calendar_days` от начала затронутого + семестра до конца диапазона. Внутренние проверки semester/assignment/activity работают по + неизменяемым lookup-картам. +- Правила группы и преподавателя получили batch-запросы по набору семестров. Генератор + индексирует их по `groupId + semesterId`, поэтому репозиторий не вызывается из циклов дат, + правил и групп. +- Ручные назначения сетки звонков, weekday scopes и временные слоты также читаются один раз + на диапазон; фактический слот выбирается по `date/scope/orderNumber` без дополнительных + запросов на каждое занятие. +- `ScheduleGenerationQueryCountTest` обрабатывает 120 включительных дат, 30 групп и 20 + правил. Тест измеряет ровно 7 чтений репозиториев, запрещает старые point-методы и + одновременно подтверждает 30 корректных занятий с эффективной weekday-сеткой. +- AutoUpdateDocs синхронизировал `docs/BUSINESS_LOGIC.md` и `docs/ARCHITECTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `ScheduleGenerationQueryCountTest` | 1 тест, 0 failures, 0 errors, 0 skipped; 120 дат, 30 групп, 20 правил, 7 repository reads. | +| `ScheduleQueryServiceTest,ScheduleQueryServiceTeacherOverrideTest` | 9 тестов, 0 failures, 0 errors, 0 skipped; batch-путь и overrides сохранены. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 228 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 53 test source-файла скомпилированы. | + +### № 24 — tenant-aware cleanup refresh-сессий + +- `RefreshTokenCleanupJob` получает атомарный snapshot доменов из + `TenantRoutingDataSource`, устанавливает `TenantContext` до вызова repository proxy и + очищает каждую tenant-БД отдельно. Исходный tenant потока восстанавливается в `finally`. +- По умолчанию истёкшие и отозванные строки сохраняются 30 дней для аудита. Retention, + включение job, размер пачки, максимум пачек, interval и initial delay задаются через + `JWT_REFRESH_CLEANUP_*`. +- Native batch-delete выбирает только записи с `expires_at < cutoff` либо старым + `revoked_at`, использует `FOR UPDATE SKIP LOCKED` и отдельную транзакцию repository-метода. + Два pod разбирают непересекающиеся строки; повторная очистка безопасно возвращает 0. +- Локальный `AtomicBoolean` не допускает наложения двух запусков внутри одного pod, а лимит + пачек ограничивает длительность одного прохода. Ошибка одной tenant-БД логируется по-русски + без технического текста и не останавливает очистку остальных. +- В V1 добавлены частичный индекс по `revoked_at` и индекс по `expires_at`; новых файлов + миграций не создавалось. Текущий SHA-256 V1: + `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. +- AutoUpdateDocs синхронизировал `docs/DATABASE.md`, `docs/ARCHITECTURE.md` и + `docs/INFRASTRUCTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `RefreshTokenCleanupJobTest,RefreshTokenServiceTest` | 7 тестов, 0 failures, 0 errors, 0 skipped; два job, retention, idempotency, overlap guard и disabled mode. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 231 тест, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 54 test source-файла скомпилированы. | +| Точная V1 на чистой PostgreSQL 16.3 | Схема и два cleanup-индекса созданы без ошибок. | +| Две конкурентные PostgreSQL-транзакции | При незавершённой первой транзакции обе удалили разные пачки по 3 строки; повтор удалил 0, fresh-expired/fresh-revoked/active сохранены. | +| Testcontainers через Maven-контейнер | Не запускался: среда отклонила проброс `/var/run/docker.sock`; PostgreSQL-специфичный SQL проверен безопасным отдельным контейнером без socket mount. | + +### № 25 — безопасное преобразование DB/JDBC-ошибок + +- `GlobalExceptionHandler` перехватывает `DataIntegrityViolationException` до общего `500` + и делегирует определение безопасного ответа в `DatabaseConstraintViolationMapper`. +- Именованные CHECK-ограничения текущей V1 преобразуются в русские `400`; UNIQUE, FK и + GiST exclusion constraints — в русские `409`. Неизвестный SQLState класса integrity + получает обобщённый `409` без текста драйвера. +- Технические `constraint` и SQLState доступны только в структурированном журнале. + JDBC/SQL message, stack trace, запросы и значения параметров в HTTP JSON не включаются. +- Локальные `catch Exception`, которые формировали тело ответа через `getMessage()`, удалены: + persistence-ошибки доходят до общего handler, а контролируемые доменные сообщения сохранены. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и + `docs/DEVELOPMENT.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `GlobalExceptionHandlerDataIntegrityTest` | 4 теста, 0 failures, 0 errors, 0 skipped; известные CHECK/UNIQUE и неизвестные нарушения не раскрывают constraint, JDBC URL, SQL или пароль. | +| Связанные controller tests | `ScheduleOverrideControllerTest` и `ScheduleRuleAdminControllerTest`: 5 тестов, 0 failures, 0 errors. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 235 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 55 test source-файлов скомпилированы. | +| `docker compose config --quiet` | Успешно. | +| `git diff --check` | Успешно. | +| Каталог Flyway | Содержит только `V1__init.sql`; SHA-256 не изменился: `5f9326b545d14a8afb3c75de9e59ea9a35b36ed02fe5a8a822e8430fc3e972cd`. | + +### № 26 — самодостаточный локальный Docker Compose + +- Compose больше не требует заранее созданную внешнюю сеть: bridge-сеть `magistr`, DNS + сервисов и именованный том `postgres_data` создаются самим проектом. +- Frontend публикует `${HTTP_PORT:-80}` и через Apache `mod_proxy` направляет same-origin + `/api` и `/actuator/health` во внутренний backend. Исходный `Host` сохраняется, поэтому + локальный запрос выбирает tenant `default`; backend и PostgreSQL наружу не публикуются. +- `${POSTGRES_DB:-app_db}` одновременно задаёт создаваемую БД и + `SPRING_DATASOURCE_URL`. Backend получает обязательные `POSTGRES_PASSWORD`, `JWT_SECRET`, + JWT TTL и cleanup-параметры из `.env`; безопасный шаблон добавлен в `.env.example`. +- PostgreSQL закреплён на версии 16.3 и хранит данные в named volume. Backend имеет + liveness healthcheck, а frontend запускается после его готовности; `up --wait` теперь + подтверждает не только старт процесса, но и рабочий Spring context. +- Live startup обнаружил, что Spring не мог выбрать production-конструкторы + `TenantDatabaseHealthMonitor` и `RefreshTokenCleanupJob`, хотя unit-тесты создавали их + вручную. Оба конструктора явно помечены для injection; backend стабильно выходит в + `Started Application`. +- Скачивание mutable OpenTelemetry `latest`, блокировавшее чистую сборку, заменено + фиксированным Maven-артефактом `2.28.1` с обязательной SHA-256 проверкой. Локальный OTel + SDK по умолчанию отключён, так как Collector не входит в Compose. +- AutoUpdateDocs синхронизировал `docs/README.md` и `docs/INFRASTRUCTURE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `docker compose up -d --build --wait` | PostgreSQL, backend healthcheck и frontend готовы; внутренняя сеть и `magistr_postgres_data` созданы автоматически. | +| Live HTTP через frontend proxy | `/` → `200`, `/actuator/health/liveness` → `200`, защищённый `/api/departments` → ожидаемый `401`. | +| Публикация порта | Порт 80 уже занят пользовательским Caddy; эквивалентная проверка успешно выполнена через документированный `HTTP_PORT=18080`, чужой сервис не останавливался. | +| PostgreSQL mount | Docker inspect: named volume `magistr_postgres_data` смонтирован в `/var/lib/postgresql/data`; backend и DB не имеют host ports. | +| Java Agent supply chain | Maven Central `opentelemetry-javaagent:2.28.1`, SHA-256 `faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589`, build check `OK`. | +| Контейнерный `mvn -Dtest=!*IntegrationTest test` без Docker socket | 235 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `docker compose config --quiet` и `git diff --check` | Успешно. | +| Каталог Flyway | По-прежнему содержит только `V1__init.sql`; новых миграций нет. | + +### № 27 — обязательные CI/CD gates и immutable rollout + +- `.gitea/workflows/docker-build.yaml` получил обязательный `checks` job: полный backend + non-integration suite, frontend `npm ci && npm run check`, Compose validation и shell-тест + immutable deployment. Build/push jobs имеют `needs: checks`, поэтому непроверенный commit + не публикуется и не разворачивается. +- Metadata публикует полный `sha-` и release tag без `latest`. Оба build job отдают + registry digest, а deploy формирует ссылки `image@sha256:...`; release tag больше не + перезапускает старый `:main`. +- Workflow-wide `magistr-production` concurrency lock сериализует production-запуски. + `kubectl v1.33.12` загружается с официального CDN и проходит проверку закреплённого + SHA-256 до установки. +- `scripts/deploy-images.sh` отклоняет любые tag-only ссылки, сохраняет текущие backend и + frontend image refs, применяет оба новых digest и ждёт rollout. Ошибка `set image` или + readiness запускает восстановление обоих предыдущих образов и повторную проверку. +- `scripts/test-deploy-images.sh` использует fake kubectl для трёх сценариев: tag отклонён, + успешные digests применены, failed backend rollout вернул оба предыдущих digest. +- Во внешних `../k8s/backend.yaml` и `frontend.yaml` удалены `:main`; нулевой digest-sentinel + не может случайно выбрать mutable image. `../k8s/deploy.sh` требует два реальных digest и + подставляет их локально до apply. Backend probes переведены на HTTP liveness/readiness, + а ConfigMap теперь задаёт корректные `TENANTS_CONFIG_PATH` и + `TENANTS_CONFIG_REQUIRED=true`. +- AutoUpdateDocs синхронизировал `docs/INFRASTRUCTURE.md` и `../k8s/README.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `bash scripts/test-deploy-images.sh` | Mutable tag отклонён; success rollout завершён; failed rollout восстановил backend и frontend, тест завершён успешно. | +| Workflow static validation | Ruby YAML parser — успешно; отсутствуют `:main`, `stable.txt` и `rollout restart`; build jobs зависят от checks и экспортируют digest. | +| `bash -n` | `scripts/deploy-images.sh`, `scripts/test-deploy-images.sh` и `../k8s/deploy.sh` синтаксически корректны. | +| `K8S_DIR=../k8s bash scripts/check-production-secrets.sh` | Успешно. | +| `kubectl kustomize ../k8s >/dev/null` | Успешно. | +| `docker compose config --quiet` и `git diff --check` | Успешно. | +| Изменённые внешние файлы | `../k8s/backend.yaml`, `frontend.yaml`, `config.yaml`, `deploy.sh`, `README.md`; они находятся вне Git-корня `magistr` и отдельно проверены. | +| Production rollout | Не выполнялся: это внешняя необратимая операция, требующая отдельных полномочий и реальных build digests. | + +### № 28 — общий PostgreSQL rate limit входа + +- `LoginRateLimitService` выполняет создание/блокировку строки, bcrypt-проверку, изменение + счётчика и запись аудита в одной транзакции. Ключ состоит из tenant, NFKC-нормализованного + username в нижнем регистре и проверенного IP. `INSERT ... ON CONFLICT DO NOTHING` вместе + с `PESSIMISTIC_WRITE` сериализует первые и последующие попытки на разных backend-pod. +- Пять отказов за 15 минут по умолчанию дают блокировку на 60 секунд. Следующая ошибка после + окончания блокировки удваивает срок до максимума 15 минут. Controller возвращает `429` + с округлённым вверх `Retry-After`; успешный вход удаляет состояние. +- Неизвестный, архивный и ошибочный пользователь проходят одинаковый bcrypt-путь и получают + одинаковый `401`. В production-журнал попадает только SHA-256 fingerprint нормализованного + имени; пароль и признак существования пользователя не сохраняются и не логируются. +- `ClientIpResolver` игнорирует `X-Forwarded-For` от недоверенного непосредственного + источника, ограничивает длину/число hops и разбирает допустимую цепочку справа налево. + Compose доверяет внутренней Docker-сети, K3s ConfigMap — стандартной pod-сети Traefik; + нестандартный CIDR документирован как обязательная настройка оператора. +- В V1 добавлены `auth_login_rate_limits`, `auth_login_attempt_audit`, уникальный ключ, + CHECK и cleanup-индексы. `LoginAttemptAuditCleanupJob` раз в сутки tenant-aware пачками + `FOR UPDATE SKIP LOCKED` удаляет audit и неактивные счётчики старше 90 дней. Новых файлов + миграций не создано; текущий SHA-256 V1: + `250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md`, + `docs/DATABASE.md`, `docs/INFRASTRUCTURE.md` и внешний `../k8s/README.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `ClientIpResolverTest`, `LoginRateLimitServiceTest`, `AuthControllerTest` | 10 тестов, 0 failures; trusted chain, spoofed header, uniform reject, progressive block и `Retry-After`. | +| `LoginRateLimitConcurrencyIntegrationTest` | 2 теста, 0 failures; две отдельные service instance одной PostgreSQL-БД атомарно получили `REJECTED` + `BLOCKED`, failure count 2; cleanup сохранил fresh/active rows. | +| Полный контейнерный `mvn test` | 266 тестов: 263 успешны; все 12 тестов № 28 прошли. Три ранее добавленных конкурентных migration-теста нестабильны: два получили допустимый PostgreSQL `40P01` вместо ожидаемого только `23P01`, один запуск не воспроизвёл гонку. Стабилизация оставлена в финальном регрессионном аудите. | +| Чистая PostgreSQL 16.3 | Единственная V1 многократно применена Flyway без ошибок; обе новые таблицы и индексы созданы. | +| Compose, Kustomize, secret-scan, `git diff --check` | Успешно. | +| Изменённые внешние файлы | `../k8s/config.yaml`, `../k8s/README.md`; каталог вне Git-корня и проверен отдельно. | + ## Точка продолжения -Текущий этап: **№ 12 — access JWT и удалённый JavaScript в одном origin**. +Текущий этап: **№ 29 — единая временная зона бизнес-дат**. Следующая операция: -1. инвентаризировать `localStorage`/`sessionStorage`, refresh-cookie, удалённые `import()` и - inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации; -2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly - refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload; -3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без - `unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки; -4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP - перед AutoUpdateDocs и изменением статуса № 12. +1. построить graphify-контекст всех `LocalDate.now()`/`LocalDateTime.now()` и frontend + `toISOString()` в бизнес-операциях; +2. внедрить единый `Clock`/`ZoneId` Europe/Moscow без изменения абсолютных timestamp; +3. зафиксировать `TZ`/`user.timezone` в Compose и Kubernetes; +4. проверить границы московской полуночи и date-only форматирование без UTC-сдвига. diff --git a/backend/Dockerfile b/backend/Dockerfile index 23e1969..995c9a6 100755 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,15 @@ COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B -RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar + +ARG OTEL_JAVAAGENT_VERSION=2.28.1 +ARG OTEL_JAVAAGENT_SHA256=faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589 +RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \ + -Dartifact=io.opentelemetry.javaagent:opentelemetry-javaagent:${OTEL_JAVAAGENT_VERSION} \ + -DoutputDirectory=/app \ + -Dmdep.stripVersion=true \ + -B \ + && echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c - FROM eclipse-temurin:17-jre-alpine diff --git a/backend/src/main/java/com/magistr/app/config/auth/ClientIpResolver.java b/backend/src/main/java/com/magistr/app/config/auth/ClientIpResolver.java new file mode 100644 index 0000000..fd45f7e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/ClientIpResolver.java @@ -0,0 +1,146 @@ +package com.magistr.app.config.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy. + */ +@Component +public class ClientIpResolver { + + private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class); + private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048; + private static final int MAX_PROXY_HOPS = 20; + private static final String UNKNOWN_ADDRESS = "неизвестен"; + + private final List trustedProxyRanges; + + public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) { + this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs); + } + + public String resolve(HttpServletRequest request) { + Optional remoteAddress = parseAddress(request.getRemoteAddr()); + String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS); + if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) { + return fallback; + } + + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded == null || forwarded.isBlank()) { + return fallback; + } + if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) { + log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина"); + return fallback; + } + + String[] hops = forwarded.split(",", -1); + if (hops.length > MAX_PROXY_HOPS) { + log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy"); + return fallback; + } + + InetAddress leftmost = null; + for (int index = hops.length - 1; index >= 0; index--) { + Optional hop = parseAddress(hops[index].trim()); + if (hop.isEmpty()) { + log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес"); + return fallback; + } + leftmost = hop.get(); + if (!isTrusted(hop.get())) { + return hop.get().getHostAddress(); + } + } + return leftmost == null ? fallback : leftmost.getHostAddress(); + } + + private boolean isTrusted(InetAddress address) { + return trustedProxyRanges.stream().anyMatch(range -> range.contains(address)); + } + + private List parseTrustedRanges(String rawRanges) { + if (rawRanges == null || rawRanges.isBlank()) { + return List.of(); + } + List ranges = new ArrayList<>(); + for (String rawRange : rawRanges.split(",")) { + String value = rawRange.trim(); + if (!value.isEmpty()) { + ranges.add(CidrRange.parse(value)); + } + } + return List.copyOf(ranges); + } + + private static Optional parseAddress(String rawAddress) { + if (rawAddress == null) { + return Optional.empty(); + } + String value = rawAddress.trim().toLowerCase(Locale.ROOT); + if (value.startsWith("[") && value.endsWith("]")) { + value = value.substring(1, value.length() - 1); + } + if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) { + return Optional.empty(); + } + try { + return Optional.of(InetAddress.getByName(value)); + } catch (UnknownHostException ignored) { + return Optional.empty(); + } + } + + private record CidrRange(byte[] network, int prefixLength) { + + static CidrRange parse(String value) { + String[] parts = value.split("/", -1); + Optional address = ClientIpResolver.parseAddress(parts[0]); + if (address.isEmpty()) { + throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value); + } + int addressBits = address.get().getAddress().length * 8; + int prefix; + try { + prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]); + } catch (NumberFormatException exception) { + throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception); + } + if (parts.length > 2 || prefix < 0 || prefix > addressBits) { + throw new IllegalStateException("Некорректная маска доверенного proxy: " + value); + } + return new CidrRange(mask(address.get().getAddress(), prefix), prefix); + } + + boolean contains(InetAddress candidate) { + byte[] candidateBytes = candidate.getAddress(); + return candidateBytes.length == network.length + && Arrays.equals(mask(candidateBytes, prefixLength), network); + } + + private static byte[] mask(byte[] address, int prefix) { + byte[] masked = Arrays.copyOf(address, address.length); + int fullBytes = prefix / 8; + int remainingBits = prefix % 8; + if (fullBytes < masked.length && remainingBits > 0) { + masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits)); + fullBytes++; + } + Arrays.fill(masked, fullBytes, masked.length, (byte) 0); + return masked; + } + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/LoginAttemptAuditCleanupJob.java b/backend/src/main/java/com/magistr/app/config/auth/LoginAttemptAuditCleanupJob.java new file mode 100644 index 0000000..fa35fcd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/LoginAttemptAuditCleanupJob.java @@ -0,0 +1,167 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.repository.AuthLoginAttemptAuditRepository; +import com.magistr.app.repository.AuthLoginRateLimitRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */ +@Component +public class LoginAttemptAuditCleanupJob { + + private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class); + + private final TenantRoutingDataSource routingDataSource; + private final AuthLoginAttemptAuditRepository auditRepository; + private final AuthLoginRateLimitRepository rateLimitRepository; + private final boolean enabled; + private final Duration retention; + private final int batchSize; + private final int maxBatchesPerTenant; + private final Clock clock; + private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false); + + @Autowired + public LoginAttemptAuditCleanupJob( + TenantRoutingDataSource routingDataSource, + AuthLoginAttemptAuditRepository auditRepository, + AuthLoginRateLimitRepository rateLimitRepository, + @Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled, + @Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention, + @Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize, + @Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) { + this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention, + batchSize, maxBatchesPerTenant, Clock.systemUTC()); + } + + LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource, + AuthLoginAttemptAuditRepository auditRepository, + AuthLoginRateLimitRepository rateLimitRepository, + boolean enabled, + Duration retention, + int batchSize, + int maxBatchesPerTenant, + Clock clock) { + this.routingDataSource = Objects.requireNonNull(routingDataSource); + this.auditRepository = Objects.requireNonNull(auditRepository); + this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository); + if (retention == null || retention.isZero() || retention.isNegative()) { + throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным"); + } + if (batchSize < 1 || batchSize > 10_000) { + throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000"); + } + if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) { + throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000"); + } + this.enabled = enabled; + this.retention = retention; + this.batchSize = batchSize; + this.maxBatchesPerTenant = maxBatchesPerTenant; + this.clock = Objects.requireNonNull(clock); + } + + @Scheduled( + fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}", + initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}" + ) + public void cleanupScheduled() { + if (enabled) { + cleanupNow(); + } + } + + public CleanupSummary cleanupNow() { + if (!cleanupInProgress.compareAndSet(false, true)) { + return new CleanupSummary(0, 0, 0, 0, true); + } + String previousTenant = TenantContext.getCurrentTenant(); + try { + TenantContext.clear(); + Map tenants = routingDataSource.snapshotTenantConfigs(); + Instant now = clock.instant(); + Instant cutoff = now.minus(retention); + int deletedAudits = 0; + int deletedLimits = 0; + int failedTenants = 0; + for (String tenant : tenants.keySet()) { + TenantContext.setCurrentTenant(tenant); + try { + deletedAudits += cleanupAudit(tenant, cutoff); + deletedLimits += cleanupLimits(tenant, cutoff, now); + } catch (RuntimeException failure) { + failedTenants++; + log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}", + tenant, failure.getClass().getSimpleName()); + log.debug("Технические детали очистки аудита входа", failure); + } finally { + TenantContext.clear(); + } + } + if (deletedAudits + deletedLimits > 0) { + log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}", + tenants.size(), deletedAudits, deletedLimits, failedTenants); + } + return new CleanupSummary( + tenants.size(), deletedAudits, deletedLimits, failedTenants, false + ); + } finally { + restoreTenant(previousTenant); + cleanupInProgress.set(false); + } + } + + private int cleanupAudit(String tenant, Instant cutoff) { + int deletedTotal = 0; + for (int batch = 0; batch < maxBatchesPerTenant; batch++) { + int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize); + deletedTotal += deleted; + if (deleted < batchSize) { + return deletedTotal; + } + } + log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant); + return deletedTotal; + } + + private int cleanupLimits(String tenant, Instant cutoff, Instant now) { + int deletedTotal = 0; + for (int batch = 0; batch < maxBatchesPerTenant; batch++) { + int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize); + deletedTotal += deleted; + if (deleted < batchSize) { + return deletedTotal; + } + } + log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant); + return deletedTotal; + } + + private void restoreTenant(String tenant) { + if (tenant == null || tenant.isBlank()) { + TenantContext.clear(); + } else { + TenantContext.setCurrentTenant(tenant); + } + } + + public record CleanupSummary(int tenantCount, + int auditDeletedCount, + int rateLimitDeletedCount, + int failedTenantCount, + boolean skippedBecauseAlreadyRunning) { + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/LoginRateLimitService.java b/backend/src/main/java/com/magistr/app/config/auth/LoginRateLimitService.java new file mode 100644 index 0000000..177b142 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/LoginRateLimitService.java @@ -0,0 +1,226 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.AuthLoginAttemptAudit; +import com.magistr.app.model.AuthLoginRateLimit; +import com.magistr.app.model.User; +import com.magistr.app.repository.AuthLoginAttemptAuditRepository; +import com.magistr.app.repository.AuthLoginRateLimitRepository; +import com.magistr.app.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +@Service +public class LoginRateLimitService { + + private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class); + private static final String OUTCOME_FAILURE = "FAILURE"; + private static final String OUTCOME_BLOCKED = "BLOCKED"; + + private final AuthLoginRateLimitRepository rateLimitRepository; + private final AuthLoginAttemptAuditRepository auditRepository; + private final UserRepository userRepository; + private final BCryptPasswordEncoder passwordEncoder; + private final LoginSecurityProperties properties; + private final Clock clock; + private final String dummyPasswordHash; + + @Autowired + public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository, + AuthLoginAttemptAuditRepository auditRepository, + UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + LoginSecurityProperties properties) { + this(rateLimitRepository, auditRepository, userRepository, passwordEncoder, + properties, Clock.systemUTC()); + } + + LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository, + AuthLoginAttemptAuditRepository auditRepository, + UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + LoginSecurityProperties properties, + Clock clock) { + this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository); + this.auditRepository = Objects.requireNonNull(auditRepository); + this.userRepository = Objects.requireNonNull(userRepository); + this.passwordEncoder = Objects.requireNonNull(passwordEncoder); + this.properties = Objects.requireNonNull(properties); + this.clock = Objects.requireNonNull(clock); + this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only"); + } + + /** + * Блокировка строки удерживается до завершения проверки пароля и записи результата. + * Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток. + */ + @Transactional + public AuthenticationDecision authenticate(String tenant, + String username, + String rawPassword, + String clientIp) { + String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant"); + String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя"); + String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен"); + Instant now = clock.instant(); + + rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now); + AuthLoginRateLimit state = rateLimitRepository + .findForUpdate(safeTenant, normalizedUsername, safeClientIp) + .orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа")); + + if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) { + long retryAfter = retryAfterSeconds(now, state.getBlockedUntil()); + audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter); + logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter); + return AuthenticationDecision.blocked(retryAfter); + } + + if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) { + state.setFailureCount(0); + state.setWindowStartedAt(now); + state.setBlockedUntil(null); + } + + Optional user = username == null ? Optional.empty() : userRepository.findByUsername(username); + String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash); + boolean passwordMatches = passwordEncoder.matches( + rawPassword == null ? "" : rawPassword, + storedHash + ); + boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord(); + + if (authenticated) { + rateLimitRepository.delete(state); + return AuthenticationDecision.authenticated(user.get()); + } + + int failureCount = state.getFailureCount() + 1; + state.setFailureCount(failureCount); + state.setLastFailureAt(now); + state.setUpdatedAt(now); + + if (failureCount >= properties.getMaxFailures()) { + Duration blockDuration = progressiveBlockDuration(failureCount); + Instant blockedUntil = now.plus(blockDuration); + state.setBlockedUntil(blockedUntil); + long retryAfter = retryAfterSeconds(now, blockedUntil); + audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter); + logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter); + return AuthenticationDecision.blocked(retryAfter); + } + + audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null); + logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null); + return AuthenticationDecision.rejected(); + } + + private Duration progressiveBlockDuration(int failureCount) { + int exponent = Math.min(failureCount - properties.getMaxFailures(), 30); + long multiplier = 1L << exponent; + Duration candidate; + try { + candidate = properties.getBaseBlockDuration().multipliedBy(multiplier); + } catch (ArithmeticException overflow) { + return properties.getMaxBlockDuration(); + } + return candidate.compareTo(properties.getMaxBlockDuration()) > 0 + ? properties.getMaxBlockDuration() + : candidate; + } + + private void audit(String tenant, + String username, + String clientIp, + String outcome, + Instant occurredAt, + Long retryAfterSeconds) { + Integer retryAfter = retryAfterSeconds == null + ? null + : Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE)); + auditRepository.save(new AuthLoginAttemptAudit( + tenant, username, clientIp, outcome, occurredAt, retryAfter + )); + } + + private void logDenied(String tenant, + String username, + String clientIp, + String outcome, + Long retryAfterSeconds) { + log.warn( + "Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}", + tenant, + fingerprint(username), + clientIp, + outcome, + retryAfterSeconds + ); + } + + private String normalizeAndLimit(String value, int maxLength, String fallback) { + String bounded = value == null + ? fallback + : value.substring(0, Math.min(value.length(), maxLength * 4)); + String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC) + .replaceAll("\\p{C}", "") + .strip() + .toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + normalized = fallback; + } + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } + + private long retryAfterSeconds(Instant now, Instant blockedUntil) { + Duration remaining = Duration.between(now, blockedUntil); + long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0); + return Math.max(1, seconds); + } + + private String fingerprint(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest, 0, 8); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception); + } + } + + public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) { + + static AuthenticationDecision authenticated(User user) { + return new AuthenticationDecision(Status.AUTHENTICATED, user, 0); + } + + static AuthenticationDecision rejected() { + return new AuthenticationDecision(Status.REJECTED, null, 0); + } + + static AuthenticationDecision blocked(long retryAfterSeconds) { + return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds); + } + + public enum Status { + AUTHENTICATED, + REJECTED, + BLOCKED + } + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/LoginSecurityProperties.java b/backend/src/main/java/com/magistr/app/config/auth/LoginSecurityProperties.java new file mode 100644 index 0000000..de8d9ae --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/LoginSecurityProperties.java @@ -0,0 +1,70 @@ +package com.magistr.app.config.auth; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@ConfigurationProperties(prefix = "app.login-security") +public class LoginSecurityProperties { + + private int maxFailures = 5; + private Duration attemptWindow = Duration.ofMinutes(15); + private Duration baseBlockDuration = Duration.ofMinutes(1); + private Duration maxBlockDuration = Duration.ofMinutes(15); + + @PostConstruct + void validate() { + if (maxFailures < 2 || maxFailures > 100) { + throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100"); + } + requirePositive(attemptWindow, "Окно учёта попыток входа"); + requirePositive(baseBlockDuration, "Начальный срок блокировки входа"); + requirePositive(maxBlockDuration, "Максимальный срок блокировки входа"); + if (maxBlockDuration.compareTo(baseBlockDuration) < 0) { + throw new IllegalStateException( + "Максимальный срок блокировки входа не может быть меньше начального" + ); + } + } + + private void requirePositive(Duration duration, String fieldName) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalStateException(fieldName + " должен быть положительным"); + } + } + + public int getMaxFailures() { + return maxFailures; + } + + public void setMaxFailures(int maxFailures) { + this.maxFailures = maxFailures; + } + + public Duration getAttemptWindow() { + return attemptWindow; + } + + public void setAttemptWindow(Duration attemptWindow) { + this.attemptWindow = attemptWindow; + } + + public Duration getBaseBlockDuration() { + return baseBlockDuration; + } + + public void setBaseBlockDuration(Duration baseBlockDuration) { + this.baseBlockDuration = baseBlockDuration; + } + + public Duration getMaxBlockDuration() { + return maxBlockDuration; + } + + public void setMaxBlockDuration(Duration maxBlockDuration) { + this.maxBlockDuration = maxBlockDuration; + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenCleanupJob.java b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenCleanupJob.java new file mode 100644 index 0000000..aea3be6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenCleanupJob.java @@ -0,0 +1,163 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.repository.AuthRefreshTokenRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД. + */ +@Component +public class RefreshTokenCleanupJob { + + private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class); + + private final TenantRoutingDataSource routingDataSource; + private final AuthRefreshTokenRepository repository; + private final boolean enabled; + private final Duration retention; + private final int batchSize; + private final int maxBatchesPerTenant; + private final Clock clock; + private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false); + + @Autowired + public RefreshTokenCleanupJob( + TenantRoutingDataSource routingDataSource, + AuthRefreshTokenRepository repository, + @Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled, + @Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention, + @Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize, + @Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) { + this( + routingDataSource, + repository, + enabled, + retention, + batchSize, + maxBatchesPerTenant, + Clock.systemUTC() + ); + } + + RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource, + AuthRefreshTokenRepository repository, + boolean enabled, + Duration retention, + int batchSize, + int maxBatchesPerTenant, + Clock clock) { + this.routingDataSource = Objects.requireNonNull( + routingDataSource, + "Маршрутизатор tenant-БД обязателен" + ); + this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен"); + if (retention == null || retention.isZero() || retention.isNegative()) { + throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным"); + } + if (batchSize < 1 || batchSize > 10_000) { + throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000"); + } + if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) { + throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000"); + } + this.enabled = enabled; + this.retention = retention; + this.batchSize = batchSize; + this.maxBatchesPerTenant = maxBatchesPerTenant; + this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны"); + } + + @Scheduled( + fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}", + initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}" + ) + public void cleanupScheduled() { + if (enabled) { + cleanupNow(); + } + } + + /** + * Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков + * внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod. + */ + public CleanupSummary cleanupNow() { + if (!cleanupInProgress.compareAndSet(false, true)) { + return new CleanupSummary(0, 0, 0, true); + } + + String previousTenant = TenantContext.getCurrentTenant(); + try { + TenantContext.clear(); + Map tenantSnapshot = routingDataSource.snapshotTenantConfigs(); + Instant cutoff = clock.instant().minus(retention); + int deletedTotal = 0; + int failedTenants = 0; + + for (String tenant : tenantSnapshot.keySet()) { + TenantContext.setCurrentTenant(tenant); + try { + deletedTotal += cleanupTenant(tenant, cutoff); + } catch (RuntimeException cleanupFailure) { + failedTenants++; + log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}", + tenant, cleanupFailure.getClass().getSimpleName()); + log.debug("Технические детали очистки refresh-сессий", cleanupFailure); + } finally { + TenantContext.clear(); + } + } + + if (deletedTotal > 0) { + log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}", + tenantSnapshot.size(), deletedTotal, failedTenants); + } + return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false); + } finally { + restoreTenant(previousTenant); + cleanupInProgress.set(false); + } + } + + private int cleanupTenant(String tenant, Instant cutoff) { + int deletedTotal = 0; + for (int batch = 0; batch < maxBatchesPerTenant; batch++) { + int deleted = repository.deleteCleanupBatch(cutoff, batchSize); + deletedTotal += deleted; + if (deleted < batchSize) { + return deletedTotal; + } + } + log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход", + tenant); + return deletedTotal; + } + + private void restoreTenant(String previousTenant) { + if (previousTenant == null || previousTenant.isBlank()) { + TenantContext.clear(); + } else { + TenantContext.setCurrentTenant(previousTenant); + } + } + + public record CleanupSummary(int tenantCount, + int deletedCount, + int failedTenantCount, + boolean skippedBecauseAlreadyRunning) { + } +} 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 044b461..b711343 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 @@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken; import com.magistr.app.model.User; import com.magistr.app.repository.AuthRefreshTokenRepository; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import java.util.Base64; import java.util.HexFormat; import java.util.Optional; @@ -25,17 +27,24 @@ public class RefreshTokenService { private final SecureRandom secureRandom = new SecureRandom(); private final AuthRefreshTokenRepository repository; private final JwtProperties properties; + private final Clock clock; + @Autowired public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) { + this(repository, properties, Clock.systemUTC()); + } + + RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) { this.repository = repository; this.properties = properties; + this.clock = clock; } @Transactional public String createSession(User user, String tenant, String userAgent, String ipAddress) { String refreshToken = generateRawToken(); AuthRefreshToken token = new AuthRefreshToken(); - LocalDateTime now = LocalDateTime.now(); + Instant now = clock.instant(); token.setUser(user); token.setTenant(tenant); token.setTokenHash(hashToken(refreshToken)); @@ -53,7 +62,7 @@ public class RefreshTokenService { return Optional.empty(); } - LocalDateTime now = LocalDateTime.now(); + Instant now = clock.instant(); String currentHash = hashToken(rawToken); Optional storedOpt = repository.findByTokenHashForUpdate(currentHash); if (storedOpt.isEmpty()) { @@ -101,7 +110,7 @@ public class RefreshTokenService { .filter(token -> tenant.equalsIgnoreCase(token.getTenant())) .filter(token -> token.getRevokedAt() == null) .ifPresent(token -> { - token.setRevokedAt(LocalDateTime.now()); + token.setRevokedAt(clock.instant()); repository.save(token); }); } 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 24059db..39bd1b3 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 @@ -65,7 +65,7 @@ public class TenantDataSourceConfig { "Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword ); tenants.add(defaultTenant); - log.info("Конфигурация тенантов отсутствует, используется default DataSource"); + log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию"); } readinessRegistry.clearConfigurationFailure(); @@ -79,10 +79,10 @@ public class TenantDataSourceConfig { registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true); } - // Если всё ещё нет ни одного тенанта — H2 in-memory заглушка + // Если всё ещё нет ни одного тенанта — H2-заглушка в памяти if (routingDataSource.getTenantConfigs().isEmpty()) { log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ==="); - log.warn("Создаём H2 in-memory заглушку для запуска приложения."); + log.warn("Создаём H2-заглушку в памяти для запуска приложения"); log.warn("Добавьте тенант через POST /api/database/tenants"); TenantConfig h2Fallback = new TenantConfig( @@ -155,7 +155,7 @@ public class TenantDataSourceConfig { log.info("Загружено конфигураций тенантов: {}", list.size()); return new TenantConfigLoadResult(List.copyOf(list), false); } catch (Exception e) { - log.error("Не удалось прочитать конфигурацию тенантов: errorType={}", + log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}", e.getClass().getSimpleName()); log.debug("Технические детали чтения конфигурации тенантов", e); return new TenantConfigLoadResult(List.of(), true); @@ -189,15 +189,15 @@ public class TenantDataSourceConfig { readinessRegistry.markConnectivity(tenant.getDomain(), true); } closeDataSource(previous == null ? null : previous.dataSource()); - log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain()); + log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain()); return true; } catch (Exception startupFailure) { if (requiredForReadiness) { readinessRegistry.markMigrationFailed(tenant); } - log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}", + log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}", tenant.getDomain(), startupFailure.getClass().getSimpleName()); - log.debug("Технические детали startup lifecycle tenant-БД", startupFailure); + log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure); return false; } finally { if (!activated) { @@ -211,9 +211,9 @@ public class TenantDataSourceConfig { try { hikariDataSource.close(); } catch (RuntimeException closeFailure) { - log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}", + log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}", closeFailure.getClass().getSimpleName()); - log.debug("Технические детали закрытия startup Hikari pool", closeFailure); + log.debug("Технические детали закрытия стартового пула Hikari", closeFailure); } } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java index e85c961..573d2ee 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java @@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor { TenantContext.setCurrentTenant(tenant); MDC.put("tenant.id", tenant); Span.current().setAttribute("tenant.id", tenant); - log.debug("Database API request, tenant '{}' (no strict check)", tenant); + log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant); return true; } // Проверяем, существует ли тенант if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) { - log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host); + log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host); response.setStatus(404); response.setContentType("application/json;charset=UTF-8"); new ObjectMapper().writeValue(response.getOutputStream(), Map.of( @@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor { TenantContext.setCurrentTenant(tenant); MDC.put("tenant.id", tenant); Span.current().setAttribute("tenant.id", tenant); - log.debug("Resolved tenant '{}' from Host '{}'", tenant, host); + log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host); return true; } 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 index d06ae19..88f314f 100644 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java @@ -141,7 +141,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource { try (Connection connection = tenantDataSource.get().getConnection()) { return connection.isValid(5); } catch (SQLException e) { - log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}", + log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}", normalizeDomain(domain), e.getClass().getSimpleName()); log.debug("Технические детали проверки подключения тенанта", e); return false; @@ -165,7 +165,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource { } return "Подключение не валидно"; } catch (Exception e) { - log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}", + log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}", e.getClass().getSimpleName()); log.debug("Технические детали проверки внешнего подключения", e); return "Не удалось подключиться к базе данных"; diff --git a/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java index 9b8b281..520db32 100644 --- a/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java @@ -4,6 +4,7 @@ import com.magistr.app.config.tenant.TenantRoutingDataSource; import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -41,6 +42,7 @@ public class TenantDatabaseHealthMonitor { private final ExecutorService executor; private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); + @Autowired public TenantDatabaseHealthMonitor( TenantReadinessRegistry registry, TenantRoutingDataSource routingDataSource, diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java index d4116b7..b01d608 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.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.AcademicPeriodService; import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,17 +23,20 @@ public class AcademicCalendarAdminController { private final AcademicCalendarActivityTypeRepository activityTypeRepository; private final AcademicCalendarDayRepository calendarDayRepository; private final ScheduleGeneratorService scheduleGeneratorService; + private final AcademicPeriodService academicPeriodService; public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, SemesterRepository semesterRepository, AcademicCalendarActivityTypeRepository activityTypeRepository, AcademicCalendarDayRepository calendarDayRepository, - ScheduleGeneratorService scheduleGeneratorService) { + ScheduleGeneratorService scheduleGeneratorService, + AcademicPeriodService academicPeriodService) { this.academicYearRepository = academicYearRepository; this.semesterRepository = semesterRepository; this.activityTypeRepository = activityTypeRepository; this.calendarDayRepository = calendarDayRepository; this.scheduleGeneratorService = scheduleGeneratorService; + this.academicPeriodService = academicPeriodService; } @GetMapping("/years") @@ -45,43 +49,17 @@ public class AcademicCalendarAdminController { @PostMapping("/years") public ResponseEntity createYear(@RequestBody AcademicYearDto request) { - String validationError = validateYear(request); - if (validationError != null) { - return ResponseEntity.badRequest().body(Map.of("message", validationError)); - } - if (academicYearRepository.findByTitle(request.title()).isPresent()) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует")); - } - - AcademicYear year = new AcademicYear(); - applyYear(year, request); - return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request))); } @PutMapping("/years/{id}") public ResponseEntity updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) { - AcademicYear year = academicYearRepository.findById(id).orElse(null); - if (year == null) { - return ResponseEntity.notFound().build(); - } - - String validationError = validateYear(request); - if (validationError != null) { - return ResponseEntity.badRequest().body(Map.of("message", validationError)); - } - - applyYear(year, request); - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request))); } @DeleteMapping("/years/{id}") public ResponseEntity deleteYear(@PathVariable Long id) { - if (!academicYearRepository.existsById(id)) { - return ResponseEntity.notFound().build(); - } - academicYearRepository.deleteById(id); - scheduleGeneratorService.clearCache(); + academicPeriodService.deleteYear(id); return ResponseEntity.ok(Map.of("message", "Учебный год удалён")); } @@ -94,39 +72,12 @@ public class AcademicCalendarAdminController { @PostMapping("/years/{academicYearId}/semesters") public ResponseEntity createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) { - AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null); - if (year == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); - } - String validationError = validateSemester(request); - if (validationError != null) { - return ResponseEntity.badRequest().body(Map.of("message", validationError)); - } - if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) { - return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году")); - } - - Semester semester = new Semester(); - semester.setAcademicYear(year); - applySemester(semester, request); - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request))); } @PutMapping("/semesters/{id}") public ResponseEntity updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) { - Semester semester = semesterRepository.findById(id).orElse(null); - if (semester == null) { - return ResponseEntity.notFound().build(); - } - String validationError = validateSemester(request); - if (validationError != null) { - return ResponseEntity.badRequest().body(Map.of("message", validationError)); - } - - applySemester(semester, request); - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request))); } @GetMapping("/activity-types") @@ -185,32 +136,6 @@ public class AcademicCalendarAdminController { return ResponseEntity.ok(Map.of("message", "Код активности удалён")); } - private String validateYear(AcademicYearDto request) { - if (request.title() == null || request.title().isBlank()) { - return "Название учебного года обязательно"; - } - if (request.startDate() == null || request.endDate() == null) { - return "Даты начала и окончания учебного года обязательны"; - } - if (request.endDate().isBefore(request.startDate())) { - return "Дата окончания не может быть раньше даты начала"; - } - return null; - } - - private String validateSemester(SemesterDto request) { - if (request.semesterType() == null) { - return "Тип семестра обязателен"; - } - if (request.startDate() == null || request.endDate() == null) { - return "Даты начала и окончания семестра обязательны"; - } - if (request.endDate().isBefore(request.startDate())) { - return "Дата окончания семестра не может быть раньше даты начала"; - } - return null; - } - private String validateActivityType(AcademicCalendarActivityTypeDto request) { if (request == null) { return "Передайте код активности"; @@ -224,18 +149,6 @@ public class AcademicCalendarAdminController { return null; } - private void applyYear(AcademicYear year, AcademicYearDto request) { - year.setTitle(request.title().trim()); - year.setStartDate(request.startDate()); - year.setEndDate(request.endDate()); - } - - private void applySemester(Semester semester, SemesterDto request) { - semester.setSemesterType(request.semesterType()); - semester.setStartDate(request.startDate()); - semester.setEndDate(request.endDate()); - } - private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) { activityType.setCode(request.code().trim()); activityType.setName(request.name().trim()); 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 7f5a460..83074a7 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -5,6 +5,7 @@ import com.magistr.app.dto.*; import com.magistr.app.model.*; import com.magistr.app.repository.*; import com.magistr.app.service.AcademicCalendarGridService; +import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +33,7 @@ public class AcademicCalendarController { private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicCalendarGridService calendarGridService; private final ScheduleGeneratorService scheduleGeneratorService; + private final AcademicStructureService academicStructureService; public AcademicCalendarController(AcademicCalendarRepository calendarRepository, AcademicCalendarDayRepository calendarDayRepository, @@ -42,7 +44,8 @@ public class AcademicCalendarController { SubjectRepository subjectRepository, AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicCalendarGridService calendarGridService, - ScheduleGeneratorService scheduleGeneratorService) { + ScheduleGeneratorService scheduleGeneratorService, + AcademicStructureService academicStructureService) { this.calendarRepository = calendarRepository; this.calendarDayRepository = calendarDayRepository; this.academicYearRepository = academicYearRepository; @@ -53,6 +56,7 @@ public class AcademicCalendarController { this.calendarSubjectRepository = calendarSubjectRepository; this.calendarGridService = calendarGridService; this.scheduleGeneratorService = scheduleGeneratorService; + this.academicStructureService = academicStructureService; } @GetMapping @@ -85,17 +89,7 @@ public class AcademicCalendarController { @PutMapping("/{id}") public ResponseEntity updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) { - AcademicCalendar calendar = calendarRepository.findById(id).orElse(null); - if (calendar == null) { - return ResponseEntity.notFound().build(); - } - try { - applyCalendar(calendar, request); - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); - } + return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request))); } @DeleteMapping("/{id}") @@ -103,14 +97,9 @@ public class AcademicCalendarController { if (!calendarRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - try { - calendarRepository.deleteById(id); - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(Map.of("message", "Календарный график удалён")); - } catch (Exception e) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Нельзя удалить график, который назначен учебным группам")); - } + calendarRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный график удалён")); } @GetMapping("/{id}/grid") diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 1f29f48..c26cbe4 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -3,17 +3,18 @@ package com.magistr.app.controller; import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.JwtProperties; import com.magistr.app.config.auth.JwtTokenService; +import com.magistr.app.config.auth.ClientIpResolver; +import com.magistr.app.config.auth.LoginRateLimitService; import com.magistr.app.config.auth.RefreshTokenRotation; import com.magistr.app.config.auth.RefreshTokenService; import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.dto.LoginRequest; import com.magistr.app.dto.LoginResponse; import com.magistr.app.model.User; -import com.magistr.app.repository.UserRepository; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -27,8 +28,8 @@ import java.util.Optional; @RequestMapping("/api/auth") public class AuthController { - private final UserRepository userRepository; - private final BCryptPasswordEncoder passwordEncoder; + private final LoginRateLimitService loginRateLimitService; + private final ClientIpResolver clientIpResolver; private final JwtTokenService jwtTokenService; private final RefreshTokenService refreshTokenService; private final JwtProperties jwtProperties; @@ -42,13 +43,13 @@ public class AuthController { "STUDENT", "/student/" ); - public AuthController(UserRepository userRepository, - BCryptPasswordEncoder passwordEncoder, + public AuthController(LoginRateLimitService loginRateLimitService, + ClientIpResolver clientIpResolver, JwtTokenService jwtTokenService, RefreshTokenService refreshTokenService, JwtProperties jwtProperties) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; + this.loginRateLimitService = loginRateLimitService; + this.clientIpResolver = clientIpResolver; this.jwtTokenService = jwtTokenService; this.refreshTokenService = refreshTokenService; this.jwtProperties = jwtProperties; @@ -56,28 +57,46 @@ public class AuthController { @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) { - Optional userOpt = userRepository.findByUsername(request.getUsername()); - - if (userOpt.isEmpty() || - !passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) { + LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate( + TenantContext.getCurrentTenant(), + request.getUsername(), + request.getPassword(), + clientIpResolver.resolve(servletRequest) + ); + if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds())) + .body(new LoginResponse( + false, + "Слишком много попыток входа. Повторите позже", + null, + null, + null, + null + )); + } + if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) { return ResponseEntity .status(401) - .body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null)); + .body(new LoginResponse( + false, + "Неверное имя пользователя или пароль", + null, + null, + null, + null + )); } - User user = userOpt.get(); - if (user.isArchivedRecord()) { - return ResponseEntity - .status(401) - .body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null)); - } + User user = decision.user(); String tenant = TenantContext.getCurrentTenant(); String accessToken = jwtTokenService.createAccessToken(user, tenant); String refreshToken = refreshTokenService.createSession( user, tenant, servletRequest.getHeader("User-Agent"), - clientIp(servletRequest) + clientIpResolver.resolve(servletRequest) ); return ResponseEntity.ok() @@ -96,7 +115,7 @@ public class AuthController { refreshToken.get(), TenantContext.getCurrentTenant(), request.getHeader("User-Agent"), - clientIp(request) + clientIpResolver.resolve(request) ); if (rotation.isEmpty()) { return unauthorizedWithClearedCookie(); @@ -192,11 +211,4 @@ public class AuthController { return Optional.empty(); } - private String clientIp(HttpServletRequest request) { - String forwarded = request.getHeader("X-Forwarded-For"); - if (forwarded != null && !forwarded.isBlank()) { - return forwarded.split(",")[0].trim(); - } - return request.getRemoteAddr(); - } } diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java new file mode 100644 index 0000000..360f0b4 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java @@ -0,0 +1,190 @@ +package com.magistr.app.controller; + +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; + +import java.sql.SQLException; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки. + */ +final class DatabaseConstraintViolationMapper { + + private static final Pattern CONSTRAINT_PATTERN = Pattern.compile( + "(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?" + ); + + private static final Map KNOWN_CONSTRAINTS = Map.ofEntries( + badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"), + badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"), + badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"), + badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"), + badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"), + badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"), + badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"), + badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"), + badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"), + badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"), + badRequest("chk_semesters_type", "Недопустимый тип семестра"), + badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"), + badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"), + badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"), + badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"), + badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"), + badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"), + badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"), + badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"), + badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"), + badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"), + badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"), + badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"), + badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"), + badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"), + badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"), + badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"), + badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"), + badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"), + badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"), + conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"), + conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"), + conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"), + conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"), + conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"), + conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"), + conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"), + conflict("uq_calendar_days", "День календарного графика уже существует"), + conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"), + conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"), + conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"), + conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"), + conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"), + conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"), + conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"), + conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"), + conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением") + ); + + ViolationResponse map(DataIntegrityViolationException exception) { + String constraintName = constraintName(exception).orElse(null); + String sqlState = sqlState(exception).orElse(null); + if (constraintName != null) { + ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName); + if (known != null) { + return known.withDetails(constraintName, sqlState); + } + if (constraintName.endsWith("_fkey")) { + return new ViolationResponse( + HttpStatus.CONFLICT, + "Операция невозможна из-за связанных данных", + constraintName, + sqlState + ); + } + } + + if ("23502".equals(sqlState)) { + return new ViolationResponse( + HttpStatus.BAD_REQUEST, + "Не заполнено обязательное поле", + constraintName, + sqlState + ); + } + if ("23514".equals(sqlState)) { + return new ViolationResponse( + HttpStatus.BAD_REQUEST, + "Данные нарушают правила предметной области", + constraintName, + sqlState + ); + } + if ("23505".equals(sqlState)) { + return new ViolationResponse( + HttpStatus.CONFLICT, + "Такая запись уже существует", + constraintName, + sqlState + ); + } + if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) { + return new ViolationResponse( + HttpStatus.CONFLICT, + "Операция невозможна из-за конфликта или связанных данных", + constraintName, + sqlState + ); + } + return new ViolationResponse( + HttpStatus.CONFLICT, + "Операция нарушает ограничения целостности данных", + constraintName, + sqlState + ); + } + + private Optional constraintName(Throwable failure) { + for (Throwable current = failure; current != null; current = nextCause(current)) { + if (current instanceof ConstraintViolationException constraintViolation + && constraintViolation.getConstraintName() != null) { + return normalize(constraintViolation.getConstraintName()); + } + String message = current.getMessage(); + if (message != null) { + Matcher matcher = CONSTRAINT_PATTERN.matcher(message); + if (matcher.find()) { + return normalize(matcher.group(1)); + } + } + } + return Optional.empty(); + } + + private Optional sqlState(Throwable failure) { + for (Throwable current = failure; current != null; current = nextCause(current)) { + if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) { + return Optional.of(sqlException.getSQLState()); + } + } + return Optional.empty(); + } + + private Throwable nextCause(Throwable current) { + Throwable cause = current.getCause(); + return cause == current ? null : cause; + } + + private Optional normalize(String constraintName) { + if (constraintName == null || constraintName.isBlank()) { + return Optional.empty(); + } + String normalized = constraintName.trim() + .replace("\"", "") + .toLowerCase(Locale.ROOT); + int dot = normalized.lastIndexOf('.'); + return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized); + } + + private static Map.Entry badRequest(String name, String message) { + return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514")); + } + + private static Map.Entry conflict(String name, String message) { + return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null)); + } + + record ViolationResponse(HttpStatus status, + String message, + String constraintName, + String sqlState) { + + private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) { + return new ViolationResponse(status, message, actualConstraintName, actualSqlState); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java index dfa9a96..ccedb9f 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java @@ -9,7 +9,6 @@ import com.magistr.app.model.Role; import com.magistr.app.repository.DepartmentRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -93,9 +92,8 @@ public class DepartmentController { ) ); } catch (Exception e) { - logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage())); + logger.error("Ошибка при создании кафедры", e); + throw e; } } @@ -148,9 +146,8 @@ public class DepartmentController { department.getDepartmentCode() )); } catch (Exception e) { - logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage())); + logger.error("Ошибка при обновлении кафедры с ID - {}", id, e); + throw e; } } diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java index 0d7d9d5..cfa8e51 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository; import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import com.magistr.app.service.ScheduleQueryService; +import com.magistr.app.service.SubjectImportService; +import com.magistr.app.service.TeacherDepartmentService; +import com.magistr.app.service.BusinessTimeService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +44,9 @@ public class DepartmentWorkspaceController { private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; private final TeacherCreationRequestRepository teacherCreationRequestRepository; private final ScheduleQueryService scheduleQueryService; + private final TeacherDepartmentService teacherDepartmentService; + private final SubjectImportService subjectImportService; + private final BusinessTimeService businessTime; public DepartmentWorkspaceController(SubjectRepository subjectRepository, SubjectCommentRepository subjectCommentRepository, @@ -47,7 +54,26 @@ public class DepartmentWorkspaceController { DepartmentRepository departmentRepository, TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, TeacherCreationRequestRepository teacherCreationRequestRepository, - ScheduleQueryService scheduleQueryService) { + ScheduleQueryService scheduleQueryService, + TeacherDepartmentService teacherDepartmentService, + SubjectImportService subjectImportService) { + this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository, + teacherDepartmentAssignmentRepository, teacherCreationRequestRepository, + scheduleQueryService, teacherDepartmentService, subjectImportService, + BusinessTimeService.systemDefault()); + } + + @Autowired + public DepartmentWorkspaceController(SubjectRepository subjectRepository, + SubjectCommentRepository subjectCommentRepository, + UserRepository userRepository, + DepartmentRepository departmentRepository, + TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, + TeacherCreationRequestRepository teacherCreationRequestRepository, + ScheduleQueryService scheduleQueryService, + TeacherDepartmentService teacherDepartmentService, + SubjectImportService subjectImportService, + BusinessTimeService businessTime) { this.subjectRepository = subjectRepository; this.subjectCommentRepository = subjectCommentRepository; this.userRepository = userRepository; @@ -55,6 +81,9 @@ public class DepartmentWorkspaceController { this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; this.teacherCreationRequestRepository = teacherCreationRequestRepository; this.scheduleQueryService = scheduleQueryService; + this.teacherDepartmentService = teacherDepartmentService; + this.subjectImportService = subjectImportService; + this.businessTime = businessTime; } @GetMapping("/subjects") @@ -77,19 +106,7 @@ public class DepartmentWorkspaceController { if (departmentId == null) { return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна")); } - List saved = requests.stream() - .filter(request -> request.getName() != null && !request.getName().isBlank()) - .map(request -> { - Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new); - subject.setName(request.getName().trim()); - subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim()); - subject.setDepartmentId(departmentId); - if (subject.isArchivedRecord()) { - subject.restore(); - } - return subjectRepository.save(subject); - }) - .toList(); + List saved = subjectImportService.importSubjects(requests, departmentId); return ResponseEntity.ok(Map.of( "message", "Дисциплины загружены", "count", saved.size() @@ -118,6 +135,7 @@ public class DepartmentWorkspaceController { return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен")); } SubjectComment comment = new SubjectComment(); + comment.setCreatedAt(businessTime.now()); comment.setSubject(subject); comment.setComment(commentText.trim()); Long currentUserId = AuthContext.currentUserId(); @@ -132,21 +150,19 @@ public class DepartmentWorkspaceController { Long effectiveDepartmentId = effectiveDepartmentId(departmentId); if (effectiveDepartmentId == null) { return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream() - .map(this::toUserResponse) + .map(user -> toUserResponse( + user, + teacherDepartmentService.findPrimaryDepartmentIdAtDate( + user.getId(), + businessTime.today() + ).orElse(user.getDepartmentId()) + )) .toList(); } - Map teachersById = new LinkedHashMap<>(); - teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now()) - .stream() - .map(TeacherDepartmentAssignment::getTeacher) - .filter(Objects::nonNull) - .filter(User::isActiveRecord) - .forEach(teacher -> teachersById.put(teacher.getId(), teacher)); - userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED) - .forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher)); - return teachersById.values().stream() - .map(this::toUserResponse) + return teacherDepartmentService + .findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream() + .map(user -> toUserResponse(user, effectiveDepartmentId)) .toList(); } @@ -167,7 +183,7 @@ public class DepartmentWorkspaceController { if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) { return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден")); } - LocalDate today = LocalDate.now(); + LocalDate today = businessTime.today(); if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) { return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре")); } @@ -225,6 +241,8 @@ public class DepartmentWorkspaceController { } TeacherCreationRequest teacherRequest = new TeacherCreationRequest(); + teacherRequest.setCreatedAt(businessTime.now()); + teacherRequest.setUpdatedAt(businessTime.now()); teacherRequest.setDepartment(department); teacherRequest.setUsername(username); teacherRequest.setFullName(fullName); @@ -257,14 +275,14 @@ public class DepartmentWorkspaceController { ); } - private UserResponse toUserResponse(User user) { + private UserResponse toUserResponse(User user, Long departmentId) { UserResponse response = new UserResponse( user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), - user.getDepartmentId() + departmentId ); response.setStatus(user.getStatus()); return response; diff --git a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java index 31306db..a9301d6 100755 --- a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java +++ b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java @@ -15,7 +15,7 @@ import java.util.Map; @RestController @RequestMapping("/api/education-forms") -@RequireRoles({Role.ADMIN}) +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public class EducationFormController { private final EducationFormRepository educationFormRepository; 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 2886f48..d1feae0 100644 --- a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.magistr.app.service.ScheduleConflictException; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.NoSuchElementException; @@ -20,6 +21,8 @@ import java.util.NoSuchElementException; public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private final DatabaseConstraintViolationMapper constraintViolationMapper = + new DatabaseConstraintViolationMapper(); @ExceptionHandler(ScheduleConflictException.class) public ResponseEntity> handleScheduleConflict(ScheduleConflictException exception, @@ -58,6 +61,23 @@ public class GlobalExceptionHandler { return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request); } + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleDataIntegrityViolation( + DataIntegrityViolationException exception, + HttpServletRequest request) { + DatabaseConstraintViolationMapper.ViolationResponse violation = + constraintViolationMapper.map(exception); + log.warn( + "Нарушение целостности данных {} {}: constraint={}, sqlState={}", + request.getMethod(), + request.getRequestURI(), + violation.constraintName() == null ? "не определено" : violation.constraintName(), + violation.sqlState() == null ? "не определено" : violation.sqlState() + ); + log.debug("Технические детали нарушения целостности данных", exception); + return error(violation.status(), violation.message(), request); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { @@ -67,7 +87,7 @@ public class GlobalExceptionHandler { private ResponseEntity> error(HttpStatus status, String message, HttpServletRequest request) { Map body = new LinkedHashMap<>(); - body.put("timestamp", LocalDateTime.now().toString()); + body.put("timestamp", Instant.now().toString()); body.put("status", status.value()); body.put("error", reason(status)); body.put("message", message); diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 6650638..ec4d97d 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -5,9 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto; import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupResponse; -import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendarSubject; -import com.magistr.app.model.AcademicYear; import com.magistr.app.model.EducationForm; import com.magistr.app.model.Role; import com.magistr.app.model.Speciality; @@ -17,12 +15,15 @@ import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupStudyState; import com.magistr.app.repository.*; +import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.StudentGroupLifecycleService; +import com.magistr.app.service.BusinessTimeService; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -45,33 +46,49 @@ public class GroupController { private final EducationFormRepository educationFormRepository; private final SpecialtiesRepository specialtiesRepository; private final SpecialtyProfileRepository specialtyProfileRepository; - private final AcademicYearRepository academicYearRepository; - private final AcademicCalendarRepository academicCalendarRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final ScheduleGeneratorService scheduleGeneratorService; private final StudentGroupLifecycleService groupLifecycleService; + private final AcademicStructureService academicStructureService; + private final BusinessTimeService businessTime; public GroupController(GroupRepository groupRepository, EducationFormRepository educationFormRepository, SpecialtiesRepository specialtiesRepository, SpecialtyProfileRepository specialtyProfileRepository, - AcademicYearRepository academicYearRepository, - AcademicCalendarRepository academicCalendarRepository, AcademicCalendarSubjectRepository calendarSubjectRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, ScheduleGeneratorService scheduleGeneratorService, - StudentGroupLifecycleService groupLifecycleService) { + StudentGroupLifecycleService groupLifecycleService, + AcademicStructureService academicStructureService) { + this(groupRepository, educationFormRepository, specialtiesRepository, + specialtyProfileRepository, calendarSubjectRepository, assignmentRepository, + scheduleGeneratorService, groupLifecycleService, academicStructureService, + BusinessTimeService.systemDefault()); + } + + @Autowired + public GroupController(GroupRepository groupRepository, + EducationFormRepository educationFormRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository, + AcademicCalendarSubjectRepository calendarSubjectRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, + ScheduleGeneratorService scheduleGeneratorService, + StudentGroupLifecycleService groupLifecycleService, + AcademicStructureService academicStructureService, + BusinessTimeService businessTime) { this.groupRepository = groupRepository; this.educationFormRepository = educationFormRepository; this.specialtiesRepository = specialtiesRepository; this.specialtyProfileRepository = specialtyProfileRepository; - this.academicYearRepository = academicYearRepository; - this.academicCalendarRepository = academicCalendarRepository; this.calendarSubjectRepository = calendarSubjectRepository; this.assignmentRepository = assignmentRepository; this.scheduleGeneratorService = scheduleGeneratorService; this.groupLifecycleService = groupLifecycleService; + this.academicStructureService = academicStructureService; + this.businessTime = businessTime; } @GetMapping @@ -82,7 +99,7 @@ public class GroupController { List groups = includeArchived ? groupRepository.findAll() : groupRepository.findAll().stream() - .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now())) + .filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today())) .toList(); List response = groups.stream() @@ -101,7 +118,7 @@ public class GroupController { logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); try { List groups = groupRepository.findByDepartmentId(departmentId).stream() - .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now())) + .filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today())) .toList(); if(groups.isEmpty()) { @@ -163,10 +180,9 @@ public class GroupController { logger.info("Группа успешно создана с ID - {}", group.getId()); return ResponseEntity.ok(mapToResponse(group)); - } catch (Exception e ) { - logger.error("Ошибка при создании группы: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка при создании группы", e); + throw e; } } @@ -174,50 +190,9 @@ public class GroupController { @RequireRoles({Role.ADMIN}) public ResponseEntity updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) { logger.info("Получен запрос на обновление группы с ID - {}", id); - try { - Optional groupOpt = groupRepository.findById(id); - if (groupOpt.isEmpty()) { - logger.info("Группа с ID - {} не найдена", id); - return ResponseEntity.notFound().build(); - } - - ResponseEntity validationError = validateGroupRequest(request); - if (validationError != null) { - return validationError; - } - - Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); - if (efOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); - } - Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) - .orElse(null); - if (speciality == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); - } - SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) - .orElse(null); - if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { - return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); - } - - StudentGroup group = groupOpt.get(); - group.setName(request.getName().trim()); - group.setGroupSize(request.getGroupSize()); - group.setEducationForm(efOpt.get()); - group.setDepartmentId(request.getDepartmentId()); - group.setYearStartStudy(request.getYearStartStudy()); - group.setSpeciality(speciality); - group.setSpecialtyProfile(profile); - groupRepository.save(group); - - logger.info("Группа с ID - {} успешно обновлена", id); - return ResponseEntity.ok(mapToResponse(group)); - } catch (Exception e) { - logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage())); - } + StudentGroup group = academicStructureService.updateGroup(id, request); + logger.info("Группа с ID - {} успешно обновлена", id); + return ResponseEntity.ok(mapToResponse(group)); } @DeleteMapping("/{id}") @@ -229,7 +204,7 @@ public class GroupController { logger.info("Группа с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } - group.archive("Группа архивирована"); + group.archive("Группа архивирована", businessTime.today(), businessTime.now()); groupRepository.save(group); logger.info("Группа с ID - {} успешно архивирована", id); return ResponseEntity.ok(Map.of("message", "Группа архивирована")); @@ -278,11 +253,11 @@ public class GroupController { } private GroupResponse mapToResponse(StudentGroup group) { - int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); + LocalDate today = businessTime.today(); + int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today); + int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today); Speciality speciality = group.getSpeciality(); SpecialtyProfile profile = group.getSpecialtyProfile(); - LocalDate today = LocalDate.now(); StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today); return new GroupResponse( group.getId(), @@ -323,42 +298,9 @@ public class GroupController { @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public ResponseEntity saveCalendarAssignment(@PathVariable Long id, @RequestBody GroupCalendarAssignmentDto request) { - if (request == null || request.academicYearId() == null || request.calendarId() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны")); - } - StudentGroup group = groupRepository.findById(id).orElse(null); - if (group == null) { - return ResponseEntity.notFound().build(); - } - AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null); - if (academicYear == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); - } - AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null); - if (calendar == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден")); - } - if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) { - return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году")); - } - if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId()) - || !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) { - return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы")); - } - if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) { - return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы")); - } - - StudentGroupCalendarAssignment assignment = assignmentRepository - .findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId()) - .orElseGet(StudentGroupCalendarAssignment::new); - assignment.setStudentGroup(group); - assignment.setAcademicYear(academicYear); - assignment.setAcademicCalendar(calendar); - scheduleGeneratorService.clearCache(); - StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment); + StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request); List subjects = calendarSubjectRepository - .findByCalendarIdWithSubject(calendar.getId()) + .findByCalendarIdWithSubject(saved.getAcademicCalendar().getId()) .stream() .map(this::toCalendarSubjectDto) .toList(); diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java index d683998..536fd35 100644 --- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository; import com.magistr.app.repository.SpecialtyProfileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -36,6 +35,7 @@ public class SpecialityController { } @GetMapping + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public List getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение списка специальностей"); try { @@ -102,9 +102,8 @@ public class SpecialityController { ) ); } catch (Exception e) { - logger.error("Ошибка при создании специальности: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage())); + logger.error("Ошибка при создании специальности", e); + throw e; } } @@ -158,9 +157,8 @@ public class SpecialityController { speciality.getSpecialityCode() )); } catch (Exception e) { - logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage())); + logger.error("Ошибка при обновлении специальности с ID - {}", id, e); + throw e; } } @@ -194,6 +192,7 @@ public class SpecialityController { } @GetMapping("/profiles") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public List getAllProfiles() { logger.info("Получен запрос на получение всех профилей обучения"); return specialtyProfileRepository.findAll().stream() @@ -204,6 +203,7 @@ public class SpecialityController { } @GetMapping("/{specialtyId}/profiles") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public ResponseEntity getProfiles(@PathVariable Long specialtyId) { if (!specialtiesRepository.existsById(specialtyId)) { return ResponseEntity.notFound().build(); @@ -255,14 +255,8 @@ public class SpecialityController { if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { return ResponseEntity.notFound().build(); } - try { - specialtyProfileRepository.delete(profile); - return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён")); - } catch (Exception e) { - logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e); - return ResponseEntity.badRequest() - .body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками")); - } + specialtyProfileRepository.delete(profile); + return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён")); } private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) { diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java index 66e75e1..ec99f95 100644 --- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -9,7 +9,6 @@ import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.ScheduleRuleSlotRepository; import com.magistr.app.repository.SubgroupRepository; import com.magistr.app.service.ScheduleGeneratorService; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -125,14 +124,10 @@ public class SubgroupController { if (deleteValidationError != null) { return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError)); } - try { - subgroup.archive("Подгруппа архивирована"); - subgroupRepository.save(subgroup); - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована")); - } catch (DataIntegrityViolationException e) { - return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); - } + subgroup.archive("Подгруппа архивирована"); + subgroupRepository.save(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована")); } private String validate(SubgroupDto request) { diff --git a/backend/src/main/java/com/magistr/app/controller/SubjectController.java b/backend/src/main/java/com/magistr/app/controller/SubjectController.java index 5674bb5..019bb08 100755 --- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -87,7 +86,7 @@ public class SubjectController { logger.error("Ошибка валидации: {}", errorMessage); return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); } - if (subjectRepository.findByName(request.getName().trim()).isPresent()) { + if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) { String errorMessage = "Дисциплина с таким названием уже существует"; logger.error("Ошибка валидации: {}", errorMessage); return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); @@ -119,10 +118,9 @@ public class SubjectController { subject.getDepartmentId() ) ); - } catch (Exception e){ - logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка при создании дисциплины", e); + throw e; } } diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java index c6b0289..e462b66 100644 --- a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java +++ b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java @@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.TeacherCreationRequestRepository; import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.BusinessTimeService; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -30,17 +29,20 @@ public class TeacherCreationRequestController { private final DepartmentRepository departmentRepository; private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; private final BCryptPasswordEncoder passwordEncoder; + private final BusinessTimeService businessTime; public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository, UserRepository userRepository, DepartmentRepository departmentRepository, TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, - BCryptPasswordEncoder passwordEncoder) { + BCryptPasswordEncoder passwordEncoder, + BusinessTimeService businessTime) { this.teacherCreationRequestRepository = teacherCreationRequestRepository; this.userRepository = userRepository; this.departmentRepository = departmentRepository; this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; this.passwordEncoder = passwordEncoder; + this.businessTime = businessTime; } @GetMapping @@ -98,7 +100,8 @@ public class TeacherCreationRequestController { TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); assignment.setTeacher(teacher); assignment.setDepartment(department); - assignment.setValidFrom(LocalDate.now()); + assignment.setValidFrom(businessTime.today()); + assignment.setCreatedAt(businessTime.now()); assignment.setPrimaryAssignment(true); assignment.setComment("Создан по заявке кафедры #" + request.getId()); assignment.setCreatedBy(AuthContext.currentUserId()); @@ -110,7 +113,7 @@ public class TeacherCreationRequestController { request.setDepartment(department); request.setStatus(TeacherCreationRequestStatus.APPROVED); request.setReviewedBy(AuthContext.currentUserId()); - request.setReviewedAt(LocalDateTime.now()); + request.setReviewedAt(businessTime.now()); request.setReviewComment(trimToNull(review.getReviewComment())); request.setCreatedTeacherId(teacher.getId()); teacherCreationRequestRepository.save(request); @@ -132,7 +135,7 @@ public class TeacherCreationRequestController { } request.setStatus(TeacherCreationRequestStatus.REJECTED); request.setReviewedBy(AuthContext.currentUserId()); - request.setReviewedAt(LocalDateTime.now()); + request.setReviewedAt(businessTime.now()); request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment())); return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request))); } diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java index 4ad3985..a844c86 100755 --- a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java @@ -11,6 +11,9 @@ import com.magistr.app.model.User; import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.TeacherSubjectRepository; import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.TeacherDepartmentService; +import com.magistr.app.service.BusinessTimeService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -25,13 +28,28 @@ public class TeacherSubjectController { private final TeacherSubjectRepository teacherSubjectRepository; private final UserRepository userRepository; private final SubjectRepository subjectRepository; + private final TeacherDepartmentService teacherDepartmentService; + private final BusinessTimeService businessTime; public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository, UserRepository userRepository, - SubjectRepository subjectRepository) { + SubjectRepository subjectRepository, + TeacherDepartmentService teacherDepartmentService) { + this(teacherSubjectRepository, userRepository, subjectRepository, + teacherDepartmentService, BusinessTimeService.systemDefault()); + } + + @Autowired + public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository, + UserRepository userRepository, + SubjectRepository subjectRepository, + TeacherDepartmentService teacherDepartmentService, + BusinessTimeService businessTime) { this.teacherSubjectRepository = teacherSubjectRepository; this.userRepository = userRepository; this.subjectRepository = subjectRepository; + this.teacherDepartmentService = teacherDepartmentService; + this.businessTime = businessTime; } @GetMapping @@ -111,7 +129,11 @@ public class TeacherSubjectController { return departmentId != null && teacher != null && subject != null - && departmentId.equals(teacher.getDepartmentId()) + && teacherDepartmentService.hasAssignmentAtDate( + teacher.getId(), + departmentId, + businessTime.today() + ) && departmentId.equals(subject.getDepartmentId()); } } diff --git a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java index efab2bb..d176e4b 100644 --- a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -11,11 +11,10 @@ import com.magistr.app.model.Role; import com.magistr.app.repository.TimeSlotDateAssignmentRepository; import com.magistr.app.repository.TimeSlotRepository; import com.magistr.app.repository.TimeSlotScopeRepository; -import org.springframework.dao.DataIntegrityViolationException; +import com.magistr.app.service.TimeSlotService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.Duration; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -35,13 +34,16 @@ public class TimeSlotAdminController { private final TimeSlotRepository timeSlotRepository; private final TimeSlotScopeRepository timeSlotScopeRepository; private final TimeSlotDateAssignmentRepository dateAssignmentRepository; + private final TimeSlotService timeSlotService; public TimeSlotAdminController(TimeSlotRepository timeSlotRepository, TimeSlotScopeRepository timeSlotScopeRepository, - TimeSlotDateAssignmentRepository dateAssignmentRepository) { + TimeSlotDateAssignmentRepository dateAssignmentRepository, + TimeSlotService timeSlotService) { this.timeSlotRepository = timeSlotRepository; this.timeSlotScopeRepository = timeSlotScopeRepository; this.dateAssignmentRepository = dateAssignmentRepository; + this.timeSlotService = timeSlotService; } @GetMapping @@ -151,83 +153,18 @@ public class TimeSlotAdminController { @PostMapping public ResponseEntity create(@RequestBody TimeSlotDto request) { - String validationError = validate(request); - if (validationError != null) { - return ResponseEntity.badRequest().body(Map.of("message", validationError)); - } - if (hasDuplicate(request, null)) { - return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары")); - } - - TimeSlot timeSlot = new TimeSlot(); - apply(timeSlot, request); - return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + return ResponseEntity.ok(toDto(timeSlotService.create(request))); } @PutMapping("/{id}") public ResponseEntity update(@PathVariable Long id, @RequestBody TimeSlotDto request) { - TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null); - if (timeSlot == null) { - return ResponseEntity.notFound().build(); - } - - String validationError = validate(request); - if (validationError != null) { - return ResponseEntity.badRequest().body(Map.of("message", validationError)); - } - if (hasDuplicate(request, id)) { - return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары")); - } - - apply(timeSlot, request); - return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + return ResponseEntity.ok(toDto(timeSlotService.update(id, request))); } @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable Long id) { - if (!timeSlotRepository.existsById(id)) { - return ResponseEntity.notFound().build(); - } - try { - timeSlotRepository.deleteById(id); - return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); - } catch (DataIntegrityViolationException e) { - return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания")); - } - } - - private String validate(TimeSlotDto request) { - if (request.orderNumber() == null || request.orderNumber() <= 0) { - return "Номер пары должен быть больше нуля"; - } - if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) { - return "Выберите сетку времени"; - } - if (request.startTime() == null || request.endTime() == null) { - return "Время начала и окончания обязательно"; - } - if (!request.startTime().isBefore(request.endTime())) { - return "Время начала должно быть раньше времени окончания"; - } - return null; - } - - private void apply(TimeSlot timeSlot, TimeSlotDto request) { - timeSlot.setOrderNumber(request.orderNumber()); - timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId()) - .orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена"))); - timeSlot.setStartTime(request.startTime()); - timeSlot.setEndTime(request.endTime()); - int duration = request.durationMinutes() != null && request.durationMinutes() > 0 - ? request.durationMinutes() - : (int) Duration.between(request.startTime(), request.endTime()).toMinutes(); - timeSlot.setDurationMinutes(duration); - } - - private boolean hasDuplicate(TimeSlotDto request, Long currentId) { - return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber()) - .filter(existing -> currentId == null || !existing.getId().equals(currentId)) - .isPresent(); + timeSlotService.delete(id); + return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); } private List effectiveSlots(LocalDate date) { diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index 9c2ae1a..80a1ff9 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -14,20 +14,20 @@ import com.magistr.app.model.User; import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.TeacherDepartmentService; +import com.magistr.app.service.BusinessTimeService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; @RestController @RequestMapping("/api/users") @@ -38,16 +38,33 @@ public class UserController { private final UserRepository userRepository; private final DepartmentRepository departmentRepository; private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; + private final TeacherDepartmentService teacherDepartmentService; private final BCryptPasswordEncoder passwordEncoder; + private final BusinessTimeService businessTime; public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository, - TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) { + TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, + TeacherDepartmentService teacherDepartmentService) { + this(userRepository, passwordEncoder, departmentRepository, + teacherDepartmentAssignmentRepository, teacherDepartmentService, + BusinessTimeService.systemDefault()); + } + + @Autowired + public UserController(UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + DepartmentRepository departmentRepository, + TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, + TeacherDepartmentService teacherDepartmentService, + BusinessTimeService businessTime) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.departmentRepository = departmentRepository; this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; + this.teacherDepartmentService = teacherDepartmentService; + this.businessTime = businessTime; } @GetMapping @@ -82,7 +99,10 @@ public class UserController { .body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры")); } logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); - List users = teachersForDepartment(departmentId, LocalDate.now()); + List users = teacherDepartmentService.findTeachersForDepartmentAtDate( + departmentId, + businessTime.today() + ); if (users.isEmpty()) { logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); @@ -92,7 +112,7 @@ public class UserController { logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); return ResponseEntity.ok(users.stream() - .map(this::toUserResponse) + .map(user -> toUserResponse(user, departmentId)) .toList()); } @@ -161,7 +181,7 @@ public class UserController { TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); assignment.setTeacher(user); assignment.setDepartment(requestedDepartment); - assignment.setValidFrom(LocalDate.now()); + assignment.setValidFrom(businessTime.today()); assignment.setPrimaryAssignment(true); assignment.setComment("Начальная кафедра при создании пользователя"); assignment.setCreatedBy(AuthContext.currentUserId()); @@ -180,7 +200,7 @@ public class UserController { logger.info("Пользователь с ID - {} не найден", id); return ResponseEntity.notFound().build(); } - user.archive("Пользователь архивирован"); + user.archive("Пользователь архивирован", businessTime.today(), businessTime.now()); userRepository.save(user); logger.info("Пользователь с ID - {} успешно архивирован", id); return ResponseEntity.ok(Map.of("message", "Пользователь архивирован")); @@ -206,7 +226,11 @@ public class UserController { var currentUser = AuthContext.getCurrentUser(); if (currentUser != null && currentUser.role() == Role.DEPARTMENT - && !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) { + && !teacherDepartmentService.hasAssignmentAtDate( + id, + currentUser.departmentId(), + businessTime.today() + )) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя")); } @@ -216,46 +240,13 @@ public class UserController { } @PostMapping("/{id}/department-transfer") - @Transactional public ResponseEntity transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) { - User teacher = userRepository.findById(id).orElse(null); - if (teacher == null || teacher.getRole() != Role.TEACHER) { - return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден")); - } - if (teacher.isArchivedRecord()) { - return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести")); - } - if (request.departmentId() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна")); - } - Department department = departmentRepository.findById(request.departmentId()).orElse(null); - if (department == null || department.isArchivedRecord()) { - return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена")); - } - LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom(); - - teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id) - .ifPresent(current -> { - if (current.getValidFrom().isBefore(validFrom)) { - current.setValidTo(validFrom.minusDays(1)); - teacherDepartmentAssignmentRepository.save(current); - } else { - teacherDepartmentAssignmentRepository.delete(current); - } - }); - - TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); - assignment.setTeacher(teacher); - assignment.setDepartment(department); - assignment.setValidFrom(validFrom); - assignment.setPrimaryAssignment(true); - assignment.setComment(request.comment()); - assignment.setCreatedBy(AuthContext.currentUserId()); - teacherDepartmentAssignmentRepository.save(assignment); - - teacher.setDepartmentId(department.getId()); - userRepository.save(teacher); - + TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher( + id, + request, + businessTime.today(), + AuthContext.currentUserId() + ); return ResponseEntity.ok(toAssignmentDto(assignment)); } @@ -268,16 +259,25 @@ public class UserController { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры")); } - LocalDate effectiveDate = date == null ? LocalDate.now() : date; - return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream() - .map(this::toUserResponse) + LocalDate effectiveDate = date == null ? businessTime.today() : date; + return ResponseEntity.ok(teacherDepartmentService + .findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream() + .map(user -> toUserResponse(user, departmentId)) .toList()); } private UserResponse toUserResponse(User user) { - String departmentName = user.getDepartmentId() == null + Long departmentId = user.getRole() == Role.TEACHER + ? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today()) + .orElse(user.getDepartmentId()) + : user.getDepartmentId(); + return toUserResponse(user, departmentId); + } + + private UserResponse toUserResponse(User user, Long departmentId) { + String departmentName = departmentId == null ? null - : departmentRepository.findById(user.getDepartmentId()) + : departmentRepository.findById(departmentId) .map(Department::getDepartmentName) .orElse("Неизвестно"); UserResponse response = new UserResponse( @@ -288,7 +288,7 @@ public class UserController { user.getJobTitle(), departmentName ); - response.setDepartmentId(user.getDepartmentId()); + response.setDepartmentId(departmentId); response.setStatus(user.getStatus()); return response; } @@ -307,23 +307,6 @@ public class UserController { ); } - private List teachersForDepartment(Long departmentId, LocalDate date) { - Map teachersById = new LinkedHashMap<>(); - List assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date); - if (assignments != null) { - assignments.stream() - .map(TeacherDepartmentAssignment::getTeacher) - .filter(Objects::nonNull) - .filter(User::isActiveRecord) - .forEach(teacher -> teachersById.put(teacher.getId(), teacher)); - } - List directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED); - if (directTeachers != null) { - directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher)); - } - return List.copyOf(teachersById.values()); - } - private boolean canAccessDepartment(Long departmentId) { var currentUser = AuthContext.getCurrentUser(); return currentUser == null diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java index 72c83fe..8246c7e 100644 --- a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java +++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java @@ -1,5 +1,6 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.ClassroomResponse; import com.magistr.app.dto.RenderedLessonDto; @@ -7,11 +8,13 @@ import com.magistr.app.dto.WorkloadSummaryDto; import com.magistr.app.model.*; import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.DepartmentRepository; -import com.magistr.app.repository.UserRepository; import com.magistr.app.service.ScheduleQueryService; +import com.magistr.app.service.TeacherDepartmentService; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.time.LocalDate; import java.util.*; @@ -27,17 +30,17 @@ public class WorkloadController { private final ScheduleQueryService scheduleQueryService; private final ClassroomRepository classroomRepository; - private final UserRepository userRepository; private final DepartmentRepository departmentRepository; + private final TeacherDepartmentService teacherDepartmentService; public WorkloadController(ScheduleQueryService scheduleQueryService, ClassroomRepository classroomRepository, - UserRepository userRepository, - DepartmentRepository departmentRepository) { + DepartmentRepository departmentRepository, + TeacherDepartmentService teacherDepartmentService) { this.scheduleQueryService = scheduleQueryService; this.classroomRepository = classroomRepository; - this.userRepository = userRepository; this.departmentRepository = departmentRepository; + this.teacherDepartmentService = teacherDepartmentService; } @GetMapping("/teachers") @@ -46,19 +49,11 @@ public class WorkloadController { @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @RequestParam(required = false) Long departmentId ) { - List lessons = scheduleQueryService.search(null, null, null, departmentId, - null, null, null, null, startDate, endDate); - Map teachersById = userRepository.findAllById(lessons.stream() - .map(RenderedLessonDto::teacherId) - .filter(Objects::nonNull) - .collect(Collectors.toSet())) - .stream() - .collect(Collectors.toMap(User::getId, Function.identity())); - return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName, - teacherId -> { - User teacher = teachersById.get(teacherId); - return teacher == null ? null : teacher.getDepartmentId(); - }); + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + List lessons = scheduleQueryService.searchForAggregation( + effectiveDepartmentId, null, startDate, endDate + ); + return summarizeTeacherWorkload(lessons, startDate, endDate); } @GetMapping("/classrooms") @@ -67,8 +62,10 @@ public class WorkloadController { @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @RequestParam(required = false) Long departmentId ) { - List lessons = scheduleQueryService.search(null, null, null, departmentId, - null, null, null, null, startDate, endDate); + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + List lessons = scheduleQueryService.searchForAggregation( + effectiveDepartmentId, null, startDate, endDate + ); return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null); } @@ -78,22 +75,20 @@ public class WorkloadController { @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @RequestParam(required = false) Long departmentId ) { - List lessons = scheduleQueryService.search(null, null, null, departmentId, - null, null, null, null, startDate, endDate); + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + List lessons = scheduleQueryService.searchForAggregation( + effectiveDepartmentId, null, startDate, endDate + ); Map departments = departmentRepository.findAll().stream() .collect(Collectors.toMap(Department::getId, Department::getDepartmentName)); - Map teachersById = userRepository.findAllById(lessons.stream() - .map(RenderedLessonDto::teacherId) - .filter(Objects::nonNull) - .collect(Collectors.toSet())) - .stream() - .collect(Collectors.toMap(User::getId, Function.identity())); + Map> assignmentsByTeacher = + assignmentsByTeacher(lessons, startDate, endDate); Map> byDepartment = lessons.stream() - .collect(Collectors.groupingBy(lesson -> { - User teacher = teachersById.get(lesson.teacherId()); - return teacher == null ? 0L : teacher.getDepartmentId(); - })); + .collect(Collectors.groupingBy(lesson -> departmentIdAtLesson( + lesson, + assignmentsByTeacher + ).orElse(0L))); return byDepartment.entrySet().stream() .map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue())) @@ -101,14 +96,76 @@ public class WorkloadController { .toList(); } + private List summarizeTeacherWorkload(List lessons, + LocalDate startDate, + LocalDate endDate) { + Map> assignmentsByTeacher = + assignmentsByTeacher(lessons, startDate, endDate); + Map> grouped = lessons.stream() + .filter(lesson -> lesson.teacherId() != null) + .collect(Collectors.groupingBy( + lesson -> new TeacherDepartmentKey( + lesson.teacherId(), + departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null) + ), + LinkedHashMap::new, + Collectors.toList() + )); + return grouped.entrySet().stream() + .map(entry -> summary( + entry.getKey().teacherId(), + entry.getValue().get(0).teacherName(), + entry.getKey().departmentId(), + entry.getValue() + )) + .sorted(Comparator + .comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)) + .thenComparing( + WorkloadSummaryDto::departmentId, + Comparator.nullsLast(Long::compareTo) + )) + .toList(); + } + + private Map> assignmentsByTeacher( + List lessons, + LocalDate startDate, + LocalDate endDate + ) { + Set teacherIds = lessons.stream() + .map(RenderedLessonDto::teacherId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return teacherDepartmentService.findAssignmentsForTeachersBetween( + teacherIds, + startDate, + endDate + ); + } + + private Optional departmentIdAtLesson( + RenderedLessonDto lesson, + Map> assignmentsByTeacher + ) { + if (lesson.teacherId() == null || lesson.date() == null) { + return Optional.empty(); + } + return teacherDepartmentService.resolveDepartmentIdAtDate( + assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()), + lesson.date() + ); + } + @GetMapping("/time-slots") public List timeSlotWorkload( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @RequestParam(required = false) Long departmentId ) { - List lessons = scheduleQueryService.search(null, null, null, departmentId, - null, null, null, null, startDate, endDate); + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + List lessons = scheduleQueryService.searchForAggregation( + effectiveDepartmentId, null, startDate, endDate + ); return summarize(lessons, RenderedLessonDto::timeSlotId, lesson -> lesson.timeSlotOrder() == null ? "Пара" @@ -121,8 +178,10 @@ public class WorkloadController { @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam Long timeSlotId ) { - Set busyClassroomIds = scheduleQueryService.search(null, null, null, null, - null, null, timeSlotId, null, date, date) + Long effectiveDepartmentId = effectiveDepartmentId(null); + Set busyClassroomIds = scheduleQueryService.searchForAggregation( + effectiveDepartmentId, timeSlotId, date, date + ) .stream() .map(RenderedLessonDto::classroomId) .filter(Objects::nonNull) @@ -166,6 +225,9 @@ public class WorkloadController { if (departmentId == null) { return null; } + if (departmentId == 0L) { + return "Кафедра не указана"; + } return departmentRepository.findById(departmentId) .map(Department::getDepartmentName) .orElse("Кафедра не найдена"); @@ -185,4 +247,35 @@ public class WorkloadController { new ArrayList<>(classroom.getEquipments()) ); } + + private Long effectiveDepartmentId(Long requestedDepartmentId) { + var currentUser = AuthContext.getCurrentUser(); + if (currentUser == null) { + throw new ResponseStatusException( + HttpStatus.UNAUTHORIZED, + "Не удалось определить текущего пользователя" + ); + } + if (currentUser.role() != Role.DEPARTMENT) { + return requestedDepartmentId; + } + + Long ownDepartmentId = currentUser.departmentId(); + if (ownDepartmentId == null) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Для пользователя кафедры не указана кафедра" + ); + } + if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Нельзя просматривать нагрузку другой кафедры" + ); + } + return ownDepartmentId; + } + + private record TeacherDepartmentKey(Long teacherId, Long departmentId) { + } } diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java index a587057..28b4e74 100755 --- a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java @@ -1,7 +1,7 @@ package com.magistr.app.dto; import com.magistr.app.model.Equipment; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; public class ClassroomResponse { @@ -12,7 +12,7 @@ public class ClassroomResponse { private Integer floor; private Boolean isAvailable; private String status; - private LocalDateTime archivedAt; + private Instant archivedAt; private String archiveReason; private List equipments; @@ -25,7 +25,7 @@ public class ClassroomResponse { } public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor, - Boolean isAvailable, String status, LocalDateTime archivedAt, + Boolean isAvailable, String status, Instant archivedAt, String archiveReason, List equipments) { this.id = id; this.name = name; @@ -95,11 +95,11 @@ public class ClassroomResponse { this.status = status; } - public LocalDateTime getArchivedAt() { + public Instant getArchivedAt() { return archivedAt; } - public void setArchivedAt(LocalDateTime archivedAt) { + public void setArchivedAt(Instant archivedAt) { this.archivedAt = archivedAt; } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java index 86a6f5e..73c0c05 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java @@ -1,7 +1,7 @@ package com.magistr.app.dto; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.Instant; public record ScheduleOverrideDto( Long id, @@ -14,6 +14,6 @@ public record ScheduleOverrideDto( String newLessonFormat, String comment, Long createdBy, - LocalDateTime createdAt + Instant createdAt ) { } diff --git a/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java b/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java index 73a3777..7060895 100644 --- a/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java +++ b/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java @@ -1,6 +1,6 @@ package com.magistr.app.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record SubjectCommentDto( Long id, @@ -8,6 +8,6 @@ public record SubjectCommentDto( Long authorId, String authorName, String comment, - LocalDateTime createdAt + Instant createdAt ) { } diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java b/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java index f6d921a..a8df6e0 100644 --- a/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java @@ -1,6 +1,6 @@ package com.magistr.app.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record TeacherCreationRequestResponse( Long id, @@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse( String comment, String status, Long requestedBy, - LocalDateTime createdAt, + Instant createdAt, Long reviewedBy, - LocalDateTime reviewedAt, + Instant reviewedAt, String reviewComment, Long createdTeacherId ) { diff --git a/backend/src/main/java/com/magistr/app/model/AuthLoginAttemptAudit.java b/backend/src/main/java/com/magistr/app/model/AuthLoginAttemptAudit.java new file mode 100644 index 0000000..ef5630b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AuthLoginAttemptAudit.java @@ -0,0 +1,78 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "auth_login_attempt_audit") +public class AuthLoginAttemptAudit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String tenant; + + @Column(name = "username_normalized", nullable = false, length = 100) + private String usernameNormalized; + + @Column(name = "client_ip", nullable = false, length = 64) + private String clientIp; + + @Column(nullable = false, length = 20) + private String outcome; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Column(name = "retry_after_seconds") + private Integer retryAfterSeconds; + + public AuthLoginAttemptAudit() { + } + + public AuthLoginAttemptAudit(String tenant, + String usernameNormalized, + String clientIp, + String outcome, + Instant occurredAt, + Integer retryAfterSeconds) { + this.tenant = tenant; + this.usernameNormalized = usernameNormalized; + this.clientIp = clientIp; + this.outcome = outcome; + this.occurredAt = occurredAt; + this.retryAfterSeconds = retryAfterSeconds; + } + + public String getTenant() { + return tenant; + } + + public String getUsernameNormalized() { + return usernameNormalized; + } + + public String getClientIp() { + return clientIp; + } + + public String getOutcome() { + return outcome; + } + + public Instant getOccurredAt() { + return occurredAt; + } + + public Integer getRetryAfterSeconds() { + return retryAfterSeconds; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AuthLoginRateLimit.java b/backend/src/main/java/com/magistr/app/model/AuthLoginRateLimit.java new file mode 100644 index 0000000..6a7564f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AuthLoginRateLimit.java @@ -0,0 +1,83 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "auth_login_rate_limits") +public class AuthLoginRateLimit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String tenant; + + @Column(name = "username_normalized", nullable = false, length = 100) + private String usernameNormalized; + + @Column(name = "client_ip", nullable = false, length = 64) + private String clientIp; + + @Column(name = "failure_count", nullable = false) + private int failureCount; + + @Column(name = "window_started_at", nullable = false) + private Instant windowStartedAt; + + @Column(name = "last_failure_at") + private Instant lastFailureAt; + + @Column(name = "blocked_until") + private Instant blockedUntil; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + public Long getId() { + return id; + } + + public int getFailureCount() { + return failureCount; + } + + public void setFailureCount(int failureCount) { + this.failureCount = failureCount; + } + + public Instant getWindowStartedAt() { + return windowStartedAt; + } + + public void setWindowStartedAt(Instant windowStartedAt) { + this.windowStartedAt = windowStartedAt; + } + + public Instant getLastFailureAt() { + return lastFailureAt; + } + + public void setLastFailureAt(Instant lastFailureAt) { + this.lastFailureAt = lastFailureAt; + } + + public Instant getBlockedUntil() { + return blockedUntil; + } + + public void setBlockedUntil(Instant blockedUntil) { + this.blockedUntil = blockedUntil; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java b/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java index ca4137e..a84d883 100644 --- a/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java +++ b/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java @@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "auth_refresh_tokens") @@ -31,13 +31,13 @@ public class AuthRefreshToken { private String tokenHash; @Column(name = "issued_at", nullable = false) - private LocalDateTime issuedAt; + private Instant issuedAt; @Column(name = "expires_at", nullable = false) - private LocalDateTime expiresAt; + private Instant expiresAt; @Column(name = "revoked_at") - private LocalDateTime revokedAt; + private Instant revokedAt; @Column(name = "rotated_to_token_hash", length = 64) private String rotatedToTokenHash; @@ -80,27 +80,27 @@ public class AuthRefreshToken { this.tokenHash = tokenHash; } - public LocalDateTime getIssuedAt() { + public Instant getIssuedAt() { return issuedAt; } - public void setIssuedAt(LocalDateTime issuedAt) { + public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; } - public LocalDateTime getExpiresAt() { + public Instant getExpiresAt() { return expiresAt; } - public void setExpiresAt(LocalDateTime expiresAt) { + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } - public LocalDateTime getRevokedAt() { + public Instant getRevokedAt() { return revokedAt; } - public void setRevokedAt(LocalDateTime revokedAt) { + public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; } @@ -128,7 +128,7 @@ public class AuthRefreshToken { this.ipAddress = ipAddress; } - public boolean isActive(LocalDateTime now) { + public boolean isActive(Instant now) { return revokedAt == null && expiresAt.isAfter(now); } } diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java index 8de4e61..7fa22d7 100644 --- a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java +++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java @@ -5,7 +5,8 @@ import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneId; @MappedSuperclass public abstract class LifecycleEntity { @@ -23,7 +24,7 @@ public abstract class LifecycleEntity { private LocalDate activeTo; @Column(name = "archived_at") - private LocalDateTime archivedAt; + private Instant archivedAt; @Column(name = "archive_reason") private String archiveReason; @@ -52,11 +53,11 @@ public abstract class LifecycleEntity { this.activeTo = activeTo; } - public LocalDateTime getArchivedAt() { + public Instant getArchivedAt() { return archivedAt; } - public void setArchivedAt(LocalDateTime archivedAt) { + public void setArchivedAt(Instant archivedAt) { this.archivedAt = archivedAt; } @@ -92,9 +93,13 @@ public abstract class LifecycleEntity { } public void archive(String reason) { + archive(reason, LocalDate.now(ZoneId.of("Europe/Moscow")), Instant.now()); + } + + public void archive(String reason, LocalDate businessDate, Instant archiveMoment) { status = STATUS_ARCHIVED; - archivedAt = LocalDateTime.now(); - activeTo = LocalDate.now(); + archivedAt = archiveMoment; + activeTo = businessDate; archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim(); } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java index 85c9bbb..b914817 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java @@ -3,7 +3,7 @@ package com.magistr.app.model; import jakarta.persistence.*; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "schedule_overrides") @@ -45,7 +45,7 @@ public class ScheduleOverride { private Long createdBy; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt = Instant.now(); public Long getId() { return id; @@ -123,11 +123,11 @@ public class ScheduleOverride { this.createdBy = createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } } diff --git a/backend/src/main/java/com/magistr/app/model/Subject.java b/backend/src/main/java/com/magistr/app/model/Subject.java index 10e1751..c63db8e 100755 --- a/backend/src/main/java/com/magistr/app/model/Subject.java +++ b/backend/src/main/java/com/magistr/app/model/Subject.java @@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(unique = true, nullable = false, length = 200) + @Column(nullable = false, length = 200) private String name; @Column(name = "code") diff --git a/backend/src/main/java/com/magistr/app/model/SubjectComment.java b/backend/src/main/java/com/magistr/app/model/SubjectComment.java index b1b8d38..a8f180c 100644 --- a/backend/src/main/java/com/magistr/app/model/SubjectComment.java +++ b/backend/src/main/java/com/magistr/app/model/SubjectComment.java @@ -2,7 +2,7 @@ package com.magistr.app.model; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "subject_comments") @@ -24,7 +24,7 @@ public class SubjectComment { private String comment; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt = Instant.now(); public Long getId() { return id; @@ -54,11 +54,11 @@ public class SubjectComment { this.comment = comment; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } } diff --git a/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java index 3f4cda5..3dceb26 100644 --- a/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java +++ b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java @@ -2,7 +2,7 @@ package com.magistr.app.model; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "teacher_creation_requests") @@ -36,16 +36,16 @@ public class TeacherCreationRequest { private Long requestedBy; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt = Instant.now(); @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt = LocalDateTime.now(); + private Instant updatedAt = Instant.now(); @Column(name = "reviewed_by") private Long reviewedBy; @Column(name = "reviewed_at") - private LocalDateTime reviewedAt; + private Instant reviewedAt; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @@ -55,7 +55,7 @@ public class TeacherCreationRequest { @PreUpdate public void touch() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(); } public Long getId() { @@ -118,19 +118,19 @@ public class TeacherCreationRequest { this.requestedBy = requestedBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - public LocalDateTime getUpdatedAt() { + public Instant getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } @@ -142,11 +142,11 @@ public class TeacherCreationRequest { this.reviewedBy = reviewedBy; } - public LocalDateTime getReviewedAt() { + public Instant getReviewedAt() { return reviewedAt; } - public void setReviewedAt(LocalDateTime reviewedAt) { + public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } diff --git a/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java b/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java index 737d249..ab03443 100644 --- a/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java +++ b/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java @@ -3,7 +3,7 @@ package com.magistr.app.model; import jakarta.persistence.*; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "teacher_department_assignments") @@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment { private String comment; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt = Instant.now(); @Column(name = "created_by") private Long createdBy; @@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment { this.comment = comment; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java index 761e1be..03f062e 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository findForScheduleBatch( + @Param("calendarIds") Collection calendarIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + boolean existsByActivityTypeId(Long activityTypeId); + boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber); + + @Query(""" + select case when count(day) > 0 then true else false end + from AcademicCalendarDay day + where day.academicCalendar.id = :calendarId + and (day.date < :startDate or day.date > :endDate) + """) + boolean existsOutsideDateRange(@Param("calendarId") Long calendarId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + @Modifying @Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId") void deleteByCalendarId(@Param("calendarId") Long calendarId); diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java index fdb8ec8..51ee39d 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -1,7 +1,9 @@ package com.magistr.app.repository; import com.magistr.app.model.AcademicCalendar; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository findByIdWithDetails(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where calendar.id = :id + """) + Optional findByIdWithDetailsForUpdate(@Param("id") Long id); } diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java index 3b40083..def3776 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java @@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository findByCalendarIdInWithSubject(@Param("calendarIds") Collection calendarIds); void deleteByAcademicCalendar_Id(Long calendarId); + + boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber); } diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java index ffc3846..f04456f 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java @@ -1,11 +1,44 @@ package com.magistr.app.repository; import com.magistr.app.model.AcademicYear; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.Optional; public interface AcademicYearRepository extends JpaRepository { Optional findByTitle(String title); + + boolean existsByTitle(String title); + + boolean existsByTitleAndIdNot(String title, Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select year from AcademicYear year where year.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Query(""" + select case when count(year) > 0 then true else false end + from AcademicYear year + where year.startDate <= :endDate + and year.endDate >= :startDate + """) + boolean existsOverlapping(@Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + select case when count(year) > 0 then true else false end + from AcademicYear year + where year.id <> :excludedId + and year.startDate <= :endDate + and year.endDate >= :startDate + """) + boolean existsOverlappingExcluding(@Param("excludedId") Long excludedId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); } diff --git a/backend/src/main/java/com/magistr/app/repository/AuthLoginAttemptAuditRepository.java b/backend/src/main/java/com/magistr/app/repository/AuthLoginAttemptAuditRepository.java new file mode 100644 index 0000000..fc311d9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AuthLoginAttemptAuditRepository.java @@ -0,0 +1,31 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AuthLoginAttemptAudit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +public interface AuthLoginAttemptAuditRepository extends JpaRepository { + + @Modifying + @Transactional + @Query(value = """ + WITH cleanup_candidates AS ( + SELECT id + FROM auth_login_attempt_audit + WHERE occurred_at < :cutoff + ORDER BY occurred_at, id + FOR UPDATE SKIP LOCKED + LIMIT :batchSize + ) + DELETE FROM auth_login_attempt_audit audit + USING cleanup_candidates candidate + WHERE audit.id = candidate.id + """, nativeQuery = true) + int deleteCleanupBatch(@Param("cutoff") Instant cutoff, + @Param("batchSize") int batchSize); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AuthLoginRateLimitRepository.java b/backend/src/main/java/com/magistr/app/repository/AuthLoginRateLimitRepository.java new file mode 100644 index 0000000..2a3cef3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AuthLoginRateLimitRepository.java @@ -0,0 +1,61 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AuthLoginRateLimit; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Optional; + +public interface AuthLoginRateLimitRepository extends JpaRepository { + + @Modifying(flushAutomatically = true) + @Query(value = """ + INSERT INTO auth_login_rate_limits ( + tenant, username_normalized, client_ip, failure_count, + window_started_at, updated_at + ) VALUES (:tenant, :username, :clientIp, 0, :now, :now) + ON CONFLICT (tenant, username_normalized, client_ip) DO NOTHING + """, nativeQuery = true) + int ensureExists(@Param("tenant") String tenant, + @Param("username") String username, + @Param("clientIp") String clientIp, + @Param("now") Instant now); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT limit + FROM AuthLoginRateLimit limit + WHERE limit.tenant = :tenant + AND limit.usernameNormalized = :username + AND limit.clientIp = :clientIp + """) + Optional findForUpdate(@Param("tenant") String tenant, + @Param("username") String username, + @Param("clientIp") String clientIp); + + @Modifying + @Transactional + @Query(value = """ + WITH cleanup_candidates AS ( + SELECT id + FROM auth_login_rate_limits + WHERE updated_at < :cutoff + AND (blocked_until IS NULL OR blocked_until <= :now) + ORDER BY updated_at, id + FOR UPDATE SKIP LOCKED + LIMIT :batchSize + ) + DELETE FROM auth_login_rate_limits rate_limit + USING cleanup_candidates candidate + WHERE rate_limit.id = candidate.id + """, nativeQuery = true) + int deleteStaleBatch(@Param("cutoff") Instant cutoff, + @Param("now") Instant now, + @Param("batchSize") int batchSize); +} 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 828e474..cf2d2c1 100644 --- a/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java @@ -4,9 +4,12 @@ import com.magistr.app.model.AuthRefreshToken; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.Optional; public interface AuthRefreshTokenRepository extends JpaRepository { @@ -21,4 +24,27 @@ public interface AuthRefreshTokenRepository extends JpaRepository findByTokenHashForUpdate(@Param("tokenHash") String tokenHash); + + /** + * Удаляет ограниченную пачку старых audit-записей. SKIP LOCKED позволяет нескольким + * backend-pod безопасно разбирать непересекающиеся пачки одной tenant-БД. + */ + @Modifying + @Transactional + @Query(value = """ + WITH cleanup_candidates AS ( + SELECT id + FROM auth_refresh_tokens + WHERE expires_at < :cutoff + OR (revoked_at IS NOT NULL AND revoked_at < :cutoff) + ORDER BY LEAST(expires_at, COALESCE(revoked_at, expires_at)), id + FOR UPDATE SKIP LOCKED + LIMIT :batchSize + ) + DELETE FROM auth_refresh_tokens token + USING cleanup_candidates candidate + WHERE token.id = candidate.id + """, nativeQuery = true) + int deleteCleanupBatch(@Param("cutoff") Instant cutoff, + @Param("batchSize") int batchSize); } diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java index 4153fc8..4cdfe96 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -1,9 +1,14 @@ package com.magistr.app.repository; import com.magistr.app.model.StudentGroup; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface GroupRepository extends JpaRepository { @@ -14,4 +19,15 @@ public interface GroupRepository extends JpaRepository { List findByStatusNot(String status); List findByDepartmentIdAndStatusNot(Long departmentId, String status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select studentGroup + from StudentGroup studentGroup + join fetch studentGroup.educationForm + join fetch studentGroup.speciality + join fetch studentGroup.specialtyProfile + where studentGroup.id = :id + """) + Optional findByIdForUpdate(@Param("id") Long id); } 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 f4fdfe3..6cf7f28 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; +import java.util.Collection; public interface ScheduleRuleRepository extends JpaRepository { @@ -60,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' + """) + List findByGroupIdsAndSemesterIds( + @Param("groupIds") Collection groupIds, + @Param("semesterIds") Collection semesterIds + ); + + @Query(""" + select distinct r + from ScheduleRule r + join r.slots teacherSlot + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.subgroups slotSubgroups + left join fetch slotSubgroups.studentGroup + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where teacherSlot.teacher.id = :teacherId + and sem.id in :semesterIds + and r.status <> 'ARCHIVED' + """) + List findByTeacherIdAndSemesterIds( + @Param("teacherId") Long teacherId, + @Param("semesterIds") Collection semesterIds + ); + @Query(""" select distinct r from ScheduleRule r diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java index 72f71cb..b024477 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java @@ -9,4 +9,6 @@ public interface ScheduleRuleSlotRepository extends JpaRepository 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId") boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId); + + boolean existsByTimeSlotId(Long timeSlotId); } 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 0f27097..6d38ed5 100644 --- a/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java @@ -17,10 +17,51 @@ public interface SemesterRepository extends JpaRepository { Optional findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate); + @Query(""" + select semester + from Semester semester + join fetch semester.academicYear + where semester.startDate <= :endDate + and semester.endDate >= :startDate + order by semester.startDate, semester.id + """) + List findOverlappingForSchedule(@Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + List findByAcademicYearIdOrderByStartDateAsc(Long academicYearId); Optional findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType); + boolean existsByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType); + + boolean existsByAcademicYearIdAndSemesterTypeAndIdNot(Long academicYearId, + SemesterType semesterType, + Long id); + + @Query(""" + select case when count(semester) > 0 then true else false end + from Semester semester + where semester.academicYear.id = :academicYearId + and semester.startDate <= :endDate + and semester.endDate >= :startDate + """) + boolean existsOverlapping(@Param("academicYearId") Long academicYearId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query(""" + select case when count(semester) > 0 then true else false end + from Semester semester + where semester.academicYear.id = :academicYearId + and semester.id <> :excludedId + and semester.startDate <= :endDate + and semester.endDate >= :startDate + """) + boolean existsOverlappingExcluding(@Param("academicYearId") Long academicYearId, + @Param("excludedId") Long excludedId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select semester from Semester semester where semester.id = :id") Optional findByIdForUpdate(@Param("id") Long id); diff --git a/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java index 446c99d..5a48ba8 100644 --- a/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java @@ -1,12 +1,15 @@ package com.magistr.app.repository; import com.magistr.app.model.StudentGroupCalendarAssignment; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; +import java.util.Collection; public interface StudentGroupCalendarAssignmentRepository extends JpaRepository { @@ -51,4 +54,52 @@ public interface StudentGroupCalendarAssignmentRepository extends JpaRepository< @Param("groupId") Long groupId, @Param("academicYearId") Long academicYearId ); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.studentGroup + join fetch assignment.academicYear + join fetch assignment.academicCalendar + where assignment.studentGroup.id in :groupIds + and assignment.academicYear.id in :academicYearIds + """) + List findForScheduleBatch( + @Param("groupIds") Collection groupIds, + @Param("academicYearIds") Collection academicYearIds + ); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.studentGroup studentGroup + join fetch studentGroup.educationForm + join fetch studentGroup.speciality + join fetch studentGroup.specialtyProfile + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where calendar.id = :calendarId + order by assignment.id + """) + List findByCalendarIdWithDetails(@Param("calendarId") Long calendarId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findByGroupIdAndAcademicYearIdForUpdate( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); } diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java index dfb4888..bf942d0 100755 --- a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Optional; public interface SubjectRepository extends JpaRepository { - Optional findByName(String name); + Optional findByNameIgnoreCase(String name); List findByDepartmentId(Long departmentId); diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java index be94f19..6bc65f7 100644 --- a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java @@ -1,13 +1,16 @@ package com.magistr.app.repository; import com.magistr.app.model.TeacherDepartmentAssignment; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDate; import java.util.List; import java.util.Optional; +import java.util.Set; public interface TeacherDepartmentAssignmentRepository extends JpaRepository { @@ -60,4 +63,46 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository= :date) + order by a.validFrom desc, a.id desc + """) + Optional findPrimaryAtDate( + @Param("teacherId") Long teacherId, + @Param("date") LocalDate date + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.department + where a.teacher.id = :teacherId + and a.primaryAssignment = true + order by a.validFrom, a.id + """) + List findPrimaryHistoryForUpdate(@Param("teacherId") Long teacherId); + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.teacher.id in :teacherIds + and a.validFrom <= :endDate + and (a.validTo is null or a.validTo >= :startDate) + order by a.teacher.id, a.validFrom, a.id + """) + List findForTeachersBetween( + @Param("teacherIds") Set teacherIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java index 4fd642c..70bc231 100644 --- a/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java @@ -2,6 +2,8 @@ package com.magistr.app.repository; import com.magistr.app.model.TimeSlotDateAssignment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDate; import java.util.List; @@ -15,5 +17,15 @@ public interface TimeSlotDateAssignmentRepository extends JpaRepository findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate); + @Query(""" + select assignment + from TimeSlotDateAssignment assignment + join fetch assignment.timeSlotScope + where assignment.date between :startDate and :endDate + order by assignment.date + """) + List findForScheduleRange(@Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + boolean existsByTimeSlotScopeId(Long scopeId); } diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java index dc364e9..2428fde 100644 --- a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java @@ -1,7 +1,13 @@ package com.magistr.app.repository; import com.magistr.app.model.TimeSlot; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -14,5 +20,45 @@ public interface TimeSlotRepository extends JpaRepository { Optional findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber); + @Query(""" + select slot + from TimeSlot slot + join fetch slot.timeSlotScope + order by slot.timeSlotScope.displayOrder, slot.orderNumber, slot.startTime + """) + List findAllForSchedule(); + + boolean existsByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber); + + boolean existsByTimeSlotScopeIdAndOrderNumberAndIdNot(Long scopeId, Integer orderNumber, Long id); + boolean existsByTimeSlotScopeId(Long scopeId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select slot from TimeSlot slot where slot.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Query(""" + select case when count(slot) > 0 then true else false end + from TimeSlot slot + where slot.timeSlotScope.id = :scopeId + and slot.startTime < :endTime + and slot.endTime > :startTime + """) + boolean existsOverlapping(@Param("scopeId") Long scopeId, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime); + + @Query(""" + select case when count(slot) > 0 then true else false end + from TimeSlot slot + where slot.timeSlotScope.id = :scopeId + and slot.id <> :excludedId + and slot.startTime < :endTime + and slot.endTime > :startTime + """) + boolean existsOverlappingExcluding(@Param("scopeId") Long scopeId, + @Param("excludedId") Long excludedId, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime); } diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java index a03a02c..cfc5fd1 100644 --- a/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java @@ -1,7 +1,11 @@ package com.magistr.app.repository; import com.magistr.app.model.TimeSlotScope; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -15,4 +19,10 @@ public interface TimeSlotScopeRepository extends JpaRepository findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode); Optional findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek); + + List findByApplyModeOrderByDisplayOrderAsc(String applyMode); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select scope from TimeSlotScope scope where scope.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java index f0510b2..95e9700 100755 --- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -2,7 +2,11 @@ package com.magistr.app.repository; import com.magistr.app.model.Role; import com.magistr.app.model.User; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -20,4 +24,8 @@ public interface UserRepository extends JpaRepository { List findByRoleAndStatusNot(Role role, String status); List findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select user from User user where user.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java index 8180415..0d9a0ad 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -9,7 +9,9 @@ import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.temporal.ChronoUnit; -import java.util.Optional; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; @Service public class AcademicDateService { @@ -33,6 +35,64 @@ public class AcademicDateService { return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date); } + public List findSemesters(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null || endDate.isBefore(startDate)) { + return List.of(); + } + return semesterRepository.findOverlappingForSchedule(startDate, endDate); + } + + /** + * Загружает календарные данные одним набором batch-запросов и возвращает снимок, + * который не обращается к репозиториям во внутренних циклах генератора. + */ + public ScheduleSnapshot createScheduleSnapshot(Collection groups, + Collection semesters, + LocalDate endDate) { + List semesterSnapshot = Optional.ofNullable(semesters) + .orElseGet(List::of) + .stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(Semester::getStartDate).thenComparing(Semester::getId)) + .toList(); + LocalDate snapshotStart = semesterSnapshot.stream() + .map(Semester::getStartDate) + .min(LocalDate::compareTo) + .orElse(endDate); + + Set groupIds = Optional.ofNullable(groups) + .orElseGet(List::of) + .stream() + .filter(Objects::nonNull) + .map(StudentGroup::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set academicYearIds = semesterSnapshot.stream() + .map(Semester::getAcademicYear) + .filter(Objects::nonNull) + .map(AcademicYear::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + List assignments = groupIds.isEmpty() || academicYearIds.isEmpty() + ? List.of() + : assignmentRepository.findForScheduleBatch(groupIds, academicYearIds); + Set calendarIds = assignments.stream() + .map(StudentGroupCalendarAssignment::getAcademicCalendar) + .filter(Objects::nonNull) + .map(AcademicCalendar::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List calendarDays = calendarIds.isEmpty() + || snapshotStart == null + || endDate == null + || endDate.isBefore(snapshotStart) + ? List.of() + : calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate); + + return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate); + } + public int getWeekNumber(Semester semester, LocalDate date) { long days = ChronoUnit.DAYS.between(semester.getStartDate(), date); if (days < 0) { @@ -86,4 +146,128 @@ public class AcademicDateService { } return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent(); } + + public static final class ScheduleSnapshot { + + private final Map semesterByDate; + private final Map assignmentByGroupAndYear; + private final Map activityByCalendarCourseAndDate; + private final LocalDate startDate; + private final LocalDate endDate; + + private ScheduleSnapshot(Collection semesters, + Collection assignments, + Collection calendarDays, + LocalDate startDate, + LocalDate endDate) { + this.startDate = startDate; + this.endDate = endDate; + this.semesterByDate = indexSemesters(semesters, startDate, endDate); + this.assignmentByGroupAndYear = assignments.stream() + .collect(Collectors.toUnmodifiableMap( + assignment -> new GroupAcademicYearKey( + assignment.getStudentGroup().getId(), + assignment.getAcademicYear().getId() + ), + Function.identity() + )); + this.activityByCalendarCourseAndDate = calendarDays.stream() + .collect(Collectors.toUnmodifiableMap( + day -> new CalendarDayKey( + day.getAcademicCalendar().getId(), + day.getCourseNumber(), + day.getDate() + ), + AcademicCalendarDay::getActivityType + )); + } + + public Optional findSemester(LocalDate date) { + return Optional.ofNullable(semesterByDate.get(date)); + } + + public boolean hasCalendarAssignment(StudentGroup group, Semester semester) { + return findAssignment(group, semester).isPresent(); + } + + public Optional getActivityType(StudentGroup group, + Semester semester, + LocalDate date) { + int courseNumber = courseNumber(group, date); + if (courseNumber < 1) { + return Optional.empty(); + } + return findAssignment(group, semester) + .map(StudentGroupCalendarAssignment::getAcademicCalendar) + .map(AcademicCalendar::getId) + .map(calendarId -> activityByCalendarCourseAndDate.get( + new CalendarDayKey(calendarId, courseNumber, date) + )); + } + + public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { + return getActivityType(group, semester, date) + .map(AcademicCalendarActivityType::getAllowSchedule) + .orElse(false); + } + + public LocalDate startDate() { + return startDate; + } + + public LocalDate endDate() { + return endDate; + } + + private Optional findAssignment(StudentGroup group, Semester semester) { + if (group == null + || group.getId() == null + || semester == null + || semester.getAcademicYear() == null + || semester.getAcademicYear().getId() == null) { + return Optional.empty(); + } + return Optional.ofNullable(assignmentByGroupAndYear.get( + new GroupAcademicYearKey(group.getId(), semester.getAcademicYear().getId()) + )); + } + + private static Map indexSemesters(Collection semesters, + LocalDate startDate, + LocalDate endDate) { + if (startDate == null || endDate == null || endDate.isBefore(startDate)) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Semester semester : semesters) { + LocalDate effectiveStart = semester.getStartDate().isAfter(startDate) + ? semester.getStartDate() + : startDate; + LocalDate effectiveEnd = semester.getEndDate().isBefore(endDate) + ? semester.getEndDate() + : endDate; + for (LocalDate date = effectiveStart; !date.isAfter(effectiveEnd); date = date.plusDays(1)) { + Semester previous = result.putIfAbsent(date, semester); + if (previous != null && !Objects.equals(previous.getId(), semester.getId())) { + throw new IllegalStateException("На одну дату назначено несколько семестров"); + } + } + } + return Map.copyOf(result); + } + } + + private static int courseNumber(StudentGroup group, LocalDate date) { + if (group == null || date == null || group.getYearStartStudy() == null) { + return 0; + } + int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1; + return academicYearStart - group.getYearStartStudy() + 1; + } + + private record GroupAcademicYearKey(Long groupId, Long academicYearId) { + } + + private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) { + } } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java b/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java new file mode 100644 index 0000000..099a373 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java @@ -0,0 +1,251 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.AcademicYearDto; +import com.magistr.app.dto.SemesterDto; +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.Semester; +import com.magistr.app.repository.AcademicYearRepository; +import com.magistr.app.repository.SemesterRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDate; +import java.util.NoSuchElementException; + +@Service +public class AcademicPeriodService { + + private static final String YEAR_CONFLICT_MESSAGE = + "Название или период учебного года конфликтует с существующим учебным годом"; + private static final String SEMESTER_CONFLICT_MESSAGE = + "Тип или период семестра конфликтует с существующим семестром учебного года"; + + private final AcademicYearRepository academicYearRepository; + private final SemesterRepository semesterRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicPeriodService(AcademicYearRepository academicYearRepository, + SemesterRepository semesterRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.academicYearRepository = academicYearRepository; + this.semesterRepository = semesterRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @Transactional + public AcademicYear createYear(AcademicYearDto request) { + validateYear(request); + String title = request.title().trim(); + if (academicYearRepository.existsByTitle(title) + || academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) { + throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); + } + + AcademicYear year = new AcademicYear(); + applyYear(year, title, request.startDate(), request.endDate()); + AcademicYear saved = saveYear(year); + clearScheduleCacheAfterCommit(); + return saved; + } + + @Transactional + public AcademicYear updateYear(Long id, AcademicYearDto request) { + validateYear(request); + AcademicYear year = lockYear(id); + String title = request.title().trim(); + + if (academicYearRepository.existsByTitleAndIdNot(title, id) + || academicYearRepository.existsOverlappingExcluding( + id, request.startDate(), request.endDate())) { + throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); + } + boolean semesterOutsideNewRange = semesterRepository + .findByAcademicYearIdOrderByStartDateAsc(id) + .stream() + .anyMatch(semester -> !contains( + request.startDate(), + request.endDate(), + semester.getStartDate(), + semester.getEndDate() + )); + if (semesterOutsideNewRange) { + throw new IllegalArgumentException( + "Новые границы учебного года не включают все его семестры" + ); + } + + applyYear(year, title, request.startDate(), request.endDate()); + AcademicYear saved = saveYear(year); + clearScheduleCacheAfterCommit(); + return saved; + } + + @Transactional + public void deleteYear(Long id) { + AcademicYear year = lockYear(id); + academicYearRepository.delete(year); + academicYearRepository.flush(); + clearScheduleCacheAfterCommit(); + } + + @Transactional + public Semester createSemester(Long academicYearId, SemesterDto request) { + validateSemester(request); + AcademicYear year = lockYear(academicYearId); + validateSemesterWithinYear(year, request.startDate(), request.endDate()); + ensureSemesterAvailable(year.getId(), request, null); + + Semester semester = new Semester(); + semester.setAcademicYear(year); + applySemester(semester, request); + Semester saved = saveSemester(semester); + clearScheduleCacheAfterCommit(); + return saved; + } + + @Transactional + public Semester updateSemester(Long id, SemesterDto request) { + validateSemester(request); + Semester snapshot = semesterRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Семестр не найден")); + AcademicYear year = lockYear(snapshot.getAcademicYear().getId()); + Semester semester = semesterRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Семестр не найден")); + if (!semester.getAcademicYear().getId().equals(year.getId())) { + throw new ScheduleConflictException("Учебный год семестра был изменён конкурентно"); + } + + validateSemesterWithinYear(year, request.startDate(), request.endDate()); + ensureSemesterAvailable(year.getId(), request, id); + applySemester(semester, request); + Semester saved = saveSemester(semester); + clearScheduleCacheAfterCommit(); + return saved; + } + + private void validateYear(AcademicYearDto request) { + if (request == null) { + throw new IllegalArgumentException("Данные учебного года обязательны"); + } + if (request.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Название учебного года обязательно"); + } + validateDateRange( + request.startDate(), + request.endDate(), + "Даты начала и окончания учебного года обязательны", + "Дата окончания учебного года не может быть раньше даты начала" + ); + } + + private void validateSemester(SemesterDto request) { + if (request == null) { + throw new IllegalArgumentException("Данные семестра обязательны"); + } + if (request.semesterType() == null) { + throw new IllegalArgumentException("Тип семестра обязателен"); + } + validateDateRange( + request.startDate(), + request.endDate(), + "Даты начала и окончания семестра обязательны", + "Дата окончания семестра не может быть раньше даты начала" + ); + } + + private void validateDateRange(LocalDate startDate, + LocalDate endDate, + String requiredMessage, + String orderMessage) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException(requiredMessage); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException(orderMessage); + } + } + + private void validateSemesterWithinYear(AcademicYear year, + LocalDate startDate, + LocalDate endDate) { + if (!contains(year.getStartDate(), year.getEndDate(), startDate, endDate)) { + throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года"); + } + } + + private boolean contains(LocalDate outerStart, + LocalDate outerEnd, + LocalDate innerStart, + LocalDate innerEnd) { + return !innerStart.isBefore(outerStart) && !innerEnd.isAfter(outerEnd); + } + + private void ensureSemesterAvailable(Long academicYearId, SemesterDto request, Long currentId) { + boolean duplicateType = currentId == null + ? semesterRepository.existsByAcademicYearIdAndSemesterType( + academicYearId, request.semesterType()) + : semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot( + academicYearId, request.semesterType(), currentId); + boolean overlapping = currentId == null + ? semesterRepository.existsOverlapping( + academicYearId, request.startDate(), request.endDate()) + : semesterRepository.existsOverlappingExcluding( + academicYearId, currentId, request.startDate(), request.endDate()); + if (duplicateType || overlapping) { + throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE); + } + } + + private AcademicYear lockYear(Long id) { + return academicYearRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Учебный год не найден")); + } + + private void applyYear(AcademicYear year, + String title, + LocalDate startDate, + LocalDate endDate) { + year.setTitle(title); + year.setStartDate(startDate); + year.setEndDate(endDate); + } + + private void applySemester(Semester semester, SemesterDto request) { + semester.setSemesterType(request.semesterType()); + semester.setStartDate(request.startDate()); + semester.setEndDate(request.endDate()); + } + + private AcademicYear saveYear(AcademicYear year) { + try { + return academicYearRepository.saveAndFlush(year); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); + } + } + + private Semester saveSemester(Semester semester) { + try { + return semesterRepository.saveAndFlush(semester); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE); + } + } + + private void clearScheduleCacheAfterCommit() { + if (!TransactionSynchronizationManager.isSynchronizationActive() + || !TransactionSynchronizationManager.isActualTransactionActive()) { + scheduleGeneratorService.clearCache(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + scheduleGeneratorService.clearCache(); + } + }); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java new file mode 100644 index 0000000..c8a48ae --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java @@ -0,0 +1,421 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.AcademicCalendarDto; +import com.magistr.app.dto.CreateGroupRequest; +import com.magistr.app.dto.GroupCalendarAssignmentDto; +import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarRepository; +import com.magistr.app.repository.AcademicCalendarSubjectRepository; +import com.magistr.app.repository.AcademicYearRepository; +import com.magistr.app.repository.EducationFormRepository; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; +import com.magistr.app.utils.CourseAndSemesterCalculator; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.NoSuchElementException; + +@Service +public class AcademicStructureService { + + private final AcademicCalendarRepository calendarRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarSubjectRepository calendarSubjectRepository; + private final AcademicYearRepository academicYearRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository profileRepository; + private final EducationFormRepository educationFormRepository; + private final GroupRepository groupRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicStructureService(AcademicCalendarRepository calendarRepository, + AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarSubjectRepository calendarSubjectRepository, + AcademicYearRepository academicYearRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository profileRepository, + EducationFormRepository educationFormRepository, + GroupRepository groupRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.calendarRepository = calendarRepository; + this.calendarDayRepository = calendarDayRepository; + this.calendarSubjectRepository = calendarSubjectRepository; + this.academicYearRepository = academicYearRepository; + this.specialtiesRepository = specialtiesRepository; + this.profileRepository = profileRepository; + this.educationFormRepository = educationFormRepository; + this.groupRepository = groupRepository; + this.assignmentRepository = assignmentRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @Transactional + public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) { + validateCalendarRequest(request); + AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Календарный график не найден")); + CalendarDimensions candidate = resolveCalendarDimensions(request); + + List assignments = + assignmentRepository.findByCalendarIdWithDetails(id); + boolean incompatibleAssignment = assignments.stream() + .anyMatch(assignment -> !isCompatible( + assignment.getStudentGroup(), + assignment.getAcademicYear(), + candidate.academicYear(), + candidate.speciality(), + candidate.profile(), + candidate.studyForm(), + request.courseCount() + )); + if (incompatibleAssignment) { + throw new ScheduleConflictException( + "Нельзя изменить график: сохранённые назначения групп станут несовместимыми" + ); + } + if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( + id, request.courseCount())) { + throw new ScheduleConflictException( + "Нельзя уменьшить количество курсов: в сетке есть данные старших курсов" + ); + } + if (calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan( + id, request.courseCount() * 2)) { + throw new ScheduleConflictException( + "Нельзя уменьшить количество курсов: есть дисциплины старших семестров" + ); + } + if (calendarDayRepository.existsOutsideDateRange( + id, + candidate.academicYear().getStartDate(), + candidate.academicYear().getEndDate())) { + throw new ScheduleConflictException( + "Нельзя изменить учебный год графика: сохранённая сетка содержит даты за его границами" + ); + } + + calendar.setTitle(request.title().trim()); + applyCalendarDimensions(calendar, candidate, request.courseCount()); + AcademicCalendar saved = saveCalendar(calendar); + clearScheduleCacheAfterCommit(); + return saved; + } + + @Transactional + public StudentGroup updateGroup(Long id, CreateGroupRequest request) { + validateGroupRequest(request); + StudentGroup group = groupRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена")); + GroupDimensions candidate = resolveGroupDimensions(request); + + boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream() + .anyMatch(assignment -> !isCompatible( + request.getYearStartStudy(), + candidate.speciality(), + candidate.profile(), + candidate.studyForm(), + assignment.getAcademicYear(), + assignment.getAcademicCalendar() + )); + if (incompatibleAssignment) { + throw new ScheduleConflictException( + "Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми" + ); + } + + group.setName(request.getName().trim()); + group.setGroupSize(request.getGroupSize()); + group.setEducationForm(candidate.studyForm()); + group.setDepartmentId(request.getDepartmentId()); + group.setYearStartStudy(request.getYearStartStudy()); + group.setSpeciality(candidate.speciality()); + group.setSpecialtyProfile(candidate.profile()); + StudentGroup saved = saveGroup(group); + clearScheduleCacheAfterCommit(); + return saved; + } + + @Transactional + public StudentGroupCalendarAssignment saveAssignment(Long groupId, + GroupCalendarAssignmentDto request) { + if (request == null || request.academicYearId() == null || request.calendarId() == null) { + throw new IllegalArgumentException("Учебный год и календарный график обязательны"); + } + StudentGroup group = groupRepository.findByIdForUpdate(groupId) + .orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена")); + AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(request.calendarId()) + .orElseThrow(() -> new IllegalArgumentException("Календарный график не найден")); + AcademicYear academicYear = academicYearRepository.findByIdForUpdate(request.academicYearId()) + .orElseThrow(() -> new IllegalArgumentException("Учебный год не найден")); + + if (!isCompatible(group, academicYear, calendar)) { + throw new IllegalArgumentException( + "Календарный график не соответствует учебному году, параметрам или курсу группы" + ); + } + + StudentGroupCalendarAssignment assignment = assignmentRepository + .findByGroupIdAndAcademicYearIdForUpdate(groupId, academicYear.getId()) + .orElseGet(StudentGroupCalendarAssignment::new); + assignment.setStudentGroup(group); + assignment.setAcademicYear(academicYear); + assignment.setAcademicCalendar(calendar); + try { + StudentGroupCalendarAssignment saved = assignmentRepository.saveAndFlush(assignment); + clearScheduleCacheAfterCommit(); + return saved; + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException( + "Назначение календарного графика было изменено конкурентно" + ); + } + } + + private void validateCalendarRequest(AcademicCalendarDto request) { + if (request == null) { + throw new IllegalArgumentException("Передайте данные календарного графика"); + } + if (request.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Название графика обязательно"); + } + if (request.courseCount() == null || request.courseCount() <= 0) { + throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); + } + if (request.courseCount() > 8) { + throw new IllegalArgumentException("Количество курсов не может быть больше 8"); + } + if (request.academicYearId() == null + || request.specialtyId() == null + || request.specialtyProfileId() == null + || request.studyFormId() == null) { + throw new IllegalArgumentException( + "Учебный год, специальность, профиль и форма обучения обязательны" + ); + } + } + + private void validateGroupRequest(CreateGroupRequest request) { + if (request == null) { + throw new IllegalArgumentException("Передайте данные учебной группы"); + } + if (request.getName() == null || request.getName().isBlank()) { + throw new IllegalArgumentException("Название группы обязательно"); + } + if (request.getGroupSize() == null) { + throw new IllegalArgumentException("Численность группы обязательна"); + } + if (request.getEducationFormId() == null) { + throw new IllegalArgumentException("Форма обучения обязательна"); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + throw new IllegalArgumentException("ID кафедры обязателен"); + } + if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { + throw new IllegalArgumentException("Год начала обучения обязателен"); + } + if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) { + throw new IllegalArgumentException("Код специальности обязателен"); + } + if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) { + throw new IllegalArgumentException("Профиль обучения обязателен"); + } + } + + private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) { + AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()) + .orElseThrow(() -> new IllegalArgumentException("Учебный год не найден")); + Speciality speciality = specialtiesRepository.findById(request.specialtyId()) + .orElseThrow(() -> new IllegalArgumentException("Специальность не найдена")); + SpecialtyProfile profile = resolveProfile(request.specialtyProfileId(), speciality); + EducationForm studyForm = educationFormRepository.findById(request.studyFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); + return new CalendarDimensions(academicYear, speciality, profile, studyForm); + } + + private GroupDimensions resolveGroupDimensions(CreateGroupRequest request) { + EducationForm studyForm = educationFormRepository.findById(request.getEducationFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElseThrow(() -> new IllegalArgumentException("Специальность не найдена")); + SpecialtyProfile profile = resolveProfile(request.getSpecialtyProfileId(), speciality); + return new GroupDimensions(speciality, profile, studyForm); + } + + private SpecialtyProfile resolveProfile(Long profileId, Speciality speciality) { + SpecialtyProfile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new IllegalArgumentException( + "Профиль обучения не найден для выбранной специальности" + )); + if (!profile.getSpeciality().getId().equals(speciality.getId())) { + throw new IllegalArgumentException( + "Профиль обучения не найден для выбранной специальности" + ); + } + return profile; + } + + private boolean isCompatible(StudentGroup group, + AcademicYear assignmentYear, + AcademicCalendar calendar) { + return isCompatible( + group, + assignmentYear, + calendar.getAcademicYear(), + calendar.getSpeciality(), + calendar.getSpecialtyProfile(), + calendar.getStudyForm(), + calendar.getCourseCount() + ); + } + + private boolean isCompatible(StudentGroup group, + AcademicYear assignmentYear, + AcademicYear calendarYear, + Speciality calendarSpeciality, + SpecialtyProfile calendarProfile, + EducationForm calendarStudyForm, + Integer courseCount) { + return isCompatible( + group.getYearStartStudy(), + group.getSpeciality(), + group.getSpecialtyProfile(), + group.getEducationForm(), + assignmentYear, + calendarYear, + calendarSpeciality, + calendarProfile, + calendarStudyForm, + courseCount + ); + } + + private boolean isCompatible(Integer yearStartStudy, + Speciality groupSpeciality, + SpecialtyProfile groupProfile, + EducationForm groupStudyForm, + AcademicYear assignmentYear, + AcademicCalendar calendar) { + return isCompatible( + yearStartStudy, + groupSpeciality, + groupProfile, + groupStudyForm, + assignmentYear, + calendar.getAcademicYear(), + calendar.getSpeciality(), + calendar.getSpecialtyProfile(), + calendar.getStudyForm(), + calendar.getCourseCount() + ); + } + + private boolean isCompatible(Integer yearStartStudy, + Speciality groupSpeciality, + SpecialtyProfile groupProfile, + EducationForm groupStudyForm, + AcademicYear assignmentYear, + AcademicYear calendarYear, + Speciality calendarSpeciality, + SpecialtyProfile calendarProfile, + EducationForm calendarStudyForm, + Integer courseCount) { + if (!sameId(assignmentYear, calendarYear) + || !sameId(groupSpeciality, calendarSpeciality) + || !sameId(groupProfile, calendarProfile) + || !sameId(groupStudyForm, calendarStudyForm)) { + return false; + } + int course = CourseAndSemesterCalculator.getActualCourse( + yearStartStudy, + assignmentYear.getStartDate() + ); + return course >= 1 && courseCount != null && course <= courseCount; + } + + private boolean sameId(AcademicYear first, AcademicYear second) { + return first != null && second != null && first.getId().equals(second.getId()); + } + + private boolean sameId(Speciality first, Speciality second) { + return first != null && second != null && first.getId().equals(second.getId()); + } + + private boolean sameId(SpecialtyProfile first, SpecialtyProfile second) { + return first != null && second != null && first.getId().equals(second.getId()); + } + + private boolean sameId(EducationForm first, EducationForm second) { + return first != null && second != null && first.getId().equals(second.getId()); + } + + private void applyCalendarDimensions(AcademicCalendar calendar, + CalendarDimensions dimensions, + Integer courseCount) { + calendar.setAcademicYear(dimensions.academicYear()); + calendar.setSpeciality(dimensions.speciality()); + calendar.setSpecialtyProfile(dimensions.profile()); + calendar.setStudyForm(dimensions.studyForm()); + calendar.setCourseCount(courseCount); + } + + private AcademicCalendar saveCalendar(AcademicCalendar calendar) { + try { + return calendarRepository.saveAndFlush(calendar); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException( + "Изменение календарного графика конфликтует с сохранёнными данными" + ); + } + } + + private StudentGroup saveGroup(StudentGroup group) { + try { + return groupRepository.saveAndFlush(group); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException( + "Изменение учебной группы конфликтует с сохранёнными данными" + ); + } + } + + private void clearScheduleCacheAfterCommit() { + if (!TransactionSynchronizationManager.isSynchronizationActive() + || !TransactionSynchronizationManager.isActualTransactionActive()) { + scheduleGeneratorService.clearCache(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + scheduleGeneratorService.clearCache(); + } + }); + } + + private record CalendarDimensions(AcademicYear academicYear, + Speciality speciality, + SpecialtyProfile profile, + EducationForm studyForm) { + } + + private record GroupDimensions(Speciality speciality, + SpecialtyProfile profile, + EducationForm studyForm) { + } +} diff --git a/backend/src/main/java/com/magistr/app/service/BusinessTimeService.java b/backend/src/main/java/com/magistr/app/service/BusinessTimeService.java new file mode 100644 index 0000000..495145e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/BusinessTimeService.java @@ -0,0 +1,56 @@ +package com.magistr.app.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.zone.ZoneRulesException; +import java.util.Objects; + +/** Единый источник бизнес-даты и абсолютного времени приложения. */ +@Service +public class BusinessTimeService { + + public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Moscow"); + + private final Clock clock; + private final ZoneId zoneId; + + @Autowired + public BusinessTimeService(@Value("${app.time.business-zone:Europe/Moscow}") String zoneId) { + this(Clock.systemUTC(), parseZone(zoneId)); + } + + public BusinessTimeService(Clock clock, ZoneId zoneId) { + this.clock = Objects.requireNonNull(clock, "Часы приложения обязательны"); + this.zoneId = Objects.requireNonNull(zoneId, "Часовой пояс бизнес-даты обязателен"); + } + + public LocalDate today() { + return LocalDate.ofInstant(clock.instant(), zoneId); + } + + public Instant now() { + return clock.instant(); + } + + public ZoneId zoneId() { + return zoneId; + } + + public static BusinessTimeService systemDefault() { + return new BusinessTimeService(Clock.systemUTC(), DEFAULT_ZONE); + } + + private static ZoneId parseZone(String value) { + try { + return ZoneId.of(value == null || value.isBlank() ? DEFAULT_ZONE.getId() : value.trim()); + } catch (ZoneRulesException exception) { + throw new IllegalStateException("Некорректный часовой пояс бизнес-даты: " + value, exception); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 772cb32..37bf881 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -10,6 +10,7 @@ import com.magistr.app.repository.TimeSlotScopeRepository; import com.magistr.app.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.DayOfWeek; @@ -33,6 +34,7 @@ public class ScheduleGeneratorService { private final TimeSlotRepository timeSlotRepository; private final TimeSlotScopeRepository timeSlotScopeRepository; private final TimeSlotDateAssignmentRepository dateAssignmentRepository; + private final BusinessTimeService businessTime; public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, GroupRepository groupRepository, @@ -41,6 +43,20 @@ public class ScheduleGeneratorService { TimeSlotRepository timeSlotRepository, TimeSlotScopeRepository timeSlotScopeRepository, TimeSlotDateAssignmentRepository dateAssignmentRepository) { + this(scheduleRuleRepository, groupRepository, userRepository, academicDateService, + timeSlotRepository, timeSlotScopeRepository, dateAssignmentRepository, + BusinessTimeService.systemDefault()); + } + + @Autowired + public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, + GroupRepository groupRepository, + UserRepository userRepository, + AcademicDateService academicDateService, + TimeSlotRepository timeSlotRepository, + TimeSlotScopeRepository timeSlotScopeRepository, + TimeSlotDateAssignmentRepository dateAssignmentRepository, + BusinessTimeService businessTime) { this.scheduleRuleRepository = scheduleRuleRepository; this.groupRepository = groupRepository; this.userRepository = userRepository; @@ -48,45 +64,134 @@ public class ScheduleGeneratorService { this.timeSlotRepository = timeSlotRepository; this.timeSlotScopeRepository = timeSlotScopeRepository; this.dateAssignmentRepository = dateAssignmentRepository; + this.businessTime = businessTime; } public List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) { validateRange(startDate, endDate); StudentGroup group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); + return buildScheduleForGroupsInternal(List.of(group), startDate, endDate); + } + + public List buildScheduleForGroups(Collection groups, + LocalDate startDate, + LocalDate endDate) { + validateRange(startDate, endDate); + List groupSnapshot = Optional.ofNullable(groups) + .orElseGet(List::of) + .stream() + .filter(Objects::nonNull) + .filter(group -> group.getId() != null) + .collect(Collectors.toMap( + StudentGroup::getId, + group -> group, + (first, second) -> first, + LinkedHashMap::new + )) + .values() + .stream() + .toList(); + return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate); + } + + private List buildScheduleForGroupsInternal(List groups, + LocalDate startDate, + LocalDate endDate) { + if (groups.isEmpty()) { + return List.of(); + } + + List semesters = academicDateService.findSemesters(startDate, endDate); + if (semesters.isEmpty()) { + return List.of(); + } + Set semesterIds = semesters.stream() + .map(Semester::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set groupIds = groups.stream() + .map(StudentGroup::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds); + AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService + .createScheduleSnapshot(groups, semesters, endDate); + TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules); + Map> rulesByGroupAndSemester = indexRulesByGroupAndSemester( + rules, + groupIds + ); + + List result = new ArrayList<>(); + for (StudentGroup group : groups) { + result.addAll(buildScheduleForGroup( + group, + startDate, + endDate, + dateSnapshot, + timeSlotSnapshot, + rulesByGroupAndSemester + )); + } + return result; + } + + private List buildScheduleForGroup(StudentGroup group, + LocalDate startDate, + LocalDate endDate, + AcademicDateService.ScheduleSnapshot dateSnapshot, + TimeSlotSnapshot timeSlotSnapshot, + Map> rulesByGroupAndSemester) { - Map> rulesBySemester = new HashMap<>(); Map consumedHoursProgress = new HashMap<>(); List result = new ArrayList<>(); Set warnedAcademicYears = new HashSet<>(); Set primedSemesterIds = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - Optional semesterOpt = academicDateService.findSemester(date); + Optional semesterOpt = dateSnapshot.findSemester(date); if (semesterOpt.isEmpty()) { continue; } Semester semester = semesterOpt.get(); - if (!academicDateService.hasCalendarAssignment(group, semester) + if (!dateSnapshot.hasCalendarAssignment(group, semester) && warnedAcademicYears.add(semester.getAcademicYear().getId())) { logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}", group.getName(), semester.getAcademicYear().getTitle()); } - if (!academicDateService.isTheoryDay(group, semester, date)) { + if (!dateSnapshot.isTheoryDay(group, semester, date)) { continue; } - List rules = rulesBySemester.computeIfAbsent( - semester.getId(), - semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) + List rules = rulesByGroupAndSemester.getOrDefault( + new GroupSemesterKey(group.getId(), semester.getId()), + List.of() ); if (primedSemesterIds.add(semester.getId())) { - primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress); + primeConsumedHoursBeforeRange( + semester, + rules, + startDate, + group, + null, + consumedHoursProgress, + dateSnapshot, + timeSlotSnapshot + ); } for (ScheduleRule rule : sortRules(rules)) { - processRuleForDate(rule, date, group, null, consumedHoursProgress, result); + processRuleForDate( + rule, + semester, + date, + group, + null, + consumedHoursProgress, + result, + dateSnapshot, + timeSlotSnapshot + ); } } @@ -99,28 +204,68 @@ public class ScheduleGeneratorService { throw new IllegalArgumentException("Преподаватель не найден"); } - Map> rulesBySemester = new HashMap<>(); + List semesters = academicDateService.findSemesters(startDate, endDate); + if (semesters.isEmpty()) { + return List.of(); + } + Set semesterIds = semesters.stream() + .map(Semester::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List rules = scheduleRuleRepository.findByTeacherIdAndSemesterIds(teacherId, semesterIds); + List groups = rules.stream() + .flatMap(rule -> rule.getGroups().stream()) + .filter(group -> group.getId() != null) + .collect(Collectors.toMap( + StudentGroup::getId, + group -> group, + (first, second) -> first, + LinkedHashMap::new + )) + .values() + .stream() + .toList(); + AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService + .createScheduleSnapshot(groups, semesters, endDate); + TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules); + Map> rulesBySemester = rules.stream() + .collect(Collectors.groupingBy(rule -> rule.getSemester().getId())); Map consumedHoursProgress = new HashMap<>(); List result = new ArrayList<>(); Set primedSemesterIds = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - Optional semesterOpt = academicDateService.findSemester(date); + Optional semesterOpt = dateSnapshot.findSemester(date); if (semesterOpt.isEmpty()) { continue; } Semester semester = semesterOpt.get(); - List rules = rulesBySemester.computeIfAbsent( - semester.getId(), - semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) - ); + List semesterRules = rulesBySemester.getOrDefault(semester.getId(), List.of()); if (primedSemesterIds.add(semester.getId())) { - primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress); + primeConsumedHoursBeforeRange( + semester, + semesterRules, + startDate, + null, + teacherId, + consumedHoursProgress, + dateSnapshot, + timeSlotSnapshot + ); } - for (ScheduleRule rule : sortRules(rules)) { - processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result); + for (ScheduleRule rule : sortRules(semesterRules)) { + processRuleForDate( + rule, + semester, + date, + null, + teacherId, + consumedHoursProgress, + result, + dateSnapshot, + timeSlotSnapshot + ); } } @@ -161,12 +306,48 @@ public class ScheduleGeneratorService { .toList(); } + private Map> indexRulesByGroupAndSemester( + Collection rules, + Set requestedGroupIds + ) { + Map> result = new HashMap<>(); + for (ScheduleRule rule : rules) { + Long semesterId = rule.getSemester().getId(); + for (StudentGroup group : rule.getGroups()) { + if (!requestedGroupIds.contains(group.getId())) { + continue; + } + result.computeIfAbsent( + new GroupSemesterKey(group.getId(), semesterId), + ignored -> new ArrayList<>() + ).add(rule); + } + } + result.replaceAll((key, value) -> sortRules(value)); + return result; + } + + private TimeSlotSnapshot loadTimeSlotSnapshot(LocalDate startDate, + LocalDate endDate, + Collection rules) { + if (rules.isEmpty()) { + return TimeSlotSnapshot.empty(); + } + return new TimeSlotSnapshot( + dateAssignmentRepository.findForScheduleRange(startDate, endDate), + timeSlotScopeRepository.findByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_WEEKDAY), + timeSlotRepository.findAllForSchedule() + ); + } + private void primeConsumedHoursBeforeRange(Semester semester, List rules, LocalDate rangeStart, StudentGroup targetGroup, Long targetTeacherId, - Map consumedHoursProgress) { + Map consumedHoursProgress, + AcademicDateService.ScheduleSnapshot dateSnapshot, + TimeSlotSnapshot timeSlotSnapshot) { LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1)); if (!semester.getStartDate().isBefore(endExclusive)) { return; @@ -175,7 +356,17 @@ public class ScheduleGeneratorService { List sortedRules = sortRules(rules); for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) { for (ScheduleRule rule : sortedRules) { - processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null); + processRuleForDate( + rule, + semester, + date, + targetGroup, + targetTeacherId, + consumedHoursProgress, + null, + dateSnapshot, + timeSlotSnapshot + ); } } } @@ -185,19 +376,21 @@ public class ScheduleGeneratorService { } private void processRuleForDate(ScheduleRule rule, - LocalDate date, - StudentGroup targetGroup, - Long targetTeacherId, - Map consumedHoursProgress, - List result) { + Semester semester, + LocalDate date, + StudentGroup targetGroup, + Long targetTeacherId, + Map consumedHoursProgress, + List result, + AcademicDateService.ScheduleSnapshot dateSnapshot, + TimeSlotSnapshot timeSlotSnapshot) { if (rule.getLectureAcademicHours() == null || rule.getLaboratoryAcademicHours() == null || rule.getPracticeAcademicHours() == null) { return; } - Optional semesterOpt = academicDateService.findSemester(date); - if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) { + if (semester == null || !Objects.equals(semester.getId(), rule.getSemester().getId())) { return; } if (!rule.getSubject().isActiveOn(date)) { @@ -207,8 +400,7 @@ public class ScheduleGeneratorService { return; } - Semester semester = semesterOpt.get(); - List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); + List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date, dateSnapshot); if (eligibleGroups.isEmpty()) { return; } @@ -233,7 +425,7 @@ public class ScheduleGeneratorService { if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) { continue; } - if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) { + if (date.isAfter(businessTime.today()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) { continue; } ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType()); @@ -283,7 +475,7 @@ public class ScheduleGeneratorService { .orElse(0); result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, - totalHours, consumedHours, remainingAfterLesson)); + totalHours, consumedHours, remainingAfterLesson, timeSlotSnapshot)); } for (ActiveLessonScope scope : activeScopes) { consumedHoursProgress.put( @@ -294,16 +486,20 @@ public class ScheduleGeneratorService { } } - private List eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) { + private List eligibleGroups(ScheduleRule rule, + StudentGroup targetGroup, + Semester semester, + LocalDate date, + AcademicDateService.ScheduleSnapshot dateSnapshot) { if (targetGroup != null) { - return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date) + return targetGroup.isActiveOn(date) && dateSnapshot.isTheoryDay(targetGroup, semester, date) ? List.of(targetGroup) : List.of(); } return rule.getGroups().stream() .filter(group -> group.isActiveOn(date)) - .filter(group -> academicDateService.isTheoryDay(group, semester, date)) + .filter(group -> dateSnapshot.isTheoryDay(group, semester, date)) .sorted(Comparator.comparing(StudentGroup::getName)) .toList(); } @@ -356,6 +552,12 @@ public class ScheduleGeneratorService { private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) { } + private record GroupSemesterKey(Long groupId, Long semesterId) { + } + + private record ScopeOrderKey(Long scopeId, Integer orderNumber) { + } + private RenderedLessonDto toRenderedLesson(ScheduleRule rule, ScheduleRuleSlot slot, LocalDate date, @@ -365,8 +567,9 @@ public class ScheduleGeneratorService { List subgroups, int lessonTypeAcademicHours, int consumedBeforeLesson, - int remainingAfterLesson) { - TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date); + int remainingAfterLesson, + TimeSlotSnapshot timeSlotSnapshot) { + TimeSlot timeSlot = timeSlotSnapshot.resolve(slot.getTimeSlot(), date); List sortedGroups = groups.stream() .sorted(Comparator.comparing(StudentGroup::getName)) .toList(); @@ -415,23 +618,55 @@ public class ScheduleGeneratorService { return subgroup.getStudentGroup().getName() + ": " + subgroup.getName(); } - private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) { - if (baseSlot == null || baseSlot.getOrderNumber() == null) { - return baseSlot; - } - return effectiveScope(date) - .flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber( - scope.getId(), - baseSlot.getOrderNumber())) - .orElse(baseSlot); - } + private static final class TimeSlotSnapshot { - private Optional effectiveScope(LocalDate date) { - return dateAssignmentRepository.findByDate(date) - .map(TimeSlotDateAssignment::getTimeSlotScope) - .or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek( - APPLY_MODE_WEEKDAY, - date.getDayOfWeek().getValue())); + private final Map dateScopeIds; + private final Map weekdayScopeIds; + private final Map slotsByScopeAndOrder; + + private TimeSlotSnapshot(Collection dateAssignments, + Collection weekdayScopes, + Collection timeSlots) { + this.dateScopeIds = dateAssignments.stream() + .collect(Collectors.toUnmodifiableMap( + TimeSlotDateAssignment::getDate, + assignment -> assignment.getTimeSlotScope().getId() + )); + this.weekdayScopeIds = weekdayScopes.stream() + .filter(scope -> scope.getDayOfWeek() != null) + .collect(Collectors.toUnmodifiableMap( + TimeSlotScope::getDayOfWeek, + TimeSlotScope::getId, + (first, second) -> first + )); + this.slotsByScopeAndOrder = timeSlots.stream() + .collect(Collectors.toUnmodifiableMap( + slot -> new ScopeOrderKey( + slot.getTimeSlotScope().getId(), + slot.getOrderNumber() + ), + slot -> slot + )); + } + + private static TimeSlotSnapshot empty() { + return new TimeSlotSnapshot(List.of(), List.of(), List.of()); + } + + private TimeSlot resolve(TimeSlot baseSlot, LocalDate date) { + if (baseSlot == null || baseSlot.getOrderNumber() == null || date == null) { + return baseSlot; + } + Long scopeId = Optional.ofNullable(dateScopeIds.get(date)) + .orElseGet(() -> weekdayScopeIds.get(date.getDayOfWeek().getValue())); + if (scopeId == null) { + return baseSlot; + } + return slotsByScopeAndOrder.getOrDefault( + new ScopeOrderKey(scopeId, baseSlot.getOrderNumber()), + baseSlot + ); + } } public static String dayName(DayOfWeek dayOfWeek) { 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 af5cc6a..eb2f021 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -41,10 +41,10 @@ public class ScheduleQueryService { if (date == null) { throw new IllegalArgumentException("Дата расписания обязательна"); } - List generatedLessons = groupRepository.findAll().stream() + List groups = groupRepository.findAll().stream() .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date)) - .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream()) .toList(); + List generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date); return deduplicate(generatedLessons); } @@ -85,16 +85,70 @@ public class ScheduleQueryService { String parity, LocalDate startDate, LocalDate endDate) { + return searchInternal( + groupId, + teacherId, + classroomId, + departmentId, + subjectId, + lessonTypeId, + timeSlotId, + parity, + startDate, + endDate, + true + ); + } + + /** + * Строит расписание для workload-агрегатов без интерактивного ограничения 50 групп. + * Набор поддерживаемых фильтров намеренно сужен до кафедры и временного слота, чтобы + * этот путь не превратился во второй общий поисковый API. + */ + public List searchForAggregation(Long departmentId, + Long timeSlotId, + LocalDate startDate, + LocalDate endDate) { + return searchInternal( + null, + null, + null, + departmentId, + null, + null, + timeSlotId, + null, + startDate, + endDate, + false + ); + } + + private List searchInternal(Long groupId, + Long teacherId, + Long classroomId, + Long departmentId, + Long subjectId, + Long lessonTypeId, + Long timeSlotId, + String parity, + LocalDate startDate, + LocalDate endDate, + boolean enforceInteractiveGroupLimit) { validateRange(startDate, endDate); List generatedLessons; boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null; if (teacherOnlySearch) { generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); } else { - List groups = resolveGroups(groupId, departmentId, startDate, endDate); - generatedLessons = groups.stream() - .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) - .toList(); + List groups = resolveGroups( + groupId, + departmentId, + startDate, + endDate, + enforceInteractiveGroupLimit + ); + generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate); } List overrideSnapshot = scheduleOverrideRepository .findByLessonDateBetweenWithDetails(startDate, endDate); @@ -249,7 +303,11 @@ public class ScheduleQueryService { } } - private List resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) { + private List resolveGroups(Long groupId, + Long departmentId, + LocalDate startDate, + LocalDate endDate, + boolean enforceInteractiveGroupLimit) { if (groupId != null) { return groupRepository.findById(groupId) .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate)) @@ -265,7 +323,9 @@ public class ScheduleQueryService { groups = groups.stream() .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate)) .toList(); - if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) { + if (enforceInteractiveGroupLimit + && departmentId == null + && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) { throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает " + groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE); } diff --git a/backend/src/main/java/com/magistr/app/service/SubjectImportService.java b/backend/src/main/java/com/magistr/app/service/SubjectImportService.java new file mode 100644 index 0000000..daed6de --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/SubjectImportService.java @@ -0,0 +1,105 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.CreateSubjectRequest; +import com.magistr.app.model.Subject; +import com.magistr.app.repository.SubjectRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +@Service +public class SubjectImportService { + + private static final int MAX_NAME_LENGTH = 200; + private static final int MAX_CODE_LENGTH = 20; + + private final SubjectRepository subjectRepository; + + public SubjectImportService(SubjectRepository subjectRepository) { + this.subjectRepository = subjectRepository; + } + + @Transactional + public List importSubjects(List requests, Long departmentId) { + if (requests == null || requests.isEmpty()) { + throw new IllegalArgumentException("Передайте список дисциплин для загрузки"); + } + if (departmentId == null || departmentId <= 0) { + throw new IllegalArgumentException("Кафедра обязательна"); + } + + List candidates = normalizeCandidates(requests); + Map existingByCanonicalName = new LinkedHashMap<>(); + for (SubjectCandidate candidate : candidates) { + subjectRepository.findByNameIgnoreCase(candidate.name()).ifPresent(existing -> { + if (!Objects.equals(existing.getDepartmentId(), departmentId)) { + throw new ScheduleConflictException( + "Дисциплина «" + candidate.name() + "» уже принадлежит другой кафедре" + ); + } + existingByCanonicalName.put(candidate.canonicalName(), existing); + }); + } + + List subjects = new ArrayList<>(candidates.size()); + for (SubjectCandidate candidate : candidates) { + Subject subject = existingByCanonicalName.getOrDefault( + candidate.canonicalName(), + new Subject() + ); + subject.setName(candidate.name()); + subject.setCode(candidate.code()); + subject.setDepartmentId(departmentId); + if (subject.isArchivedRecord()) { + subject.restore(); + } + subjects.add(subject); + } + + try { + return subjectRepository.saveAllAndFlush(subjects); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException( + "Дисциплина с таким названием уже существует" + ); + } + } + + private List normalizeCandidates(List requests) { + Map unique = new LinkedHashMap<>(); + for (CreateSubjectRequest request : requests) { + if (request == null || request.getName() == null || request.getName().isBlank()) { + continue; + } + String name = request.getName().trim(); + String code = trimToNull(request.getCode()); + if (name.length() > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("Название дисциплины не может быть длиннее 200 символов"); + } + if (code != null && code.length() > MAX_CODE_LENGTH) { + throw new IllegalArgumentException("Код дисциплины не может быть длиннее 20 символов"); + } + String canonicalName = name.toLowerCase(Locale.ROOT); + unique.put(canonicalName, new SubjectCandidate(canonicalName, name, code)); + } + return List.copyOf(unique.values()); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private record SubjectCandidate(String canonicalName, String name, String code) { + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java b/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java new file mode 100644 index 0000000..118af72 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java @@ -0,0 +1,234 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.DepartmentTransferRequest; +import com.magistr.app.model.Department; +import com.magistr.app.model.Role; +import com.magistr.app.model.TeacherDepartmentAssignment; +import com.magistr.app.model.User; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +import com.magistr.app.repository.UserRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class TeacherDepartmentService { + + private final TeacherDepartmentAssignmentRepository assignmentRepository; + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + private final BusinessTimeService businessTime; + + public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository, + UserRepository userRepository, + DepartmentRepository departmentRepository) { + this(assignmentRepository, userRepository, departmentRepository, + BusinessTimeService.systemDefault()); + } + + @Autowired + public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository, + UserRepository userRepository, + DepartmentRepository departmentRepository, + BusinessTimeService businessTime) { + this.assignmentRepository = assignmentRepository; + this.userRepository = userRepository; + this.departmentRepository = departmentRepository; + this.businessTime = businessTime; + } + + @Transactional(readOnly = true) + public List findTeachersForDepartmentAtDate(Long departmentId, LocalDate date) { + if (departmentId == null || date == null) { + return List.of(); + } + Map teachersById = new LinkedHashMap<>(); + assignmentRepository.findDepartmentTeachersAtDate(departmentId, date).stream() + .map(TeacherDepartmentAssignment::getTeacher) + .filter(Objects::nonNull) + .filter(teacher -> teacher.getRole() == Role.TEACHER) + .filter(teacher -> teacher.isActiveOn(date)) + .forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher)); + return List.copyOf(teachersById.values()); + } + + @Transactional(readOnly = true) + public boolean hasAssignmentAtDate(Long teacherId, Long departmentId, LocalDate date) { + return teacherId != null + && departmentId != null + && date != null + && assignmentRepository.existsActiveAssignment(teacherId, departmentId, date); + } + + @Transactional(readOnly = true) + public Optional findPrimaryDepartmentIdAtDate(Long teacherId, LocalDate date) { + if (teacherId == null || date == null) { + return Optional.empty(); + } + return assignmentRepository.findPrimaryAtDate(teacherId, date) + .map(TeacherDepartmentAssignment::getDepartment) + .filter(Objects::nonNull) + .map(Department::getId); + } + + @Transactional(readOnly = true) + public Map> findAssignmentsForTeachersBetween( + Collection teacherIds, + LocalDate startDate, + LocalDate endDate + ) { + if (teacherIds == null || teacherIds.isEmpty() || startDate == null || endDate == null) { + return Map.of(); + } + Set normalizedIds = teacherIds.stream() + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (normalizedIds.isEmpty()) { + return Map.of(); + } + return assignmentRepository.findForTeachersBetween(normalizedIds, startDate, endDate).stream() + .collect(Collectors.groupingBy( + assignment -> assignment.getTeacher().getId(), + LinkedHashMap::new, + Collectors.toList() + )); + } + + public Optional resolveDepartmentIdAtDate( + Collection assignments, + LocalDate date + ) { + if (assignments == null || date == null) { + return Optional.empty(); + } + return assignments.stream() + .filter(Objects::nonNull) + .filter(assignment -> isActiveAt(assignment, date)) + .filter(assignment -> assignment.getDepartment() != null) + .sorted(Comparator + .comparing( + (TeacherDepartmentAssignment assignment) -> + !Boolean.TRUE.equals(assignment.getPrimaryAssignment()) + ) + .thenComparing( + TeacherDepartmentAssignment::getValidFrom, + Comparator.nullsLast(Comparator.reverseOrder()) + ) + .thenComparing( + TeacherDepartmentAssignment::getId, + Comparator.nullsLast(Comparator.reverseOrder()) + )) + .map(assignment -> assignment.getDepartment().getId()) + .findFirst(); + } + + @Transactional + public TeacherDepartmentAssignment transferTeacher(Long teacherId, + DepartmentTransferRequest request, + LocalDate today, + Long createdBy) { + if (request == null || request.departmentId() == null) { + throw new IllegalArgumentException("Кафедра обязательна"); + } + LocalDate operationDate = today == null ? businessTime.today() : today; + LocalDate validFrom = request.validFrom() == null ? operationDate : request.validFrom(); + User teacher = userRepository.findByIdForUpdate(teacherId) + .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); + if (teacher.getRole() != Role.TEACHER) { + throw new IllegalArgumentException("Преподаватель не найден"); + } + if (teacher.isArchivedRecord()) { + throw new IllegalArgumentException("Архивного преподавателя нельзя перевести"); + } + Department department = departmentRepository.findById(request.departmentId()) + .filter(candidate -> !candidate.isArchivedRecord()) + .orElseThrow(() -> new IllegalArgumentException("Активная кафедра не найдена")); + + List primaryHistory = new ArrayList<>( + assignmentRepository.findPrimaryHistoryForUpdate(teacherId) + ); + TeacherDepartmentAssignment sameDate = primaryHistory.stream() + .filter(assignment -> validFrom.equals(assignment.getValidFrom())) + .findFirst() + .orElse(null); + if (sameDate != null) { + sameDate.setDepartment(department); + sameDate.setComment(normalizeComment(request.comment())); + sameDate.setCreatedBy(createdBy); + TeacherDepartmentAssignment saved = saveAssignment(sameDate); + synchronizeLegacyDepartment(teacher, primaryHistory, operationDate); + return saved; + } + + primaryHistory.stream() + .filter(assignment -> isActiveAt(assignment, validFrom)) + .findFirst() + .ifPresent(current -> current.setValidTo(validFrom.minusDays(1))); + + LocalDate nextValidFrom = primaryHistory.stream() + .map(TeacherDepartmentAssignment::getValidFrom) + .filter(date -> date != null && date.isAfter(validFrom)) + .min(LocalDate::compareTo) + .orElse(null); + + assignmentRepository.flush(); + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(teacher); + assignment.setDepartment(department); + assignment.setValidFrom(validFrom); + assignment.setValidTo(nextValidFrom == null ? null : nextValidFrom.minusDays(1)); + assignment.setPrimaryAssignment(true); + assignment.setComment(normalizeComment(request.comment())); + assignment.setCreatedBy(createdBy); + TeacherDepartmentAssignment saved = saveAssignment(assignment); + + primaryHistory.add(saved); + synchronizeLegacyDepartment(teacher, primaryHistory, operationDate); + return saved; + } + + private void synchronizeLegacyDepartment(User teacher, + List primaryHistory, + LocalDate date) { + resolveDepartmentIdAtDate(primaryHistory, date).ifPresent(departmentId -> { + if (!Objects.equals(teacher.getDepartmentId(), departmentId)) { + teacher.setDepartmentId(departmentId); + userRepository.save(teacher); + } + }); + } + + private TeacherDepartmentAssignment saveAssignment(TeacherDepartmentAssignment assignment) { + try { + return assignmentRepository.saveAndFlush(assignment); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException( + "Период основной кафедры пересекается с другим назначением преподавателя" + ); + } + } + + private boolean isActiveAt(TeacherDepartmentAssignment assignment, LocalDate date) { + return assignment.getValidFrom() != null + && !assignment.getValidFrom().isAfter(date) + && (assignment.getValidTo() == null || !assignment.getValidTo().isBefore(date)); + } + + private String normalizeComment(String comment) { + return comment == null || comment.isBlank() ? null : comment.trim(); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TimeSlotService.java b/backend/src/main/java/com/magistr/app/service/TimeSlotService.java new file mode 100644 index 0000000..3fd66f6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TimeSlotService.java @@ -0,0 +1,161 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.TimeSlotDto; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.model.TimeSlotScope; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.TimeSlotScopeRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.stream.Stream; + +@Service +public class TimeSlotService { + + private static final String APPLY_MODE_DEFAULT = "DEFAULT"; + private static final String SLOT_CONFLICT_MESSAGE = + "Номер пары или интервал времени уже занят в выбранной сетке"; + + private final TimeSlotRepository timeSlotRepository; + private final TimeSlotScopeRepository timeSlotScopeRepository; + private final ScheduleRuleSlotRepository scheduleRuleSlotRepository; + + public TimeSlotService(TimeSlotRepository timeSlotRepository, + TimeSlotScopeRepository timeSlotScopeRepository, + ScheduleRuleSlotRepository scheduleRuleSlotRepository) { + this.timeSlotRepository = timeSlotRepository; + this.timeSlotScopeRepository = timeSlotScopeRepository; + this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; + } + + @Transactional + public TimeSlot create(TimeSlotDto request) { + validate(request); + TimeSlotScope targetScope = lockScope(request.scopeId()); + ensureUniqueAndNonOverlapping(request, null); + + TimeSlot timeSlot = new TimeSlot(); + apply(timeSlot, targetScope, request); + return save(timeSlot); + } + + @Transactional + public TimeSlot update(Long id, TimeSlotDto request) { + validate(request); + TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Временной слот не найден")); + + Long currentScopeId = timeSlot.getTimeSlotScope().getId(); + List scopeIds = Stream.of(currentScopeId, request.scopeId()) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + TimeSlotScope targetScope = null; + for (Long scopeId : scopeIds) { + TimeSlotScope lockedScope = lockScope(scopeId); + if (scopeId.equals(request.scopeId())) { + targetScope = lockedScope; + } + } + if (targetScope == null) { + throw new NoSuchElementException("Сетка времени не найдена"); + } + + if (scheduleRuleSlotRepository.existsByTimeSlotId(id) + && !APPLY_MODE_DEFAULT.equals(targetScope.getApplyMode())) { + throw new IllegalArgumentException( + "Слот базовой сетки, используемый в правилах расписания, нельзя перенести в другую сетку" + ); + } + + ensureUniqueAndNonOverlapping(request, id); + apply(timeSlot, targetScope, request); + return save(timeSlot); + } + + @Transactional + public void delete(Long id) { + TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Временной слот не найден")); + if (scheduleRuleSlotRepository.existsByTimeSlotId(id)) { + throw new IllegalArgumentException( + "Нельзя удалить слот, который используется в правилах расписания" + ); + } + try { + timeSlotRepository.delete(timeSlot); + timeSlotRepository.flush(); + } catch (DataIntegrityViolationException exception) { + throw new IllegalArgumentException( + "Нельзя удалить слот, который используется в расписании", + exception + ); + } + } + + private void validate(TimeSlotDto request) { + if (request == null) { + throw new IllegalArgumentException("Данные временного слота обязательны"); + } + if (request.orderNumber() == null || request.orderNumber() <= 0) { + throw new IllegalArgumentException("Номер пары должен быть больше нуля"); + } + if (request.scopeId() == null) { + throw new IllegalArgumentException("Выберите сетку времени"); + } + if (request.startTime() == null || request.endTime() == null) { + throw new IllegalArgumentException("Время начала и окончания обязательно"); + } + if (!request.startTime().isBefore(request.endTime())) { + throw new IllegalArgumentException("Время начала должно быть раньше времени окончания"); + } + if (Duration.between(request.startTime(), request.endTime()).toMinutes() < 1) { + throw new IllegalArgumentException("Длительность временного слота должна быть не меньше одной минуты"); + } + } + + private TimeSlotScope lockScope(Long scopeId) { + return timeSlotScopeRepository.findByIdForUpdate(scopeId) + .orElseThrow(() -> new NoSuchElementException("Сетка времени не найдена")); + } + + private void ensureUniqueAndNonOverlapping(TimeSlotDto request, Long currentId) { + boolean duplicateOrder = currentId == null + ? timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumber( + request.scopeId(), request.orderNumber()) + : timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumberAndIdNot( + request.scopeId(), request.orderNumber(), currentId); + boolean overlapping = currentId == null + ? timeSlotRepository.existsOverlapping( + request.scopeId(), request.startTime(), request.endTime()) + : timeSlotRepository.existsOverlappingExcluding( + request.scopeId(), currentId, request.startTime(), request.endTime()); + if (duplicateOrder || overlapping) { + throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE); + } + } + + private void apply(TimeSlot timeSlot, TimeSlotScope scope, TimeSlotDto request) { + timeSlot.setOrderNumber(request.orderNumber()); + timeSlot.setTimeSlotScope(scope); + timeSlot.setStartTime(request.startTime()); + timeSlot.setEndTime(request.endTime()); + timeSlot.setDurationMinutes((int) Duration.between(request.startTime(), request.endTime()).toMinutes()); + } + + private TimeSlot save(TimeSlot timeSlot) { + try { + return timeSlotRepository.saveAndFlush(timeSlot); + } catch (DataIntegrityViolationException exception) { + throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java index 3b0f9e9..18cd659 100644 --- a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java +++ b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java @@ -4,12 +4,13 @@ import com.magistr.app.model.SemesterType; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.time.ZoneId; @Service public class CourseAndSemesterCalculator { public static int getActualCourse(Integer yearStartStudy) { - return getActualCourse(yearStartStudy, LocalDate.now()); + return getActualCourse(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow"))); } public static int getActualCourse(Integer yearStartStudy, LocalDate date) { @@ -22,7 +23,7 @@ public class CourseAndSemesterCalculator { } public static int getActualSemester(Integer yearStartStudy) { - return getActualSemester(yearStartStudy, LocalDate.now()); + return getActualSemester(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow"))); } public static int getActualSemester(Integer yearStartStudy, LocalDate date) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b87931a..b46180f 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -10,6 +10,11 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=none spring.jpa.show-sql=false spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.jdbc.time_zone=UTC +spring.jackson.time-zone=UTC + +# Единая зона календарных бизнес-дат. Абсолютные timestamps хранятся в UTC. +app.time.business-zone=${BUSINESS_TIME_ZONE:Europe/Moscow} # Мультитенантность app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} @@ -33,5 +38,24 @@ app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m} app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d} app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh} app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false} +app.jwt.refresh-cleanup.enabled=${JWT_REFRESH_CLEANUP_ENABLED:true} +app.jwt.refresh-cleanup.retention=${JWT_REFRESH_CLEANUP_RETENTION:30d} +app.jwt.refresh-cleanup.batch-size=${JWT_REFRESH_CLEANUP_BATCH_SIZE:500} +app.jwt.refresh-cleanup.max-batches-per-tenant=${JWT_REFRESH_CLEANUP_MAX_BATCHES:20} +app.jwt.refresh-cleanup.interval-ms=${JWT_REFRESH_CLEANUP_INTERVAL_MS:3600000} +app.jwt.refresh-cleanup.initial-delay-ms=${JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS:60000} + +# Защита входа. X-Forwarded-For принимается только от перечисленных proxy-сетей. +app.login-security.max-failures=${LOGIN_RATE_MAX_FAILURES:5} +app.login-security.attempt-window=${LOGIN_RATE_ATTEMPT_WINDOW:15m} +app.login-security.base-block-duration=${LOGIN_RATE_BASE_BLOCK_DURATION:1m} +app.login-security.max-block-duration=${LOGIN_RATE_MAX_BLOCK_DURATION:15m} +app.login-security.audit-cleanup.enabled=${LOGIN_AUDIT_CLEANUP_ENABLED:true} +app.login-security.audit-cleanup.retention=${LOGIN_AUDIT_RETENTION:90d} +app.login-security.audit-cleanup.batch-size=${LOGIN_AUDIT_CLEANUP_BATCH_SIZE:500} +app.login-security.audit-cleanup.max-batches-per-tenant=${LOGIN_AUDIT_CLEANUP_MAX_BATCHES:20} +app.login-security.audit-cleanup.interval-ms=${LOGIN_AUDIT_CLEANUP_INTERVAL_MS:86400000} +app.login-security.audit-cleanup.initial-delay-ms=${LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS:120000} +app.http.trusted-proxy-cidrs=${TRUSTED_PROXY_CIDRS:} #logging.level.root=DEBUG diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index a0fc2fe..734d875 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS departments ( status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS specialties ( status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -46,11 +46,11 @@ CREATE TABLE IF NOT EXISTS specialty_profiles ( specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE, name VARCHAR(500) NOT NULL, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT, CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name) @@ -72,12 +72,12 @@ CREATE TABLE IF NOT EXISTS users ( full_name VARCHAR(255) NOT NULL, job_title VARCHAR(255) NOT NULL, department_id BIGINT NOT NULL REFERENCES departments(id), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -96,9 +96,9 @@ CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, tenant VARCHAR(100) NOT NULL, token_hash VARCHAR(64) UNIQUE NOT NULL, - issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP NOT NULL, - revoked_at TIMESTAMP, + issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, rotated_to_token_hash VARCHAR(64), user_agent VARCHAR(512), ip_address VARCHAR(64) @@ -110,6 +110,57 @@ CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires ON auth_refresh_tokens(tenant, expires_at); +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_cleanup_expires + ON auth_refresh_tokens(expires_at); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_cleanup_revoked + ON auth_refresh_tokens(revoked_at) + WHERE revoked_at IS NOT NULL; + +CREATE TABLE IF NOT EXISTS auth_login_rate_limits ( + id BIGSERIAL PRIMARY KEY, + tenant VARCHAR(100) NOT NULL, + username_normalized VARCHAR(100) NOT NULL, + client_ip VARCHAR(64) NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0, + window_started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_failure_at TIMESTAMPTZ, + blocked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_auth_login_rate_limit_key + UNIQUE (tenant, username_normalized, client_ip), + CONSTRAINT chk_auth_login_rate_limit_failure_count + CHECK (failure_count >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_auth_login_rate_limits_blocked_until + ON auth_login_rate_limits(blocked_until) + WHERE blocked_until IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_auth_login_rate_limits_updated_at + ON auth_login_rate_limits(updated_at, id); + +CREATE TABLE IF NOT EXISTS auth_login_attempt_audit ( + id BIGSERIAL PRIMARY KEY, + tenant VARCHAR(100) NOT NULL, + username_normalized VARCHAR(100) NOT NULL, + client_ip VARCHAR(64) NOT NULL, + outcome VARCHAR(20) NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + retry_after_seconds INTEGER, + CONSTRAINT chk_auth_login_attempt_audit_outcome + CHECK (outcome IN ('FAILURE', 'BLOCKED')), + CONSTRAINT chk_auth_login_attempt_audit_retry_after + CHECK (retry_after_seconds IS NULL OR retry_after_seconds > 0) +); + +CREATE INDEX IF NOT EXISTS idx_auth_login_attempt_audit_occurred + ON auth_login_attempt_audit(occurred_at, id); + +CREATE INDEX IF NOT EXISTS idx_auth_login_attempt_audit_key + ON auth_login_attempt_audit(tenant, username_normalized, client_ip, occurred_at DESC); + CREATE TABLE IF NOT EXISTS teacher_department_assignments ( id BIGSERIAL PRIMARY KEY, teacher_id BIGINT NOT NULL REFERENCES users(id), @@ -118,7 +169,7 @@ CREATE TABLE IF NOT EXISTS teacher_department_assignments ( valid_to DATE, is_primary BOOLEAN NOT NULL DEFAULT TRUE, comment TEXT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by BIGINT REFERENCES users(id), CONSTRAINT chk_teacher_department_dates CHECK (valid_to IS NULL OR valid_to >= valid_from) ); @@ -156,10 +207,10 @@ CREATE TABLE IF NOT EXISTS teacher_creation_requests ( comment TEXT, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', requested_by BIGINT REFERENCES users(id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, reviewed_by BIGINT REFERENCES users(id), - reviewed_at TIMESTAMP, + reviewed_at TIMESTAMPTZ, review_comment TEXT, created_teacher_id BIGINT REFERENCES users(id), CONSTRAINT chk_teacher_creation_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')) @@ -182,11 +233,11 @@ CREATE TABLE IF NOT EXISTS education_forms ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -209,11 +260,11 @@ CREATE TABLE IF NOT EXISTS student_groups ( specialty_id BIGINT NOT NULL REFERENCES specialties(id), specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), year_start_study BIGINT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -242,7 +293,7 @@ CREATE TABLE IF NOT EXISTS subgroups ( status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT, UNIQUE(group_id, name) @@ -271,33 +322,38 @@ ON CONFLICT (group_id, name) DO NOTHING; -- Дисциплины CREATE TABLE IF NOT EXISTS subjects ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(200) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, code VARCHAR(20), department_id BIGINT NOT NULL REFERENCES departments(id), description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); +CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci + ON subjects (LOWER(name)); +COMMENT ON INDEX uq_subjects_name_ci IS + 'Глобально уникальное название дисциплины без учёта регистра'; + INSERT INTO subjects (name, department_id) VALUES ('Высшая математика', 1), ('Философия', 1), ('Информатика', 1), ('Базы данных', 1), ('Английский язык', 1) -ON CONFLICT (name) DO NOTHING; +ON CONFLICT DO NOTHING; CREATE TABLE IF NOT EXISTS subject_comments ( id BIGSERIAL PRIMARY KEY, subject_id BIGINT NOT NULL REFERENCES subjects(id), author_id BIGINT REFERENCES users(id), comment TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_subject_comments_subject @@ -326,7 +382,7 @@ CREATE TABLE IF NOT EXISTS equipments ( status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -349,11 +405,11 @@ CREATE TABLE IF NOT EXISTS classrooms ( floor INT, is_available BOOLEAN DEFAULT TRUE, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, - archived_at TIMESTAMP, + archived_at TIMESTAMPTZ, archived_by BIGINT, archive_reason TEXT ); @@ -561,8 +617,8 @@ CREATE TABLE IF NOT EXISTS academic_calendars ( specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), study_form_id BIGINT NOT NULL REFERENCES education_forms(id), course_count INT NOT NULL DEFAULT 4, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8), CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title) ); @@ -622,7 +678,7 @@ CREATE TABLE IF NOT EXISTS academic_calendar_subjects ( calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, semester_number INT NOT NULL, subject_id BIGINT NOT NULL REFERENCES subjects(id), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0), CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id) ); @@ -638,7 +694,7 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments ( group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_group_calendar_year UNIQUE (group_id, academic_year_id) ); @@ -654,7 +710,9 @@ FROM student_groups sg JOIN academic_years ay ON ay.title = '2025-2026' JOIN academic_calendars calendar ON calendar.academic_year_id = ay.id + AND calendar.specialty_id = sg.specialty_id AND calendar.specialty_profile_id = sg.specialty_profile_id + AND calendar.study_form_id = sg.education_form_id WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м') ON CONFLICT (group_id, academic_year_id) DO NOTHING; @@ -674,7 +732,7 @@ CREATE TABLE IF NOT EXISTS schedule_rules ( version_group_id BIGINT, change_reason TEXT, created_by BIGINT REFERENCES users(id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK ( lecture_academic_hours >= 0 AND laboratory_academic_hours >= 0 @@ -718,7 +776,7 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots ( classroom_locked BOOLEAN NOT NULL DEFAULT FALSE, teacher_locked BOOLEAN NOT NULL DEFAULT FALSE, locked_by BIGINT REFERENCES users(id), - locked_at TIMESTAMP, + locked_at TIMESTAMPTZ, lock_comment TEXT, CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7), CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')), @@ -749,7 +807,7 @@ CREATE TABLE IF NOT EXISTS schedule_overrides ( new_lesson_format VARCHAR(30), comment TEXT, created_by BIGINT REFERENCES users(id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_schedule_overrides_action CHECK (action IN ('MOVE', 'CANCEL', 'REPLACE')), CONSTRAINT uq_schedule_overrides_slot_date UNIQUE (base_rule_slot_id, lesson_date) ); @@ -1029,6 +1087,13 @@ COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время от COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации'; COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена'; COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена'; +COMMENT ON TABLE auth_login_rate_limits IS 'Общий для backend-pod счётчик неудачных попыток входа'; +COMMENT ON COLUMN auth_login_rate_limits.username_normalized IS 'Нормализованное имя пользователя для ключа rate limit'; +COMMENT ON COLUMN auth_login_rate_limits.client_ip IS 'IP клиента, принятый только от настроенного доверенного proxy'; +COMMENT ON COLUMN auth_login_rate_limits.blocked_until IS 'Момент окончания временной блокировки комбинации tenant, username и IP'; +COMMENT ON TABLE auth_login_attempt_audit IS 'Аудит отклонённых и заблокированных попыток входа без паролей'; +COMMENT ON COLUMN auth_login_attempt_audit.outcome IS 'Результат попытки: FAILURE или BLOCKED'; +COMMENT ON COLUMN auth_login_attempt_audit.retry_after_seconds IS 'Возвращённый клиенту срок Retry-After в секундах'; COMMENT ON COLUMN education_forms.id IS 'ID формы обучения'; COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; @@ -1130,3 +1195,856 @@ COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назн COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; + +-- ========================================== +-- Ограничения активных подгрупп +-- ========================================== +ALTER TABLE subgroups + DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name + ON subgroups (group_id, lower(name)) + WHERE status <> 'ARCHIVED'; + +-- ========================================== +-- Инварианты точечных изменений расписания +-- ========================================== +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( + 'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте seed-данные V1 и повторите создание схемы.', + 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; + +-- ========================================== +-- Инварианты правил расписания +-- ========================================== +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( + 'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.', + 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( + 'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.', + 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 + ); + +-- ========================================== +-- Инварианты временных слотов +-- ========================================== +DO $$ +DECLARE + duration_violations BIGINT; + overlap_violations BIGINT; + scope_violations BIGINT; +BEGIN + SELECT count(*) + INTO duration_violations + FROM time_slots + WHERE duration_minutes <> floor(extract(epoch FROM (end_time - start_time)) / 60)::INT; + + SELECT count(*) + INTO overlap_violations + FROM time_slots first_slot + JOIN time_slots second_slot + ON second_slot.time_slot_scope_id = first_slot.time_slot_scope_id + AND second_slot.id > first_slot.id + AND first_slot.start_time < second_slot.end_time + AND second_slot.start_time < first_slot.end_time; + + SELECT count(*) + INTO scope_violations + FROM schedule_rule_slots rule_slot + JOIN time_slots time_slot ON time_slot.id = rule_slot.time_slot_id + JOIN time_slot_scopes scope ON scope.id = time_slot.time_slot_scope_id + WHERE scope.apply_mode <> 'DEFAULT'; + + IF duration_violations > 0 + OR overlap_violations > 0 + OR scope_violations > 0 THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = format( + 'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.', + duration_violations, + overlap_violations, + scope_violations + ); + END IF; +END $$; + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +ALTER TABLE time_slots + ADD CONSTRAINT chk_time_slots_duration_matches_range + CHECK ( + duration_minutes = floor(extract(epoch FROM (end_time - start_time)) / 60)::INT + ) NOT VALID; + +ALTER TABLE time_slots + VALIDATE CONSTRAINT chk_time_slots_duration_matches_range; + +ALTER TABLE time_slots + ADD CONSTRAINT ex_time_slots_scope_no_overlap + EXCLUDE USING gist ( + time_slot_scope_id WITH =, + (numrange( + extract(epoch FROM start_time), + extract(epoch FROM end_time), + '[)' + )) WITH && + ); + +CREATE OR REPLACE FUNCTION enforce_schedule_rule_slot_default_scope() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + target_apply_mode VARCHAR(20); +BEGIN + SELECT scope.apply_mode + INTO target_apply_mode + FROM time_slots time_slot + JOIN time_slot_scopes scope ON scope.id = time_slot.time_slot_scope_id + WHERE time_slot.id = NEW.time_slot_id + FOR KEY SHARE OF time_slot, scope; + + IF target_apply_mode IS NOT NULL AND target_apply_mode <> 'DEFAULT' THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'В правилах расписания можно использовать только слоты базовой сетки времени'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_schedule_rule_slots_default_scope + BEFORE INSERT OR UPDATE OF time_slot_id + ON schedule_rule_slots + FOR EACH ROW + EXECUTE FUNCTION enforce_schedule_rule_slot_default_scope(); + +CREATE OR REPLACE FUNCTION prevent_used_time_slot_scope_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + target_apply_mode VARCHAR(20); +BEGIN + SELECT apply_mode + INTO target_apply_mode + FROM time_slot_scopes + WHERE id = NEW.time_slot_scope_id; + + IF target_apply_mode <> 'DEFAULT' + AND EXISTS ( + SELECT 1 + FROM schedule_rule_slots + WHERE time_slot_id = OLD.id + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Слот, используемый в правилах расписания, нельзя переносить из базовой сетки времени'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_time_slots_protect_used_scope + BEFORE UPDATE OF time_slot_scope_id + ON time_slots + FOR EACH ROW + WHEN (OLD.time_slot_scope_id IS DISTINCT FROM NEW.time_slot_scope_id) + EXECUTE FUNCTION prevent_used_time_slot_scope_change(); + +CREATE OR REPLACE FUNCTION prevent_used_time_slot_scope_mode_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.apply_mode <> 'DEFAULT' + AND EXISTS ( + SELECT 1 + FROM time_slots time_slot + JOIN schedule_rule_slots rule_slot ON rule_slot.time_slot_id = time_slot.id + WHERE time_slot.time_slot_scope_id = OLD.id + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Сетку со слотами, используемыми в правилах расписания, нельзя сделать небазовой'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_time_slot_scopes_protect_used_mode + BEFORE UPDATE OF apply_mode + ON time_slot_scopes + FOR EACH ROW + WHEN (OLD.apply_mode IS DISTINCT FROM NEW.apply_mode) + EXECUTE FUNCTION prevent_used_time_slot_scope_mode_change(); + +-- ========================================== +-- Инварианты учебных периодов +-- ========================================== +DO $$ +DECLARE + academic_year_overlap_violations BIGINT; + semester_bounds_violations BIGINT; + semester_overlap_violations BIGINT; +BEGIN + SELECT count(*) + INTO academic_year_overlap_violations + FROM academic_years first_year + JOIN academic_years second_year + ON second_year.id > first_year.id + AND first_year.start_date <= second_year.end_date + AND second_year.start_date <= first_year.end_date; + + SELECT count(*) + INTO semester_bounds_violations + FROM semesters semester + JOIN academic_years academic_year ON academic_year.id = semester.academic_year_id + WHERE semester.start_date < academic_year.start_date + OR semester.end_date > academic_year.end_date; + + SELECT count(*) + INTO semester_overlap_violations + FROM semesters first_semester + JOIN semesters second_semester + ON second_semester.academic_year_id = first_semester.academic_year_id + AND second_semester.id > first_semester.id + AND first_semester.start_date <= second_semester.end_date + AND second_semester.start_date <= first_semester.end_date; + + IF academic_year_overlap_violations > 0 + OR semester_bounds_violations > 0 + OR semester_overlap_violations > 0 THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = format( + 'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.', + academic_year_overlap_violations, + semester_bounds_violations, + semester_overlap_violations + ); + END IF; +END $$; + +ALTER TABLE academic_years + ADD CONSTRAINT ex_academic_years_no_overlap + EXCLUDE USING gist ( + (daterange(start_date, end_date, '[]')) WITH && + ); + +ALTER TABLE semesters + ADD CONSTRAINT ex_semesters_year_no_overlap + EXCLUDE USING gist ( + academic_year_id WITH =, + (daterange(start_date, end_date, '[]')) WITH && + ); + +CREATE OR REPLACE FUNCTION enforce_semester_within_academic_year() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + academic_year_start DATE; + academic_year_end DATE; +BEGIN + SELECT start_date, end_date + INTO academic_year_start, academic_year_end + FROM academic_years + WHERE id = NEW.academic_year_id + FOR KEY SHARE; + + IF academic_year_start IS NOT NULL + AND ( + NEW.start_date < academic_year_start + OR NEW.end_date > academic_year_end + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Семестр должен полностью находиться в границах учебного года'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_semesters_within_academic_year + BEFORE INSERT OR UPDATE OF academic_year_id, start_date, end_date + ON semesters + FOR EACH ROW + EXECUTE FUNCTION enforce_semester_within_academic_year(); + +CREATE OR REPLACE FUNCTION prevent_academic_year_excluding_semesters() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM semesters semester + WHERE semester.academic_year_id = OLD.id + AND ( + semester.start_date < NEW.start_date + OR semester.end_date > NEW.end_date + ) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Новые границы учебного года не включают все его семестры'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_academic_years_include_semesters + BEFORE UPDATE OF start_date, end_date + ON academic_years + FOR EACH ROW + WHEN (OLD.start_date IS DISTINCT FROM NEW.start_date OR OLD.end_date IS DISTINCT FROM NEW.end_date) + EXECUTE FUNCTION prevent_academic_year_excluding_semesters(); + +-- ========================================== +-- Инварианты зависимостей календарных графиков +-- ========================================== +DO $$ +DECLARE + assignment_violations BIGINT; + calendar_day_violations BIGINT; + calendar_subject_violations BIGINT; +BEGIN + SELECT count(*) + INTO assignment_violations + FROM student_group_calendar_assignments assignment + JOIN student_groups student_group ON student_group.id = assignment.group_id + JOIN academic_years academic_year ON academic_year.id = assignment.academic_year_id + JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id + WHERE assignment.academic_year_id <> calendar.academic_year_id + OR student_group.specialty_id <> calendar.specialty_id + OR student_group.specialty_profile_id <> calendar.specialty_profile_id + OR student_group.education_form_id <> calendar.study_form_id + OR CASE + WHEN extract(month FROM academic_year.start_date) >= 9 + THEN extract(year FROM academic_year.start_date)::INT + - student_group.year_start_study::INT + 1 + ELSE extract(year FROM academic_year.start_date)::INT + - student_group.year_start_study::INT + END NOT BETWEEN 1 AND calendar.course_count; + + SELECT count(*) + INTO calendar_day_violations + FROM academic_calendar_days calendar_day + JOIN academic_calendars calendar ON calendar.id = calendar_day.calendar_id + JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id + WHERE calendar_day.course_number > calendar.course_count + OR calendar_day.date < academic_year.start_date + OR calendar_day.date > academic_year.end_date; + + SELECT count(*) + INTO calendar_subject_violations + FROM academic_calendar_subjects calendar_subject + JOIN academic_calendars calendar ON calendar.id = calendar_subject.calendar_id + WHERE calendar_subject.semester_number > calendar.course_count * 2; + + IF assignment_violations > 0 + OR calendar_day_violations > 0 + OR calendar_subject_violations > 0 THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = format( + 'Невозможно создать baseline: несовместимые назначения графиков=%s, строки сетки вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.', + assignment_violations, + calendar_day_violations, + calendar_subject_violations + ); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION calculate_group_course_for_academic_year( + academic_year_start DATE, + group_start_year BIGINT +) +RETURNS INTEGER +LANGUAGE SQL +IMMUTABLE +STRICT +AS $$ + SELECT CASE + WHEN extract(month FROM academic_year_start) >= 9 + THEN extract(year FROM academic_year_start)::INT - group_start_year::INT + 1 + ELSE extract(year FROM academic_year_start)::INT - group_start_year::INT + END +$$; + +CREATE OR REPLACE FUNCTION enforce_calendar_assignment_compatibility() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + calendar_academic_year_id BIGINT; + calendar_specialty_id BIGINT; + calendar_profile_id BIGINT; + calendar_study_form_id BIGINT; + calendar_course_count INTEGER; + group_specialty_id BIGINT; + group_profile_id BIGINT; + group_study_form_id BIGINT; + group_course INTEGER; +BEGIN + SELECT + calendar.academic_year_id, + calendar.specialty_id, + calendar.specialty_profile_id, + calendar.study_form_id, + calendar.course_count, + student_group.specialty_id, + student_group.specialty_profile_id, + student_group.education_form_id, + calculate_group_course_for_academic_year( + academic_year.start_date, + student_group.year_start_study + ) + INTO + calendar_academic_year_id, + calendar_specialty_id, + calendar_profile_id, + calendar_study_form_id, + calendar_course_count, + group_specialty_id, + group_profile_id, + group_study_form_id, + group_course + FROM student_groups student_group + CROSS JOIN academic_calendars calendar + JOIN academic_years academic_year ON academic_year.id = NEW.academic_year_id + WHERE student_group.id = NEW.group_id + AND calendar.id = NEW.calendar_id + FOR NO KEY UPDATE OF student_group + FOR KEY SHARE OF calendar, academic_year; + + IF calendar_academic_year_id IS NOT NULL + AND ( + NEW.academic_year_id <> calendar_academic_year_id + OR group_specialty_id <> calendar_specialty_id + OR group_profile_id <> calendar_profile_id + OR group_study_form_id <> calendar_study_form_id + OR group_course NOT BETWEEN 1 AND calendar_course_count + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Календарный график не соответствует учебному году, параметрам или курсу группы'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_calendar_assignments_compatible + BEFORE INSERT OR UPDATE OF group_id, academic_year_id, calendar_id + ON student_group_calendar_assignments + FOR EACH ROW + EXECUTE FUNCTION enforce_calendar_assignment_compatibility(); + +CREATE OR REPLACE FUNCTION prevent_incompatible_academic_calendar_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + academic_year_start DATE; + academic_year_end DATE; +BEGIN + SELECT start_date, end_date + INTO academic_year_start, academic_year_end + FROM academic_years + WHERE id = NEW.academic_year_id + FOR KEY SHARE; + + IF EXISTS ( + SELECT 1 + FROM student_group_calendar_assignments assignment + JOIN student_groups student_group ON student_group.id = assignment.group_id + JOIN academic_years assigned_year ON assigned_year.id = assignment.academic_year_id + WHERE assignment.calendar_id = OLD.id + AND ( + assignment.academic_year_id <> NEW.academic_year_id + OR student_group.specialty_id <> NEW.specialty_id + OR student_group.specialty_profile_id <> NEW.specialty_profile_id + OR student_group.education_form_id <> NEW.study_form_id + OR calculate_group_course_for_academic_year( + assigned_year.start_date, + student_group.year_start_study + ) NOT BETWEEN 1 AND NEW.course_count + ) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Нельзя изменить график: сохранённые назначения групп станут несовместимыми'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM academic_calendar_days calendar_day + WHERE calendar_day.calendar_id = OLD.id + AND ( + calendar_day.course_number > NEW.course_count + OR calendar_day.date < academic_year_start + OR calendar_day.date > academic_year_end + ) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Нельзя изменить график: сохранённая сетка выходит за новые измерения'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM academic_calendar_subjects calendar_subject + WHERE calendar_subject.calendar_id = OLD.id + AND calendar_subject.semester_number > NEW.course_count * 2 + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Нельзя изменить график: дисциплины выходят за новые измерения'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_academic_calendars_protect_dependencies + BEFORE UPDATE OF academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count + ON academic_calendars + FOR EACH ROW + EXECUTE FUNCTION prevent_incompatible_academic_calendar_change(); + +CREATE OR REPLACE FUNCTION prevent_incompatible_student_group_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM student_group_calendar_assignments assignment + JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id + JOIN academic_years academic_year ON academic_year.id = assignment.academic_year_id + WHERE assignment.group_id = OLD.id + AND ( + assignment.academic_year_id <> calendar.academic_year_id + OR NEW.specialty_id <> calendar.specialty_id + OR NEW.specialty_profile_id <> calendar.specialty_profile_id + OR NEW.education_form_id <> calendar.study_form_id + OR calculate_group_course_for_academic_year( + academic_year.start_date, + NEW.year_start_study + ) NOT BETWEEN 1 AND calendar.course_count + ) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_student_groups_protect_calendar_assignments + BEFORE UPDATE OF education_form_id, specialty_id, specialty_profile_id, year_start_study + ON student_groups + FOR EACH ROW + EXECUTE FUNCTION prevent_incompatible_student_group_change(); + +CREATE OR REPLACE FUNCTION enforce_calendar_day_dimensions() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + calendar_course_count INTEGER; + academic_year_start DATE; + academic_year_end DATE; +BEGIN + SELECT calendar.course_count, academic_year.start_date, academic_year.end_date + INTO calendar_course_count, academic_year_start, academic_year_end + FROM academic_calendars calendar + JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id + WHERE calendar.id = NEW.calendar_id + FOR KEY SHARE OF calendar, academic_year; + + IF calendar_course_count IS NOT NULL + AND ( + NEW.course_number > calendar_course_count + OR NEW.date < academic_year_start + OR NEW.date > academic_year_end + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Строка сетки выходит за измерения календарного графика'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_calendar_days_dimensions + BEFORE INSERT OR UPDATE OF calendar_id, course_number, date + ON academic_calendar_days + FOR EACH ROW + EXECUTE FUNCTION enforce_calendar_day_dimensions(); + +CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + calendar_course_count INTEGER; +BEGIN + SELECT course_count + INTO calendar_course_count + FROM academic_calendars + WHERE id = NEW.calendar_id + FOR KEY SHARE; + + IF calendar_course_count IS NOT NULL + AND NEW.semester_number > calendar_course_count * 2 THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Номер семестра дисциплины выходит за измерения календарного графика'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_calendar_subjects_dimensions + BEFORE INSERT OR UPDATE OF calendar_id, semester_number + ON academic_calendar_subjects + FOR EACH ROW + EXECUTE FUNCTION enforce_calendar_subject_dimensions(); + +CREATE OR REPLACE FUNCTION prevent_academic_year_breaking_calendar_dependencies() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM academic_calendars calendar + JOIN academic_calendar_days calendar_day ON calendar_day.calendar_id = calendar.id + WHERE calendar.academic_year_id = OLD.id + AND ( + calendar_day.date < NEW.start_date + OR calendar_day.date > NEW.end_date + ) + ) OR EXISTS ( + SELECT 1 + FROM student_group_calendar_assignments assignment + JOIN student_groups student_group ON student_group.id = assignment.group_id + JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id + WHERE assignment.academic_year_id = OLD.id + AND calculate_group_course_for_academic_year( + NEW.start_date, + student_group.year_start_study + ) NOT BETWEEN 1 AND calendar.course_count + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'Новые границы учебного года нарушают календарные сетки или назначения групп'; + END IF; + + RETURN NEW; +END $$; + +CREATE TRIGGER trg_academic_years_protect_calendar_dependencies + BEFORE UPDATE OF start_date, end_date + ON academic_years + FOR EACH ROW + WHEN (OLD.start_date IS DISTINCT FROM NEW.start_date OR OLD.end_date IS DISTINCT FROM NEW.end_date) + EXECUTE FUNCTION prevent_academic_year_breaking_calendar_dependencies(); + +-- ========================================== +-- Историческая принадлежность преподавателей к кафедрам +-- ========================================== +ALTER TABLE teacher_department_assignments + ADD CONSTRAINT ex_teacher_primary_department_no_overlap + EXCLUDE USING gist ( + teacher_id WITH =, + (daterange(valid_from, COALESCE(valid_to, 'infinity'::DATE), '[]')) WITH && + ) + WHERE (is_primary = TRUE); + +COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap + ON teacher_department_assignments + IS 'У преподавателя не могут пересекаться периоды двух основных кафедр'; diff --git a/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql b/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql deleted file mode 100644 index 9f5959d..0000000 --- a/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE subgroups - DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key; - -CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name - ON subgroups (group_id, lower(name)) - WHERE status <> 'ARCHIVED'; 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 deleted file mode 100644 index ca5b5e6..0000000 --- a/backend/src/main/resources/db/migration/V3__schedule_override_invariants.sql +++ /dev/null @@ -1,94 +0,0 @@ -DO $$ -DECLARE - cancel_violations BIGINT; - move_violations BIGINT; - replace_violations BIGINT; - format_violations BIGINT; -BEGIN - SELECT count(*) - INTO cancel_violations - FROM schedule_overrides - WHERE action = 'CANCEL' - AND (new_time_slot_id IS NOT NULL - OR new_classroom_id IS NOT NULL - OR new_teacher_id IS NOT NULL - OR new_lesson_format IS NOT NULL); - - SELECT count(*) - INTO move_violations - FROM schedule_overrides - WHERE action = 'MOVE' - AND new_time_slot_id IS NULL - AND new_classroom_id IS NULL; - - SELECT count(*) - INTO replace_violations - FROM schedule_overrides - WHERE action = 'REPLACE' - AND new_teacher_id IS NULL - AND new_classroom_id IS NULL - AND new_lesson_format IS NULL; - - SELECT count(*) - INTO format_violations - FROM schedule_overrides - WHERE new_lesson_format IS NOT NULL - AND new_lesson_format NOT IN ('Очно', 'Онлайн'); - - IF cancel_violations > 0 - OR move_violations > 0 - OR replace_violations > 0 - OR format_violations > 0 THEN - RAISE EXCEPTION USING - ERRCODE = '23514', - MESSAGE = format( - 'Невозможно применить инварианты точечных изменений: CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте данные tenant-БД и повторите миграцию.', - cancel_violations, - move_violations, - replace_violations, - format_violations - ); - END IF; -END $$; - -ALTER TABLE schedule_overrides - ADD CONSTRAINT chk_schedule_overrides_cancel_payload - CHECK ( - action <> 'CANCEL' - OR ( - new_time_slot_id IS NULL - AND new_classroom_id IS NULL - AND new_teacher_id IS NULL - AND new_lesson_format IS NULL - ) - ) NOT VALID, - ADD CONSTRAINT chk_schedule_overrides_move_payload - CHECK ( - action <> 'MOVE' - OR new_time_slot_id IS NOT NULL - OR new_classroom_id IS NOT NULL - ) NOT VALID, - ADD CONSTRAINT chk_schedule_overrides_replace_payload - CHECK ( - action <> 'REPLACE' - OR new_teacher_id IS NOT NULL - OR new_classroom_id IS NOT NULL - OR new_lesson_format IS NOT NULL - ) NOT VALID, - ADD CONSTRAINT chk_schedule_overrides_format - CHECK ( - new_lesson_format IS NULL - OR new_lesson_format IN ('Очно', 'Онлайн') - ) NOT VALID; - -ALTER TABLE schedule_overrides - VALIDATE CONSTRAINT chk_schedule_overrides_cancel_payload; - -ALTER TABLE schedule_overrides - VALIDATE CONSTRAINT chk_schedule_overrides_move_payload; - -ALTER TABLE schedule_overrides - VALIDATE CONSTRAINT chk_schedule_overrides_replace_payload; - -ALTER TABLE schedule_overrides - VALIDATE CONSTRAINT chk_schedule_overrides_format; 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 deleted file mode 100644 index ee2d767..0000000 --- a/backend/src/main/resources/db/migration/V4__schedule_rule_even_hours.sql +++ /dev/null @@ -1,97 +0,0 @@ -DO $$ -DECLARE - lecture_violations BIGINT; - laboratory_violations BIGINT; - practice_violations BIGINT; -BEGIN - SELECT count(*) - INTO lecture_violations - FROM schedule_rules - WHERE mod(lecture_academic_hours, 2) <> 0; - - SELECT count(*) - INTO laboratory_violations - FROM schedule_rules - WHERE mod(laboratory_academic_hours, 2) <> 0; - - SELECT count(*) - INTO practice_violations - FROM schedule_rules - WHERE mod(practice_academic_hours, 2) <> 0; - - IF lecture_violations > 0 - OR laboratory_violations > 0 - OR practice_violations > 0 THEN - RAISE EXCEPTION USING - ERRCODE = '23514', - MESSAGE = format( - 'Невозможно применить инвариант чётности академических часов правил расписания: лекции=%s, лабораторные=%s, практики=%s. Исправьте данные tenant-БД и повторите миграцию.', - lecture_violations, - laboratory_violations, - practice_violations - ); - END IF; -END $$; - -DO $$ -DECLARE - duplicate_slot_groups BIGINT; -BEGIN - SELECT count(*) - INTO duplicate_slot_groups - FROM ( - SELECT - schedule_rule_id, - day_of_week, - parity, - time_slot_id, - teacher_id, - classroom_id, - lesson_type_id, - lesson_format - FROM schedule_rule_slots - GROUP BY - schedule_rule_id, - day_of_week, - parity, - time_slot_id, - teacher_id, - classroom_id, - lesson_type_id, - lesson_format - HAVING count(*) > 1 - ) duplicates; - - IF duplicate_slot_groups > 0 THEN - RAISE EXCEPTION USING - ERRCODE = '23505', - MESSAGE = format( - 'Невозможно применить инвариант уникальности слотов правил расписания: найдено групп точных дублей=%s. Исправьте данные tenant-БД и повторите миграцию.', - duplicate_slot_groups - ); - END IF; -END $$; - -ALTER TABLE schedule_rules - ADD CONSTRAINT chk_schedule_rules_academic_hours_even - CHECK ( - mod(lecture_academic_hours, 2) = 0 - AND mod(laboratory_academic_hours, 2) = 0 - AND mod(practice_academic_hours, 2) = 0 - ) NOT VALID; - -ALTER TABLE schedule_rules - VALIDATE CONSTRAINT chk_schedule_rules_academic_hours_even; - -ALTER TABLE schedule_rule_slots - ADD CONSTRAINT uq_schedule_rule_slots_exact_payload - UNIQUE ( - schedule_rule_id, - day_of_week, - parity, - time_slot_id, - teacher_id, - classroom_id, - lesson_type_id, - lesson_format - ); diff --git a/backend/src/test/java/com/magistr/app/config/auth/ClientIpResolverTest.java b/backend/src/test/java/com/magistr/app/config/auth/ClientIpResolverTest.java new file mode 100644 index 0000000..f9c0bc7 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/ClientIpResolverTest.java @@ -0,0 +1,51 @@ +package com.magistr.app.config.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ClientIpResolverTest { + + @Test + void ignoresForwardedHeaderFromUntrustedClient() { + ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16"); + MockHttpServletRequest request = request("203.0.113.7", "192.0.2.10"); + + assertThat(resolver.resolve(request)).isEqualTo("203.0.113.7"); + } + + @Test + void selectsFirstUntrustedAddressFromRightBehindTrustedProxyChain() { + ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16, 172.16.0.0/12"); + MockHttpServletRequest request = request( + "10.42.1.15", + "192.0.2.99, 198.51.100.23, 172.18.0.4" + ); + + assertThat(resolver.resolve(request)).isEqualTo("198.51.100.23"); + } + + @Test + void rejectsEntireForwardedHeaderWhenItContainsInvalidAddress() { + ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16"); + MockHttpServletRequest request = request("10.42.1.15", "198.51.100.23, attacker.example"); + + assertThat(resolver.resolve(request)).isEqualTo("10.42.1.15"); + } + + @Test + void rejectsInvalidTrustedProxyConfigurationAtStartup() { + assertThatThrownBy(() -> new ClientIpResolver("10.42.0.0/99")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Некорректная маска"); + } + + private MockHttpServletRequest request(String remoteAddress, String forwardedFor) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr(remoteAddress); + request.addHeader("X-Forwarded-For", forwardedFor); + return request; + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitConcurrencyIntegrationTest.java new file mode 100644 index 0000000..62227ee --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitConcurrencyIntegrationTest.java @@ -0,0 +1,200 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.AuthLoginRateLimit; +import com.magistr.app.repository.AuthLoginAttemptAuditRepository; +import com.magistr.app.repository.AuthLoginRateLimitRepository; +import com.magistr.app.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Timestamp; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +@SpringBootTest( + classes = LoginRateLimitConcurrencyIntegrationTest.TestApplication.class, + properties = "management.endpoint.health.validate-group-membership=false" +) +class LoginRateLimitConcurrencyIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z"); + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + + @DynamicPropertySource + static void databaseProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + } + + @Autowired + private AuthLoginRateLimitRepository rateLimitRepository; + + @Autowired + private AuthLoginAttemptAuditRepository auditRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private ExecutorService executor; + + @BeforeEach + void clearLoginSecurityTables() { + auditRepository.deleteAll(); + rateLimitRepository.deleteAll(); + } + + @AfterEach + void stopExecutor() throws InterruptedException { + if (executor != null) { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void twoServiceInstancesShareAtomicPostgreSqlCounter() throws Exception { + LoginSecurityProperties properties = new LoginSecurityProperties(); + properties.setMaxFailures(2); + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4); + LoginRateLimitService firstPod = service(properties, encoder); + LoginRateLimitService secondPod = service(properties, encoder); + String username = "missing-" + UUID.randomUUID(); + String clientIp = "198.51.100.42"; + + executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List> futures = List.of( + executor.submit(() -> authenticateAfterSignal(firstPod, username, clientIp, ready, start)), + executor.submit(() -> authenticateAfterSignal(secondPod, username, clientIp, ready, start)) + ); + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List statuses = List.of( + futures.get(0).get(10, TimeUnit.SECONDS).status(), + futures.get(1).get(10, TimeUnit.SECONDS).status() + ); + assertThat(statuses).containsExactlyInAnyOrder( + LoginRateLimitService.AuthenticationDecision.Status.REJECTED, + LoginRateLimitService.AuthenticationDecision.Status.BLOCKED + ); + + Integer failureCount = jdbcTemplate.queryForObject(""" + SELECT failure_count + FROM auth_login_rate_limits + WHERE tenant = ? AND username_normalized = ? AND client_ip = ? + """, Integer.class, "tenant", username, clientIp); + assertThat(failureCount).isEqualTo(2); + assertThat(auditRepository.count()).isEqualTo(2); + } + + @Test + void cleanupDeletesOnlyExpiredAuditAndInactiveCounters() { + Instant now = NOW; + Instant cutoff = now.minus(90, java.time.temporal.ChronoUnit.DAYS); + jdbcTemplate.update(""" + INSERT INTO auth_login_attempt_audit + (tenant, username_normalized, client_ip, outcome, occurred_at) + VALUES + ('tenant', 'old', '198.51.100.1', 'FAILURE', ?), + ('tenant', 'fresh', '198.51.100.2', 'FAILURE', ?) + """, timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)), + timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS))); + jdbcTemplate.update(""" + INSERT INTO auth_login_rate_limits + (tenant, username_normalized, client_ip, failure_count, + window_started_at, blocked_until, updated_at) + VALUES + ('tenant', 'old', '198.51.100.1', 1, ?, NULL, ?), + ('tenant', 'blocked', '198.51.100.2', 5, ?, ?, ?), + ('tenant', 'fresh', '198.51.100.3', 1, ?, NULL, ?) + """, + timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)), + timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)), + timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)), + timestamp(now.plusSeconds(300)), + timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)), + timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)), + timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS))); + + assertThat(auditRepository.deleteCleanupBatch(cutoff, 100)).isEqualTo(1); + assertThat(rateLimitRepository.deleteStaleBatch(cutoff, now, 100)).isEqualTo(1); + assertThat(auditRepository.count()).isEqualTo(1); + assertThat(rateLimitRepository.count()).isEqualTo(2); + } + + private Timestamp timestamp(Instant value) { + return Timestamp.from(value); + } + + private LoginRateLimitService.AuthenticationDecision authenticateAfterSignal( + LoginRateLimitService service, + String username, + String clientIp, + CountDownLatch ready, + CountDownLatch start + ) throws InterruptedException { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Не удалось синхронно запустить попытки входа"); + } + return transactionTemplate.execute(status -> + service.authenticate("tenant", username, "неверный-пароль", clientIp) + ); + } + + private LoginRateLimitService service(LoginSecurityProperties properties, + BCryptPasswordEncoder encoder) { + return new LoginRateLimitService( + rateLimitRepository, + auditRepository, + userRepository, + encoder, + properties, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = AuthLoginRateLimit.class) + @EnableJpaRepositories(basePackageClasses = AuthLoginRateLimitRepository.class) + static class TestApplication { + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitServiceTest.java new file mode 100644 index 0000000..1ed832d --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitServiceTest.java @@ -0,0 +1,149 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.AuthLoginAttemptAudit; +import com.magistr.app.model.AuthLoginRateLimit; +import com.magistr.app.model.User; +import com.magistr.app.repository.AuthLoginAttemptAuditRepository; +import com.magistr.app.repository.AuthLoginRateLimitRepository; +import com.magistr.app.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LoginRateLimitServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z"); + + private AuthLoginRateLimitRepository rateLimitRepository; + private AuthLoginAttemptAuditRepository auditRepository; + private UserRepository userRepository; + private BCryptPasswordEncoder passwordEncoder; + private LoginSecurityProperties properties; + + @BeforeEach + void setUp() { + rateLimitRepository = mock(AuthLoginRateLimitRepository.class); + auditRepository = mock(AuthLoginAttemptAuditRepository.class); + userRepository = mock(UserRepository.class); + passwordEncoder = new BCryptPasswordEncoder(4); + properties = new LoginSecurityProperties(); + } + + @Test + void fifthFailureBlocksKeyAndWritesAuditWithoutPassword() { + AuthLoginRateLimit state = state(4, NOW, null); + when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5")) + .thenReturn(Optional.of(state)); + when(userRepository.findByUsername("Admin")).thenReturn(Optional.empty()); + LoginRateLimitService service = service(); + + LoginRateLimitService.AuthenticationDecision decision = service.authenticate( + "tenant", "Admin", "секретный-пароль", "198.51.100.5" + ); + + assertThat(decision.status()) + .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED); + assertThat(decision.retryAfterSeconds()).isEqualTo(60); + assertThat(state.getFailureCount()).isEqualTo(5); + assertThat(state.getBlockedUntil()).isEqualTo(NOW.plusSeconds(60)); + + ArgumentCaptor auditCaptor = + ArgumentCaptor.forClass(AuthLoginAttemptAudit.class); + verify(auditRepository).save(auditCaptor.capture()); + assertThat(auditCaptor.getValue().getOutcome()).isEqualTo("BLOCKED"); + assertThat(auditCaptor.getValue().getRetryAfterSeconds()).isEqualTo(60); + assertThat(auditCaptor.getValue().getUsernameNormalized()).isEqualTo("admin"); + } + + @Test + void activeBlockDoesNotQueryUserAndReturnsRemainingDelay() { + AuthLoginRateLimit state = state(5, NOW, NOW.plusSeconds(37)); + when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5")) + .thenReturn(Optional.of(state)); + LoginRateLimitService service = service(); + + LoginRateLimitService.AuthenticationDecision decision = service.authenticate( + "tenant", "admin", "любой", "198.51.100.5" + ); + + assertThat(decision.status()) + .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED); + assertThat(decision.retryAfterSeconds()).isEqualTo(37); + verify(userRepository, never()).findByUsername(any()); + } + + @Test + void archivedUserIsRejectedLikeUnknownUser() { + AuthLoginRateLimit state = state(0, NOW, null); + when(rateLimitRepository.findForUpdate("tenant", "archived", "198.51.100.5")) + .thenReturn(Optional.of(state)); + User user = new User(); + user.setPassword(passwordEncoder.encode("верный-пароль")); + user.archive("Тест"); + when(userRepository.findByUsername("archived")).thenReturn(Optional.of(user)); + LoginRateLimitService service = service(); + + LoginRateLimitService.AuthenticationDecision decision = service.authenticate( + "tenant", "archived", "верный-пароль", "198.51.100.5" + ); + + assertThat(decision.status()) + .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.REJECTED); + assertThat(state.getFailureCount()).isEqualTo(1); + } + + @Test + void successfulLoginRemovesPreviousFailureState() { + AuthLoginRateLimit state = state(2, NOW, null); + when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5")) + .thenReturn(Optional.of(state)); + User user = new User(); + user.setPassword(passwordEncoder.encode("верный-пароль")); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user)); + LoginRateLimitService service = service(); + + LoginRateLimitService.AuthenticationDecision decision = service.authenticate( + "tenant", "admin", "верный-пароль", "198.51.100.5" + ); + + assertThat(decision.status()) + .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.AUTHENTICATED); + assertThat(decision.user()).isSameAs(user); + verify(rateLimitRepository).delete(state); + verify(auditRepository, never()).save(any()); + } + + private LoginRateLimitService service() { + return new LoginRateLimitService( + rateLimitRepository, + auditRepository, + userRepository, + passwordEncoder, + properties, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + } + + private AuthLoginRateLimit state(int failureCount, + Instant windowStartedAt, + Instant blockedUntil) { + AuthLoginRateLimit state = new AuthLoginRateLimit(); + state.setFailureCount(failureCount); + state.setWindowStartedAt(windowStartedAt); + state.setBlockedUntil(blockedUntil); + return state; + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenCleanupJobTest.java b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenCleanupJobTest.java new file mode 100644 index 0000000..5301a34 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenCleanupJobTest.java @@ -0,0 +1,195 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.repository.AuthRefreshTokenRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +@DisplayName("Tenant-aware очистка refresh-сессий") +class RefreshTokenCleanupJobTest { + + private static final ZoneId ZONE = ZoneId.of("Europe/Moscow"); + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-18T09:00:00Z"), ZONE); + private static final Instant NOW = CLOCK.instant(); + private static final Duration RETENTION = Duration.ofDays(30); + + private ExecutorService executor; + + @AfterEach + void cleanup() throws InterruptedException { + TenantContext.clear(); + if (executor != null) { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + @DisplayName("два pod удаляют старые строки идемпотентно и сохраняют active/fresh audit") + void twoPodsKeepActiveAndFreshAuditRows() throws Exception { + TenantRoutingDataSource routingDataSource = routing("alpha", "beta"); + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + TokenStore store = new TokenStore("alpha", "beta"); + when(repository.deleteCleanupBatch(any(Instant.class), anyInt())) + .thenAnswer(invocation -> store.deleteBatch( + TenantContext.getCurrentTenant(), + invocation.getArgument(0), + invocation.getArgument(1) + )); + RefreshTokenCleanupJob firstPod = job(routingDataSource, repository, true); + RefreshTokenCleanupJob secondPod = job(routingDataSource, repository, true); + + executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + Future first = executor.submit(() -> { + start.await(5, TimeUnit.SECONDS); + return firstPod.cleanupNow(); + }); + Future second = executor.submit(() -> { + start.await(5, TimeUnit.SECONDS); + return secondPod.cleanupNow(); + }); + start.countDown(); + + var firstSummary = first.get(5, TimeUnit.SECONDS); + var secondSummary = second.get(5, TimeUnit.SECONDS); + + assertThat(firstSummary.deletedCount() + secondSummary.deletedCount()).isEqualTo(4); + assertThat(firstSummary.failedTenantCount() + secondSummary.failedTenantCount()).isZero(); + assertThat(store.labels("alpha")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active"); + assertThat(store.labels("beta")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active"); + + var repeated = firstPod.cleanupNow(); + assertThat(repeated.deletedCount()).isZero(); + assertThat(store.labels("alpha")).hasSize(3); + assertThat(store.labels("beta")).hasSize(3); + } + + @Test + @DisplayName("повторный локальный запуск пропускается, пока текущий проход не завершён") + void overlappingRunInsidePodIsSkipped() throws Exception { + TenantRoutingDataSource routingDataSource = routing("alpha"); + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + CountDownLatch enteredRepository = new CountDownLatch(1); + CountDownLatch releaseRepository = new CountDownLatch(1); + when(repository.deleteCleanupBatch(any(Instant.class), anyInt())).thenAnswer(invocation -> { + enteredRepository.countDown(); + if (!releaseRepository.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Тест не разрешил завершить очистку"); + } + return 0; + }); + RefreshTokenCleanupJob job = job(routingDataSource, repository, true); + executor = Executors.newSingleThreadExecutor(); + Future running = executor.submit(job::cleanupNow); + assertThat(enteredRepository.await(5, TimeUnit.SECONDS)).isTrue(); + + var overlapping = job.cleanupNow(); + + assertThat(overlapping.skippedBecauseAlreadyRunning()).isTrue(); + releaseRepository.countDown(); + assertThat(running.get(5, TimeUnit.SECONDS).skippedBecauseAlreadyRunning()).isFalse(); + } + + @Test + @DisplayName("отключённое расписание не обращается к tenant-БД") + void disabledScheduleDoesNothing() { + TenantRoutingDataSource routingDataSource = routing("alpha"); + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + RefreshTokenCleanupJob job = job(routingDataSource, repository, false); + + job.cleanupScheduled(); + + verifyNoInteractions(repository); + } + + private RefreshTokenCleanupJob job(TenantRoutingDataSource routingDataSource, + AuthRefreshTokenRepository repository, + boolean enabled) { + return new RefreshTokenCleanupJob( + routingDataSource, + repository, + enabled, + RETENTION, + 2, + 10, + CLOCK + ); + } + + private TenantRoutingDataSource routing(String... tenants) { + TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class); + Map configs = new LinkedHashMap<>(); + for (String tenant : tenants) { + configs.put(tenant, new TenantConfig(tenant, tenant, "jdbc:test:" + tenant, "user", "password")); + } + when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.copyOf(configs)); + return routingDataSource; + } + + private static final class TokenStore { + + private final Map> rowsByTenant = new LinkedHashMap<>(); + + private TokenStore(String... tenants) { + for (String tenant : tenants) { + rowsByTenant.put(tenant, new ArrayList<>(List.of( + new TokenRow("old-expired", minusDays(NOW, 31), null), + new TokenRow("old-revoked", plusDays(NOW, 7), minusDays(NOW, 31)), + new TokenRow("fresh-expired", minusDays(NOW, 1), null), + new TokenRow("fresh-revoked", plusDays(NOW, 7), minusDays(NOW, 1)), + new TokenRow("active", plusDays(NOW, 7), null) + ))); + } + } + + private synchronized int deleteBatch(String tenant, Instant cutoff, int batchSize) { + assertThat(tenant).isNotBlank(); + List rows = rowsByTenant.get(tenant); + List candidates = rows.stream() + .filter(row -> row.expiresAt().isBefore(cutoff) + || row.revokedAt() != null && row.revokedAt().isBefore(cutoff)) + .limit(batchSize) + .toList(); + rows.removeAll(candidates); + return candidates.size(); + } + + private synchronized List labels(String tenant) { + return rowsByTenant.get(tenant).stream().map(TokenRow::label).toList(); + } + } + + private static Instant minusDays(Instant value, long days) { + return value.minus(days, java.time.temporal.ChronoUnit.DAYS); + } + + private static Instant plusDays(Instant value, long days) { + return value.plus(days, java.time.temporal.ChronoUnit.DAYS); + } + + private record TokenRow(String label, Instant expiresAt, Instant revokedAt) { + } +} 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 index ea5e02b..8f197b1 100644 --- a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java @@ -28,6 +28,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -131,6 +133,82 @@ class RefreshTokenServiceConcurrencyIntegrationTest { assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1); } + @Test + void concurrentCleanupKeepsActiveAndFreshAuditRows() throws Exception { + User user = createTestUser(); + Instant now = Instant.now(); + Instant cutoff = now.minus(30, ChronoUnit.DAYS); + AuthRefreshToken oldExpired = token(user, "old-expired", now.minus(31, ChronoUnit.DAYS), null); + AuthRefreshToken oldRevoked = token(user, "old-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(31, ChronoUnit.DAYS)); + AuthRefreshToken freshExpired = token(user, "fresh-expired", now.minus(1, ChronoUnit.DAYS), null); + AuthRefreshToken freshRevoked = token(user, "fresh-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(1, ChronoUnit.DAYS)); + AuthRefreshToken active = token(user, "active", now.plus(7, ChronoUnit.DAYS), null); + tokenRepository.saveAllAndFlush(List.of( + oldExpired, + oldRevoked, + freshExpired, + freshRevoked, + active + )); + List relevantHashes = List.of( + oldExpired.getTokenHash(), + oldRevoked.getTokenHash(), + freshExpired.getTokenHash(), + freshRevoked.getTokenHash(), + active.getTokenHash() + ); + + executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List> futures = List.of( + executor.submit(() -> cleanupAfterSignal(cutoff, ready, start)), + executor.submit(() -> cleanupAfterSignal(cutoff, ready, start)) + ); + awaitReady(ready); + start.countDown(); + + int deleted = futures.get(0).get(10, TimeUnit.SECONDS) + + futures.get(1).get(10, TimeUnit.SECONDS); + + assertThat(deleted).isEqualTo(2); + List remaining = tokenRepository.findAll().stream() + .filter(token -> relevantHashes.contains(token.getTokenHash())) + .toList(); + assertThat(remaining) + .extracting(AuthRefreshToken::getTokenHash) + .containsExactlyInAnyOrder( + freshExpired.getTokenHash(), + freshRevoked.getTokenHash(), + active.getTokenHash() + ); + assertThat(tokenRepository.deleteCleanupBatch(cutoff, 100)).isZero(); + } + + private int cleanupAfterSignal(Instant cutoff, + CountDownLatch ready, + CountDownLatch start) throws InterruptedException { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Не удалось синхронно запустить очистку refresh-сессий"); + } + return tokenRepository.deleteCleanupBatch(cutoff, 100); + } + + private AuthRefreshToken token(User user, + String marker, + Instant expiresAt, + Instant revokedAt) { + AuthRefreshToken token = new AuthRefreshToken(); + token.setUser(user); + token.setTenant(TENANT); + token.setTokenHash(service.hashToken(marker + "-" + UUID.randomUUID())); + token.setIssuedAt(Instant.now().minus(40, ChronoUnit.DAYS)); + token.setExpiresAt(expiresAt); + token.setRevokedAt(revokedAt); + return token; + } + private Optional rotateAfterSignal( String rawToken, CountDownLatch ready, 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 439b23a..9889d9d 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 @@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -35,7 +36,7 @@ class RefreshTokenServiceTest { assertThat(stored.getTokenHash()).isNotEqualTo(rawToken); assertThat(stored.getTokenHash()).hasSize(64); assertThat(stored.getTenant()).isEqualTo("magistr"); - assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now()); + assertThat(stored.getExpiresAt()).isAfter(Instant.now()); } @Test @@ -96,8 +97,8 @@ class RefreshTokenServiceTest { token.setUser(user()); token.setTenant("magistr"); token.setTokenHash(hash); - token.setIssuedAt(LocalDateTime.now().minusMinutes(1)); - token.setExpiresAt(LocalDateTime.now().plusDays(1)); + token.setIssuedAt(Instant.now().minus(1, ChronoUnit.MINUTES)); + token.setExpiresAt(Instant.now().plus(1, ChronoUnit.DAYS)); return token; } diff --git a/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java index 20e4841..b848a88 100644 --- a/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java @@ -2,20 +2,30 @@ package com.magistr.app.controller; import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.config.auth.ClientIpResolver; +import com.magistr.app.config.auth.LoginRateLimitService; +import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.dto.LoginRequest; +import com.magistr.app.dto.LoginResponse; import com.magistr.app.model.Role; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class AuthControllerTest { @AfterEach void clearAuthContext() { AuthContext.clear(); + TenantContext.clear(); } @Test @@ -35,4 +45,35 @@ class AuthControllerTest { assertThat(body).containsKey("departmentId"); assertThat(body.get("departmentId")).isNull(); } + + @Test + void loginReturnsTooManyRequestsAndRetryAfterWhenKeyIsBlocked() { + LoginRateLimitService rateLimitService = mock(LoginRateLimitService.class); + ClientIpResolver clientIpResolver = mock(ClientIpResolver.class); + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setUsername("admin"); + loginRequest.setPassword("неверный-пароль"); + TenantContext.setCurrentTenant("tenant"); + when(clientIpResolver.resolve(servletRequest)).thenReturn("198.51.100.7"); + when(rateLimitService.authenticate( + "tenant", "admin", "неверный-пароль", "198.51.100.7" + )).thenReturn(new LoginRateLimitService.AuthenticationDecision( + LoginRateLimitService.AuthenticationDecision.Status.BLOCKED, + null, + 73 + )); + AuthController controller = new AuthController( + rateLimitService, clientIpResolver, null, null, null + ); + + ResponseEntity response = controller.login(loginRequest, servletRequest); + + assertThat(response.getStatusCode().value()).isEqualTo(429); + assertThat(response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("73"); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isFalse(); + assertThat(response.getBody().getMessage()) + .isEqualTo("Слишком много попыток входа. Повторите позже"); + } } diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java index 2d41912..d37b63f 100644 --- a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java @@ -16,6 +16,8 @@ import com.magistr.app.repository.TeacherCreationRequestRepository; import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import com.magistr.app.service.ScheduleQueryService; +import com.magistr.app.service.SubjectImportService; +import com.magistr.app.service.TeacherDepartmentService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -51,7 +53,9 @@ class DepartmentWorkspaceControllerTest { mock(DepartmentRepository.class), mock(TeacherDepartmentAssignmentRepository.class), mock(TeacherCreationRequestRepository.class), - mock(ScheduleQueryService.class) + mock(ScheduleQueryService.class), + mock(TeacherDepartmentService.class), + mock(SubjectImportService.class) ); AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L)); @@ -61,14 +65,13 @@ class DepartmentWorkspaceControllerTest { } @Test - void teachersUseAssignmentsAndFallbackWithoutDuplicates() { + void teachersUseDatedAssignmentsWithoutLegacyFallback() { UserRepository userRepository = mock(UserRepository.class); TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class); + TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class); User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L); User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L); - when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now())) - .thenReturn(List.of(assignment(assignedTeacher, 1L))); - when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED)) + when(teacherDepartmentService.findTeachersForDepartmentAtDate(1L, LocalDate.now())) .thenReturn(List.of(directTeacher, assignedTeacher)); DepartmentWorkspaceController controller = new DepartmentWorkspaceController( mock(SubjectRepository.class), @@ -77,13 +80,15 @@ class DepartmentWorkspaceControllerTest { mock(DepartmentRepository.class), assignmentRepository, mock(TeacherCreationRequestRepository.class), - mock(ScheduleQueryService.class) + mock(ScheduleQueryService.class), + teacherDepartmentService, + mock(SubjectImportService.class) ); List teachers = controller.getTeachers(1L); assertThat(teachers).extracting(UserResponse::getFullName) - .containsExactly("Петров Препод Петрович", "Препод Тест Тестович"); + .containsExactlyInAnyOrder("Петров Препод Петрович", "Препод Тест Тестович"); } @Test @@ -103,7 +108,9 @@ class DepartmentWorkspaceControllerTest { departmentRepository, assignmentRepository, mock(TeacherCreationRequestRepository.class), - mock(ScheduleQueryService.class) + mock(ScheduleQueryService.class), + mock(TeacherDepartmentService.class), + mock(SubjectImportService.class) ); AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L)); diff --git a/backend/src/test/java/com/magistr/app/controller/EducationOfficeRoleMatrixTest.java b/backend/src/test/java/com/magistr/app/controller/EducationOfficeRoleMatrixTest.java new file mode 100644 index 0000000..9ebdd52 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/EducationOfficeRoleMatrixTest.java @@ -0,0 +1,119 @@ +package com.magistr.app.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.config.auth.AuthorizationInterceptor; +import com.magistr.app.config.auth.JwtTokenService; +import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.model.EducationForm; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; +import com.magistr.app.repository.AcademicCalendarRepository; +import com.magistr.app.repository.EducationFormRepository; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class EducationOfficeRoleMatrixTest { + + private static final String TENANT = "tenant-role-matrix"; + private static final String TOKEN = "education-office-token"; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + TenantContext.clear(); + TenantContext.setCurrentTenant(TENANT); + + JwtTokenService jwtTokenService = mock(JwtTokenService.class); + when(jwtTokenService.authenticate(TOKEN, TENANT)).thenReturn(Optional.of( + new AuthenticatedUser(10L, "учебный.отдел", Role.EDUCATION_OFFICE, null) + )); + + SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class); + SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class); + when(specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of()); + when(specialtiesRepository.existsById(1L)).thenReturn(true); + when(profileRepository.findBySpecialityIdOrderByNameAsc(1L)).thenReturn(List.of()); + SpecialityController specialityController = new SpecialityController( + specialtiesRepository, + profileRepository + ); + + EducationFormRepository educationFormRepository = mock(EducationFormRepository.class); + GroupRepository groupRepository = mock(GroupRepository.class); + AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class); + when(educationFormRepository.findAllByOrderByNameAsc()).thenReturn(List.of()); + when(educationFormRepository.findByName("Очная")).thenReturn(Optional.empty()); + when(educationFormRepository.save(any(EducationForm.class))).thenAnswer(invocation -> { + EducationForm form = invocation.getArgument(0); + form.setId(20L); + return form; + }); + when(educationFormRepository.existsById(20L)).thenReturn(true); + when(groupRepository.findByEducationFormId(20L)).thenReturn(List.of()); + when(calendarRepository.countByStudyFormId(20L)).thenReturn(0L); + EducationFormController educationFormController = new EducationFormController( + educationFormRepository, + groupRepository, + calendarRepository + ); + + mockMvc = MockMvcBuilders + .standaloneSetup(specialityController, educationFormController) + .addInterceptors(new AuthorizationInterceptor(jwtTokenService, new ObjectMapper())) + .build(); + } + + @AfterEach + void clearContext() { + TenantContext.clear(); + } + + @Test + void educationOfficeInitializesCalendarAndManagesEducationFormsWithoutForbidden() throws Exception { + mockMvc.perform(get("/api/specialties").header("Authorization", bearer())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/specialties/1/profiles").header("Authorization", bearer())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/education-forms").header("Authorization", bearer())) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/education-forms") + .header("Authorization", bearer()) + .contentType("application/json") + .content("{\"name\":\"Очная\"}")) + .andExpect(status().isOk()); + mockMvc.perform(delete("/api/education-forms/20").header("Authorization", bearer())) + .andExpect(status().isOk()); + } + + @Test + void educationOfficeStillCannotModifySpecialties() throws Exception { + mockMvc.perform(post("/api/specialties") + .header("Authorization", bearer()) + .contentType("application/json") + .content("{\"specialityName\":\"Тест\",\"specialityCode\":\"01\"}")) + .andExpect(status().isForbidden()); + } + + private String bearer() { + return "Bearer " + TOKEN; + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/GlobalExceptionHandlerDataIntegrityTest.java b/backend/src/test/java/com/magistr/app/controller/GlobalExceptionHandlerDataIntegrityTest.java new file mode 100644 index 0000000..addb17d --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/GlobalExceptionHandlerDataIntegrityTest.java @@ -0,0 +1,111 @@ +package com.magistr.app.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.sql.SQLException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Безопасное преобразование нарушений целостности БД") +class GlobalExceptionHandlerDataIntegrityTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + @DisplayName("известный CHECK возвращает русский 400 без JDBC details") + void knownCheckConstraintReturnsSafeBadRequest() { + String technicalMessage = "ERROR: new row violates check constraint " + + "\"chk_time_slots_time_range\" Detail: failing row contains secret-value"; + + var response = handler.handleDataIntegrityViolation( + violation(technicalMessage, "23514"), + request("POST", "/api/admin/time-slots") + ); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()) + .containsEntry("error", "Некорректный запрос") + .containsEntry("message", "Время начала пары должно быть раньше времени окончания"); + assertNoTechnicalDetails(response.getBody()); + } + + @Test + @DisplayName("известный UNIQUE возвращает русский 409 без имени ограничения") + void knownUniqueConstraintReturnsSafeConflict() { + String technicalMessage = "duplicate key value violates unique constraint " + + "\"uq_subjects_name_ci\" Detail: Key (lower(name))=(secret-value) already exists"; + + var response = handler.handleDataIntegrityViolation( + violation(technicalMessage, "23505"), + request("POST", "/api/subjects") + ); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()) + .containsEntry("error", "Конфликт") + .containsEntry("message", "Дисциплина с таким названием уже существует"); + assertNoTechnicalDetails(response.getBody()); + } + + @Test + @DisplayName("неизвестный constraint получает общий 409 без сырого исключения") + void unknownConstraintReturnsGenericSafeConflict() { + String technicalMessage = "duplicate key violates constraint \"tenant_private_constraint\" " + + "jdbc:postgresql://db/private?password=secret-value"; + + var response = handler.handleDataIntegrityViolation( + violation(technicalMessage, "23505"), + request("PUT", "/api/private") + ); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()) + .containsEntry("error", "Конфликт") + .containsEntry("message", "Такая запись уже существует"); + assertNoTechnicalDetails(response.getBody()); + } + + @Test + @DisplayName("неизвестная integrity-ошибка не превращается в 500 и не раскрывает cause") + void unknownIntegrityFailureReturnsGenericConflict() { + var exception = new DataIntegrityViolationException( + "could not execute statement; jdbc:postgresql://db/private", + new RuntimeException("password=secret-value") + ); + + var response = handler.handleDataIntegrityViolation( + exception, + request("DELETE", "/api/private/1") + ); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()) + .containsEntry("message", "Операция нарушает ограничения целостности данных"); + assertNoTechnicalDetails(response.getBody()); + } + + private DataIntegrityViolationException violation(String message, String sqlState) { + return new DataIntegrityViolationException(message, new SQLException(message, sqlState)); + } + + private MockHttpServletRequest request(String method, String path) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setRequestURI(path); + return request; + } + + private void assertNoTechnicalDetails(Map body) { + assertThat(body).isNotNull(); + String rendered = body.toString().toLowerCase(); + assertThat(rendered) + .doesNotContain("constraint") + .doesNotContain("jdbc:") + .doesNotContain("password") + .doesNotContain("secret-value") + .doesNotContain("sqlstate"); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java b/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java new file mode 100644 index 0000000..837cece --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java @@ -0,0 +1,78 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.model.Role; +import com.magistr.app.model.Subject; +import com.magistr.app.model.TeacherSubject; +import com.magistr.app.model.User; +import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.TeacherSubjectRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.TeacherDepartmentService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeacherSubjectControllerTest { + + @AfterEach + void clearAuthContext() { + AuthContext.clear(); + } + + @Test + void departmentCanManageTeacherThroughAdditionalActiveAssignment() { + TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class); + UserRepository userRepository = mock(UserRepository.class); + SubjectRepository subjectRepository = mock(SubjectRepository.class); + TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class); + User teacher = teacher(); + Subject subject = new Subject(); + subject.setId(20L); + subject.setName("Высшая математика"); + subject.setDepartmentId(1L); + when(userRepository.findById(teacher.getId())).thenReturn(Optional.of(teacher)); + when(subjectRepository.findById(subject.getId())).thenReturn(Optional.of(subject)); + when(teacherDepartmentService.hasAssignmentAtDate( + teacher.getId(), + 1L, + LocalDate.now() + )).thenReturn(true); + TeacherSubjectController controller = new TeacherSubjectController( + teacherSubjectRepository, + userRepository, + subjectRepository, + teacherDepartmentService + ); + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 1L)); + + var response = controller.create(Map.of("userId", teacher.getId(), "subjectId", subject.getId())); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + ArgumentCaptor captor = ArgumentCaptor.forClass(TeacherSubject.class); + verify(teacherSubjectRepository).save(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(teacher.getId()); + assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId()); + } + + private User teacher() { + User teacher = new User(); + teacher.setId(10L); + teacher.setUsername("teacher"); + teacher.setRole(Role.TEACHER); + teacher.setFullName("Петров Пётр Петрович"); + teacher.setJobTitle("Доцент"); + teacher.setDepartmentId(2L); + return teacher; + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java index 5c39d3d..f063d21 100644 --- a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java @@ -8,6 +8,7 @@ import com.magistr.app.model.User; import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.TeacherDepartmentService; import org.junit.jupiter.api.Test; import java.util.List; @@ -24,16 +25,19 @@ class UserControllerTest { UserRepository userRepository = mock(UserRepository.class); DepartmentRepository departmentRepository = mock(DepartmentRepository.class); TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class); + TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class); UserController controller = new UserController( userRepository, null, departmentRepository, - assignmentRepository + assignmentRepository, + teacherDepartmentService ); User teacher = user(10L, "teacher", Role.TEACHER, 2L); - when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any())) - .thenReturn(List.of()); - when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED)) + when(teacherDepartmentService.findTeachersForDepartmentAtDate( + org.mockito.ArgumentMatchers.eq(2L), + org.mockito.ArgumentMatchers.any() + )) .thenReturn(List.of(teacher)); when(departmentRepository.findById(2L)) .thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L))); diff --git a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java index 502f7ef..628ffb8 100644 --- a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java @@ -1,24 +1,87 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.dto.WorkloadSummaryDto; import com.magistr.app.model.Classroom; +import com.magistr.app.model.Department; import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; +import com.magistr.app.model.TeacherDepartmentAssignment; +import com.magistr.app.model.User; import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.DepartmentRepository; -import com.magistr.app.repository.UserRepository; import com.magistr.app.service.ScheduleQueryService; +import com.magistr.app.service.TeacherDepartmentService; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class WorkloadControllerTest { + @AfterEach + void clearAuthContext() { + AuthContext.clear(); + } + + @Test + void teacherWorkloadUsesDepartmentAtEachLessonDate() { + ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); + TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class); + DepartmentRepository departmentRepository = mock(DepartmentRepository.class); + LocalDate startDate = LocalDate.of(2026, 1, 1); + LocalDate endDate = LocalDate.of(2026, 3, 31); + RenderedLessonDto oldLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 1, 15), 1L); + RenderedLessonDto newLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 3, 15), 2L); + List assignments = List.of( + assignment(10L, 1L, LocalDate.of(2025, 9, 1), LocalDate.of(2026, 2, 28)), + assignment(10L, 2L, LocalDate.of(2026, 3, 1), null) + ); + when(scheduleQueryService.searchForAggregation(any(), any(), eq(startDate), eq(endDate))) + .thenReturn(List.of(oldLesson, newLesson)); + when(teacherDepartmentService.findAssignmentsForTeachersBetween( + eq(java.util.Set.of(10L)), + eq(startDate), + eq(endDate) + )).thenReturn(Map.of(10L, assignments)); + when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, oldLesson.date())) + .thenReturn(Optional.of(1L)); + when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, newLesson.date())) + .thenReturn(Optional.of(2L)); + when(departmentRepository.findById(1L)) + .thenReturn(Optional.of(new Department(1L, "Кафедра ИБ", 1L))); + when(departmentRepository.findById(2L)) + .thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L))); + WorkloadController controller = new WorkloadController( + scheduleQueryService, + mock(ClassroomRepository.class), + departmentRepository, + teacherDepartmentService + ); + authenticate(Role.ADMIN, null); + + List result = controller.teacherWorkload(startDate, endDate, null); + + assertThat(result).hasSize(2); + assertThat(result).extracting(WorkloadSummaryDto::departmentId) + .containsExactly(1L, 2L); + assertThat(result).extracting(WorkloadSummaryDto::lessonCount) + .containsOnly(1L); + } + @Test void freeClassroomsSkipsNullAvailabilityWithoutNpe() { ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); @@ -26,19 +89,97 @@ class WorkloadControllerTest { WorkloadController controller = new WorkloadController( scheduleQueryService, classroomRepository, - mock(UserRepository.class), - mock(DepartmentRepository.class) + mock(DepartmentRepository.class), + mock(TeacherDepartmentService.class) ); - when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any())) + when(scheduleQueryService.searchForAggregation(any(), eq(1L), any(), any())) .thenReturn(List.of()); when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)) .thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true))); + authenticate(Role.SCHEDULE_VIEWER, null); var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L); assertThat(result).extracting("id").containsExactly(3L); } + @Test + void departmentRoleUsesOwnDepartmentForEveryWorkloadEndpoint() { + ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); + ClassroomRepository classroomRepository = mock(ClassroomRepository.class); + DepartmentRepository departmentRepository = mock(DepartmentRepository.class); + TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class); + WorkloadController controller = new WorkloadController( + scheduleQueryService, + classroomRepository, + departmentRepository, + teacherDepartmentService + ); + LocalDate startDate = LocalDate.of(2026, 5, 1); + LocalDate endDate = LocalDate.of(2026, 5, 31); + LocalDate date = LocalDate.of(2026, 5, 15); + when(scheduleQueryService.searchForAggregation(any(), any(), any(), any())) + .thenReturn(List.of()); + when(departmentRepository.findAll()).thenReturn(List.of()); + when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of()); + authenticate(Role.DEPARTMENT, 7L); + + controller.teacherWorkload(startDate, endDate, null); + controller.classroomWorkload(startDate, endDate, 7L); + controller.departmentWorkload(startDate, endDate, null); + controller.timeSlotWorkload(startDate, endDate, 7L); + controller.freeClassrooms(date, 3L); + + verify(scheduleQueryService, times(4)) + .searchForAggregation(eq(7L), isNull(), eq(startDate), eq(endDate)); + verify(scheduleQueryService) + .searchForAggregation(eq(7L), eq(3L), eq(date), eq(date)); + } + + @Test + void departmentRoleCannotRequestAnotherDepartment() { + ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); + WorkloadController controller = new WorkloadController( + scheduleQueryService, + mock(ClassroomRepository.class), + mock(DepartmentRepository.class), + mock(TeacherDepartmentService.class) + ); + authenticate(Role.DEPARTMENT, 7L); + + assertThatThrownBy(() -> controller.teacherWorkload( + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + 8L + )) + .isInstanceOfSatisfying(ResponseStatusException.class, exception -> { + assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(exception.getReason()).isEqualTo("Нельзя просматривать нагрузку другой кафедры"); + }); + verifyNoInteractions(scheduleQueryService); + } + + @Test + void privilegedRoleKeepsGlobalScope() { + ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); + WorkloadController controller = new WorkloadController( + scheduleQueryService, + mock(ClassroomRepository.class), + mock(DepartmentRepository.class), + mock(TeacherDepartmentService.class) + ); + LocalDate startDate = LocalDate.of(2026, 5, 1); + LocalDate endDate = LocalDate.of(2026, 5, 31); + when(scheduleQueryService.searchForAggregation(any(), any(), any(), any())) + .thenReturn(List.of()); + authenticate(Role.EDUCATION_OFFICE, null); + + controller.timeSlotWorkload(startDate, endDate, null); + + verify(scheduleQueryService) + .searchForAggregation(isNull(), isNull(), eq(startDate), eq(endDate)); + } + private Classroom classroom(Long id, Boolean available) { Classroom classroom = new Classroom(); classroom.setId(id); @@ -47,4 +188,36 @@ class WorkloadControllerTest { classroom.setIsAvailable(available); return classroom; } + + private RenderedLessonDto lesson(Long teacherId, + String teacherName, + LocalDate date, + Long timeSlotId) { + RenderedLessonDto lesson = mock(RenderedLessonDto.class); + when(lesson.teacherId()).thenReturn(teacherId); + when(lesson.teacherName()).thenReturn(teacherName); + when(lesson.date()).thenReturn(date); + when(lesson.timeSlotId()).thenReturn(timeSlotId); + return lesson; + } + + private TeacherDepartmentAssignment assignment(Long teacherId, + Long departmentId, + LocalDate validFrom, + LocalDate validTo) { + User teacher = new User(); + teacher.setId(teacherId); + Department department = new Department(departmentId, "Кафедра " + departmentId, departmentId); + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(teacher); + assignment.setDepartment(department); + assignment.setValidFrom(validFrom); + assignment.setValidTo(validTo); + assignment.setPrimaryAssignment(true); + return assignment; + } + + private void authenticate(Role role, Long departmentId) { + AuthContext.setCurrentUser(new AuthenticatedUser(100L, "test", role, departmentId)); + } } diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java new file mode 100644 index 0000000..c4fd3a1 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java @@ -0,0 +1,292 @@ +package com.magistr.app.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Testcontainers +@DisplayName("Инварианты учебных периодов базовой схемы") +class AcademicPeriodInvariantMigrationIntegrationTest { + + private static final String YEAR_OVERLAP_CONSTRAINT = "ex_academic_years_no_overlap"; + private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap"; + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + + @Test + @DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год") + void migrationEnforcesAcademicPeriodInvariants() throws SQLException { + Flyway flyway = flyway("1"); + flyway.clean(); + flyway.migrate(); + + assertThat(flyway.info().current()).isNotNull(); + assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1"); + + try (Connection connection = POSTGRES.createConnection("")) { + long academicYearId = insertAcademicYear( + connection, + "2026-2027", + LocalDate.of(2026, 7, 1), + LocalDate.of(2027, 6, 30) + ); + long autumnId = insertSemester( + connection, + academicYearId, + "autumn", + LocalDate.of(2026, 7, 1), + LocalDate.of(2027, 1, 31) + ); + + assertSqlState( + () -> insertAcademicYear( + connection, + "2027-overlap", + LocalDate.of(2027, 6, 30), + LocalDate.of(2028, 6, 30) + ), + "23P01", + YEAR_OVERLAP_CONSTRAINT + ); + assertSqlState( + () -> insertSemester( + connection, + academicYearId, + "spring", + LocalDate.of(2026, 6, 30), + LocalDate.of(2027, 6, 30) + ), + "23514", + "Семестр должен полностью находиться в границах учебного года" + ); + + long springId = insertSemester( + connection, + academicYearId, + "spring", + LocalDate.of(2027, 2, 1), + LocalDate.of(2027, 6, 30) + ); + assertSqlState( + () -> updateDate( + connection, + "UPDATE semesters SET start_date = ? WHERE id = ?", + LocalDate.of(2027, 1, 31), + springId + ), + "23P01", + SEMESTER_OVERLAP_CONSTRAINT + ); + assertSqlState( + () -> updateDate( + connection, + "UPDATE academic_years SET start_date = ? WHERE id = ?", + LocalDate.of(2026, 7, 2), + academicYearId + ), + "23514", + "Новые границы учебного года не включают все его семестры" + ); + + assertThat(autumnId).isPositive(); + assertThat(constraintCount(connection, "academic_years", YEAR_OVERLAP_CONSTRAINT)).isOne(); + assertThat(constraintCount(connection, "semesters", SEMESTER_OVERLAP_CONSTRAINT)).isOne(); + } + } + + @Test + @DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся учебных годов") + void migrationProtectsConcurrentAcademicYearWrites() throws Exception { + Flyway flyway = flyway("1"); + flyway.clean(); + flyway.migrate(); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = List.of( + executor.submit(() -> concurrentYearInsert( + "2090-2091-a", + LocalDate.of(2090, 9, 1), + LocalDate.of(2091, 6, 30), + ready, + start + )), + executor.submit(() -> concurrentYearInsert( + "2091-2092-b", + LocalDate.of(2091, 6, 30), + LocalDate.of(2092, 6, 30), + ready, + start + )) + ); + + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + List outcomes = List.of( + futures.get(0).get(10, TimeUnit.SECONDS), + futures.get(1).get(10, TimeUnit.SECONDS) + ); + + assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1); + assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement() + .satisfies(outcome -> { + assertThat(outcome.sqlState()).isIn("23P01", "40P01"); + if ("23P01".equals(outcome.sqlState())) { + assertThat(outcome.message()).contains(YEAR_OVERLAP_CONSTRAINT); + } + }); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private InsertOutcome concurrentYearInsert(String title, + LocalDate startDate, + LocalDate endDate, + CountDownLatch ready, + CountDownLatch start) { + try (Connection connection = POSTGRES.createConnection("")) { + connection.setAutoCommit(false); + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + return new InsertOutcome(false, null, "Не получен общий сигнал запуска"); + } + try { + insertAcademicYear(connection, title, startDate, endDate); + connection.commit(); + return new InsertOutcome(true, null, null); + } catch (SQLException exception) { + connection.rollback(); + return new InsertOutcome(false, exception.getSQLState(), exception.getMessage()); + } + } catch (Exception exception) { + return new InsertOutcome(false, null, exception.getMessage()); + } + } + + private Flyway flyway(String targetVersion) { + return Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion(targetVersion)) + .cleanDisabled(false) + .load(); + } + + private long insertAcademicYear(Connection connection, + String title, + LocalDate startDate, + LocalDate endDate) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO academic_years (title, start_date, end_date) + VALUES (?, ?, ?) + RETURNING id + """)) { + statement.setString(1, title); + statement.setDate(2, Date.valueOf(startDate)); + statement.setDate(3, Date.valueOf(endDate)); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private long insertSemester(Connection connection, + long academicYearId, + String semesterType, + LocalDate startDate, + LocalDate endDate) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO semesters ( + academic_year_id, + semester_type, + start_date, + end_date + ) VALUES (?, ?, ?, ?) + RETURNING id + """)) { + statement.setLong(1, academicYearId); + statement.setString(2, semesterType); + statement.setDate(3, Date.valueOf(startDate)); + statement.setDate(4, Date.valueOf(endDate)); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void updateDate(Connection connection, String sql, LocalDate value, long id) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setDate(1, Date.valueOf(value)); + statement.setLong(2, id); + statement.executeUpdate(); + } + } + + private long constraintCount(Connection connection, + String tableName, + String constraintName) throws SQLException { + return queryLong( + connection, + "SELECT count(*) FROM pg_constraint WHERE conrelid = (?::TEXT)::regclass AND conname = ?", + tableName, + constraintName + ); + } + + private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void assertSqlState(ThrowingSqlRunnable operation, + String expectedSqlState, + String expectedMessagePart) { + Throwable failure = catchThrowable(operation::run); + assertThat(failure).isInstanceOf(SQLException.class); + SQLException sqlFailure = (SQLException) failure; + assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState); + assertThat(sqlFailure.getMessage()).contains(expectedMessagePart); + } + + @FunctionalInterface + private interface ThrowingSqlRunnable { + void run() throws SQLException; + } + + private record InsertOutcome(boolean success, String sqlState, String message) { + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java new file mode 100644 index 0000000..bc07564 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java @@ -0,0 +1,393 @@ +package com.magistr.app.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Testcontainers +@DisplayName("Инварианты зависимостей календарных графиков базовой схемы") +class AcademicStructureInvariantIntegrationTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + + @Test + @DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы") + void baselineProtectsCalendarDependencies() throws SQLException { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + assertThat(flyway.info().current()).isNotNull(); + assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1"); + + try (Connection connection = POSTGRES.createConnection("")) { + long groupId = queryLong( + connection, + "SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'" + ); + long assignmentId = queryLong( + connection, + "SELECT id FROM student_group_calendar_assignments WHERE group_id = ?", + groupId + ); + long calendarId = queryLong( + connection, + "SELECT calendar_id FROM student_group_calendar_assignments WHERE id = ?", + assignmentId + ); + long otherFormId = queryLong( + connection, + "SELECT id FROM education_forms WHERE name = 'Специалитет'" + ); + + assertThat(incompatibleAssignmentCount(connection)).isZero(); + assertSqlState( + () -> updateLong( + connection, + "UPDATE academic_calendars SET study_form_id = ? WHERE id = ?", + otherFormId, + calendarId + ), + "23514", + "сохранённые назначения групп станут несовместимыми" + ); + assertSqlState( + () -> updateLong( + connection, + "UPDATE student_groups SET education_form_id = ? WHERE id = ?", + otherFormId, + groupId + ), + "23514", + "сохранённые назначения календарных графиков станут несовместимыми" + ); + assertSqlState( + () -> updateInt( + connection, + "UPDATE academic_calendars SET course_count = ? WHERE id = ?", + 3, + calendarId + ), + "23514", + "сохранённая сетка выходит за новые измерения" + ); + assertSqlState( + () -> insertOutOfRangeCalendarDay(connection, calendarId), + "23514", + "Строка сетки выходит за измерения календарного графика" + ); + assertSqlState( + () -> insertOutOfRangeCalendarSubject(connection, calendarId), + "23514", + "Номер семестра дисциплины выходит за измерения календарного графика" + ); + + assertThat(queryLong( + connection, + "SELECT count(*) FROM student_group_calendar_assignments WHERE id = ?", + assignmentId + )).isOne(); + assertThat(queryLong( + connection, + "SELECT course_count FROM academic_calendars WHERE id = ?", + calendarId + )).isEqualTo(4); + assertThat(incompatibleAssignmentCount(connection)).isZero(); + } + } + + @Test + @DisplayName("V1 не допускает гонку между назначением графика и изменением параметров группы") + void baselineProtectsConcurrentGroupAndAssignmentWrites() throws Exception { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + long groupId; + long calendarId; + long academicYearId; + long otherFormId; + try (Connection connection = POSTGRES.createConnection("")) { + groupId = insertCompatibleGroup(connection); + calendarId = queryLong(connection, """ + SELECT calendar.id + FROM academic_calendars calendar + JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id + JOIN specialties specialty ON specialty.id = calendar.specialty_id + JOIN education_forms study_form ON study_form.id = calendar.study_form_id + WHERE academic_year.title = '2025-2026' + AND specialty.specialty_code = '09.03.01' + AND study_form.name = 'Бакалавриат' + ORDER BY calendar.id + LIMIT 1 + """); + academicYearId = queryLong( + connection, + "SELECT id FROM academic_years WHERE title = '2025-2026'" + ); + otherFormId = queryLong( + connection, + "SELECT id FROM education_forms WHERE name = 'Специалитет'" + ); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + long finalGroupId = groupId; + long finalCalendarId = calendarId; + long finalAcademicYearId = academicYearId; + long finalOtherFormId = otherFormId; + List> futures = List.of( + executor.submit(() -> concurrentGroupUpdate( + finalGroupId, + finalOtherFormId, + ready, + start + )), + executor.submit(() -> concurrentAssignmentInsert( + finalGroupId, + finalAcademicYearId, + finalCalendarId, + ready, + start + )) + ); + + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + List outcomes = List.of( + futures.get(0).get(10, TimeUnit.SECONDS), + futures.get(1).get(10, TimeUnit.SECONDS) + ); + + assertThat(outcomes).filteredOn(WriteOutcome::success).hasSize(1); + assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement() + .satisfies(outcome -> { + assertThat(outcome.sqlState()).isEqualTo("23514"); + assertThat(outcome.message()).containsAnyOf( + "Календарный график не соответствует", + "сохранённые назначения календарных графиков станут несовместимыми" + ); + }); + + try (Connection connection = POSTGRES.createConnection("")) { + assertThat(incompatibleAssignmentCount(connection)).isZero(); + assertThat(queryLong( + connection, + "SELECT count(*) FROM student_group_calendar_assignments WHERE group_id = ?", + groupId + )).isLessThanOrEqualTo(1); + } + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private WriteOutcome concurrentGroupUpdate(long groupId, + long educationFormId, + CountDownLatch ready, + CountDownLatch start) { + return concurrentWrite( + "UPDATE student_groups SET education_form_id = ? WHERE id = ?", + List.of(educationFormId, groupId), + ready, + start + ); + } + + private WriteOutcome concurrentAssignmentInsert(long groupId, + long academicYearId, + long calendarId, + CountDownLatch ready, + CountDownLatch start) { + return concurrentWrite( + """ + INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id) + VALUES (?, ?, ?) + """, + List.of(groupId, academicYearId, calendarId), + ready, + start + ); + } + + private WriteOutcome concurrentWrite(String sql, + List parameters, + CountDownLatch ready, + CountDownLatch start) { + try (Connection connection = POSTGRES.createConnection("")) { + connection.setAutoCommit(false); + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + return new WriteOutcome(false, null, "Не получен общий сигнал запуска"); + } + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.size(); index++) { + statement.setLong(index + 1, parameters.get(index)); + } + statement.executeUpdate(); + connection.commit(); + return new WriteOutcome(true, null, null); + } catch (SQLException exception) { + connection.rollback(); + return new WriteOutcome(false, exception.getSQLState(), exception.getMessage()); + } + } catch (Exception exception) { + return new WriteOutcome(false, null, exception.getMessage()); + } + } + + private Flyway baselineFlyway() { + return Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("1")) + .cleanDisabled(false) + .load(); + } + + private long insertCompatibleGroup(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO student_groups ( + name, + group_size, + education_form_id, + department_id, + specialty_id, + specialty_profile_id, + year_start_study + ) + SELECT + 'КОНКУРЕНТНАЯ-ГРУППА', + 20, + study_form.id, + department.id, + specialty.id, + profile.id, + 2025 + FROM education_forms study_form + CROSS JOIN departments department + CROSS JOIN specialties specialty + JOIN specialty_profiles profile ON profile.specialty_id = specialty.id + WHERE study_form.name = 'Бакалавриат' + AND department.code = 1 + AND specialty.specialty_code = '09.03.01' + AND profile.name = 'Без профиля' + RETURNING id + """)) { + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO academic_calendar_days ( + calendar_id, + course_number, + date, + week_number, + day_of_week, + activity_type_id + ) VALUES (?, 5, DATE '2025-09-01', 1, 1, ( + SELECT id FROM academic_calendar_activity_types WHERE code = 'Т' + )) + """)) { + statement.setLong(1, calendarId); + statement.executeUpdate(); + } + } + + private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id) + VALUES (?, 9, (SELECT min(id) FROM subjects)) + """)) { + statement.setLong(1, calendarId); + statement.executeUpdate(); + } + } + + private long incompatibleAssignmentCount(Connection connection) throws SQLException { + return queryLong(connection, """ + SELECT count(*) + FROM student_group_calendar_assignments assignment + JOIN student_groups student_group ON student_group.id = assignment.group_id + JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id + WHERE assignment.academic_year_id <> calendar.academic_year_id + OR student_group.specialty_id <> calendar.specialty_id + OR student_group.specialty_profile_id <> calendar.specialty_profile_id + OR student_group.education_form_id <> calendar.study_form_id + """); + } + + private void updateLong(Connection connection, String sql, long value, long id) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, value); + statement.setLong(2, id); + statement.executeUpdate(); + } + } + + private void updateInt(Connection connection, String sql, int value, long id) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, value); + statement.setLong(2, id); + statement.executeUpdate(); + } + } + + private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void assertSqlState(ThrowingSqlRunnable operation, + String expectedSqlState, + String expectedMessagePart) { + Throwable failure = catchThrowable(operation::run); + assertThat(failure).isInstanceOf(SQLException.class); + SQLException sqlFailure = (SQLException) failure; + assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState); + assertThat(sqlFailure.getMessage()).contains(expectedMessagePart); + } + + @FunctionalInterface + private interface ThrowingSqlRunnable { + void run() throws SQLException; + } + + private record WriteOutcome(boolean success, String sqlState, String message) { + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java index 05e5f89..32e5456 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java @@ -14,31 +14,21 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @Testcontainers -@DisplayName("Миграция инвариантов точечных изменений расписания") +@DisplayName("Инварианты точечных изменений расписания базовой схемы") class ScheduleOverrideMigrationIntegrationTest { - private static final String[] OVERRIDE_CONSTRAINTS = { - "chk_schedule_overrides_cancel_payload", - "chk_schedule_overrides_move_payload", - "chk_schedule_overrides_replace_payload", - "chk_schedule_overrides_format" - }; - @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); @Test - @DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные") + @DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные") void migrationEnforcesScheduleOverrideInvariants() throws SQLException { - Flyway flyway = targetThreeFlyway(); + Flyway flyway = baselineFlyway(); flyway.clean(); flyway.migrate(); @@ -46,8 +36,8 @@ class ScheduleOverrideMigrationIntegrationTest { .as("После полной миграции должна существовать текущая версия") .isNotNull(); assertThat(flyway.info().current().getVersion().getVersion()) - .as("Последней успешно применённой миграцией должна быть V3") - .isEqualTo("3"); + .as("Последней успешно применённой миграцией должна быть V1") + .isEqualTo("1"); try (Connection connection = POSTGRES.createConnection("")) { SeedIds seedIds = loadSeedIds(connection); @@ -106,81 +96,11 @@ class ScheduleOverrideMigrationIntegrationTest { } } - @Test - @DisplayName("V3 останавливается на legacy-данных до добавления ограничений") - void migrationFailsPreflightWithoutPartialConstraints() throws SQLException { - Flyway targetTwo = targetTwoFlyway(); - targetTwo.clean(); - targetTwo.migrate(); - - try (Connection connection = POSTGRES.createConnection("")) { - SeedIds seedIds = loadSeedIds(connection); - insertOverride( - connection, - seedIds, - LocalDate.of(2026, 5, 4), - "REPLACE", - null, - null, - null - ); - } - - Flyway targetThree = targetThreeFlyway(); - Throwable migrationFailure = catchThrowable(targetThree::migrate); - - assertThat(migrationFailure) - .as("V3 должна остановить миграцию при пустом legacy-изменении REPLACE") - .isNotNull(); - assertThat(collectMessages(migrationFailure)) - .as("Ошибка preflight должна объяснять проблему на русском языке") - .contains("Невозможно применить инварианты точечных изменений") - .contains("REPLACE=1") - .contains("Исправьте данные tenant-БД и повторите миграцию"); - - assertThat(targetThree.info().current()) - .as("После отката V3 должна остаться текущая успешная версия") - .isNotNull(); - assertThat(targetThree.info().current().getVersion().getVersion()) - .as("Последней успешно применённой миграцией должна остаться V2") - .isEqualTo("2"); - - try (Connection connection = POSTGRES.createConnection("")) { - assertThat(queryLong( - connection, - "SELECT count(*) FROM flyway_schema_history WHERE success AND version = '3'" - )) - .as("V3 не должна отмечаться как успешно применённая") - .isZero(); - assertThat(queryLong( - connection, - """ - SELECT count(*) - FROM pg_constraint - WHERE conrelid = 'schedule_overrides'::regclass - AND conname::text = ANY (?) - """, - connection.createArrayOf("text", OVERRIDE_CONSTRAINTS) - )) - .as("Ни одно ограничение V3 не должно остаться после неуспешного preflight") - .isZero(); - } - } - - private Flyway targetThreeFlyway() { + private Flyway baselineFlyway() { return Flyway.configure() .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .locations("classpath:db/migration") - .target(MigrationVersion.fromVersion("3")) - .cleanDisabled(false) - .load(); - } - - private Flyway targetTwoFlyway() { - return Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration") - .target(MigrationVersion.fromVersion("2")) + .target(MigrationVersion.fromVersion("1")) .cleanDisabled(false) .load(); } @@ -276,19 +196,6 @@ class ScheduleOverrideMigrationIntegrationTest { } } - private String collectMessages(Throwable failure) { - StringBuilder messages = new StringBuilder(); - Set 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 index 2ac8f1e..b81a1ee 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java @@ -12,15 +12,12 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @Testcontainers -@DisplayName("Миграция чётности академических часов правил расписания") +@DisplayName("Инварианты правил расписания базовой схемы") class ScheduleRuleHoursMigrationIntegrationTest { private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even"; @@ -30,9 +27,9 @@ class ScheduleRuleHoursMigrationIntegrationTest { static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); @Test - @DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий") + @DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий") void migrationEnforcesEvenAcademicHours() throws SQLException { - Flyway flyway = targetFourFlyway(); + Flyway flyway = baselineFlyway(); flyway.clean(); flyway.migrate(); @@ -40,8 +37,8 @@ class ScheduleRuleHoursMigrationIntegrationTest { .as("После полной миграции должна существовать текущая версия") .isNotNull(); assertThat(flyway.info().current().getVersion().getVersion()) - .as("Последней успешно применённой миграцией должна быть V4") - .isEqualTo("4"); + .as("Последней успешно применённой миграцией должна быть V1") + .isEqualTo("1"); try (Connection connection = POSTGRES.createConnection("")) { SeedIds seedIds = loadSeedIds(connection); @@ -63,7 +60,7 @@ class ScheduleRuleHoursMigrationIntegrationTest { """, HOURS_CONSTRAINT )) - .as("Ограничение чётности V4 должно быть провалидировано") + .as("Ограничение чётности V1 должно быть провалидировано") .isOne(); assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций"); @@ -73,113 +70,11 @@ class ScheduleRuleHoursMigrationIntegrationTest { } } - @Test - @DisplayName("V4 останавливается на legacy-правиле с нечётными часами до добавления ограничения") - void migrationFailsPreflightWithoutPartialConstraint() throws SQLException { - Flyway targetThree = targetThreeFlyway(); - targetThree.clean(); - targetThree.migrate(); - - try (Connection connection = POSTGRES.createConnection("")) { - SeedIds seedIds = loadSeedIds(connection); - insertScheduleRule(connection, seedIds, 1, 3, 5); - } - - Flyway targetFour = targetFourFlyway(); - Throwable migrationFailure = catchThrowable(targetFour::migrate); - - assertThat(migrationFailure) - .as("V4 должна остановить миграцию при нечётных часах legacy-правила") - .isNotNull(); - assertThat(collectMessages(migrationFailure)) - .as("Ошибка preflight должна объяснять проблему на русском языке") - .contains("Невозможно применить инвариант чётности академических часов правил расписания") - .contains("лекции=1") - .contains("лабораторные=1") - .contains("практики=1") - .contains("Исправьте данные tenant-БД и повторите миграцию"); - - assertThat(targetFour.info().current()) - .as("После отката V4 должна остаться текущая успешная версия") - .isNotNull(); - assertThat(targetFour.info().current().getVersion().getVersion()) - .as("Последней успешно применённой миграцией должна остаться V3") - .isEqualTo("3"); - - try (Connection connection = POSTGRES.createConnection("")) { - assertThat(queryLong( - connection, - "SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'" - )) - .as("V4 не должна отмечаться как успешно применённая") - .isZero(); - assertThat(queryLong( - connection, - """ - SELECT count(*) - FROM pg_constraint - WHERE conrelid = 'schedule_rules'::regclass - AND conname = ? - """, - HOURS_CONSTRAINT - )) - .as("Ограничение V4 не должно частично остаться после неуспешного preflight") - .isZero(); - } - } - - @Test - @DisplayName("V4 останавливается на legacy-дубле слота до добавления ограничений") - void migrationFailsOnLegacyExactSlotDuplicate() throws SQLException { - Flyway targetThree = targetThreeFlyway(); - targetThree.clean(); - targetThree.migrate(); - - try (Connection connection = POSTGRES.createConnection("")) { - duplicateExistingSlot(connection); - } - - Flyway targetFour = targetFourFlyway(); - Throwable migrationFailure = catchThrowable(targetFour::migrate); - - assertThat(migrationFailure) - .as("V4 должна остановить миграцию при точном legacy-дубле слота") - .isNotNull(); - assertThat(collectMessages(migrationFailure)) - .as("Ошибка preflight должна объяснять проблему уникальности на русском языке") - .contains("Невозможно применить инвариант уникальности слотов правил расписания") - .contains("групп точных дублей=1") - .contains("Исправьте данные tenant-БД и повторите миграцию"); - - assertThat(targetFour.info().current()) - .as("После отката V4 должна остаться текущая успешная версия") - .isNotNull(); - assertThat(targetFour.info().current().getVersion().getVersion()).isEqualTo("3"); - - try (Connection connection = POSTGRES.createConnection("")) { - assertThat(queryLong( - connection, - "SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'" - )).isZero(); - assertConstraintAbsent(connection, HOURS_CONSTRAINT); - assertConstraintAbsent(connection, EXACT_SLOT_CONSTRAINT); - } - } - - private Flyway targetFourFlyway() { + private Flyway baselineFlyway() { return Flyway.configure() .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .locations("classpath:db/migration") - .target(MigrationVersion.fromVersion("4")) - .cleanDisabled(false) - .load(); - } - - private Flyway targetThreeFlyway() { - return Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration") - .target(MigrationVersion.fromVersion("3")) + .target(MigrationVersion.fromVersion("1")) .cleanDisabled(false) .load(); } @@ -291,20 +186,6 @@ class ScheduleRuleHoursMigrationIntegrationTest { } } - private void assertConstraintAbsent(Connection connection, String constraintName) throws SQLException { - assertThat(queryLong( - connection, - """ - SELECT count(*) - FROM pg_constraint - WHERE conrelid IN ('schedule_rules'::regclass, 'schedule_rule_slots'::regclass) - AND conname = ? - """, - constraintName - )).as("Ограничение %s не должно частично остаться после неуспешной V4", constraintName) - .isZero(); - } - private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException { try (PreparedStatement statement = connection.prepareStatement(sql)) { for (int index = 0; index < parameters.length; index++) { @@ -319,19 +200,6 @@ class ScheduleRuleHoursMigrationIntegrationTest { } } - private String collectMessages(Throwable failure) { - StringBuilder messages = new StringBuilder(); - Set 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/migration/SubjectOwnershipInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java new file mode 100644 index 0000000..71110f4 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java @@ -0,0 +1,146 @@ +package com.magistr.app.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Testcontainers +@DisplayName("Уникальность владения дисциплинами базовой схемы") +class SubjectOwnershipInvariantIntegrationTest { + + private static final String UNIQUE_INDEX = "uq_subjects_name_ci"; + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + + @Test + @DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра") + void baselineEnforcesCaseInsensitiveGlobalSubjectName() throws SQLException { + migrateBaseline(); + + try (Connection connection = POSTGRES.createConnection("")) { + long anotherDepartmentId = queryLong(connection, "SELECT max(id) FROM departments"); + Throwable failure = catchThrowable(() -> insertSubject( + connection, + "информатика", + anotherDepartmentId + )); + + assertThat(failure).isInstanceOf(SQLException.class); + SQLException sqlFailure = (SQLException) failure; + assertThat(sqlFailure.getSQLState()).isEqualTo("23505"); + assertThat(sqlFailure.getMessage()).contains(UNIQUE_INDEX); + } + } + + @Test + @DisplayName("V1 конкурентно принимает только одну дисциплину с одинаковым названием") + void baselineProtectsConcurrentSubjectCreation() throws Exception { + migrateBaseline(); + long firstDepartmentId; + long secondDepartmentId; + try (Connection connection = POSTGRES.createConnection("")) { + firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments"); + secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments"); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = List.of( + executor.submit(() -> concurrentInsert("Теория систем", firstDepartmentId, ready, start)), + executor.submit(() -> concurrentInsert("теория систем", secondDepartmentId, ready, start)) + ); + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List outcomes = List.of( + futures.get(0).get(10, TimeUnit.SECONDS), + futures.get(1).get(10, TimeUnit.SECONDS) + ); + assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1); + assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement() + .satisfies(outcome -> { + assertThat(outcome.sqlState()).isEqualTo("23505"); + assertThat(outcome.message()).contains(UNIQUE_INDEX); + }); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private void migrateBaseline() { + Flyway flyway = Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("1")) + .cleanDisabled(false) + .load(); + flyway.clean(); + flyway.migrate(); + } + + private InsertOutcome concurrentInsert(String name, + long departmentId, + CountDownLatch ready, + CountDownLatch start) { + try (Connection connection = POSTGRES.createConnection("")) { + connection.setAutoCommit(false); + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + return new InsertOutcome(false, null, "Не получен общий сигнал запуска"); + } + try { + insertSubject(connection, name, departmentId); + connection.commit(); + return new InsertOutcome(true, null, null); + } catch (SQLException exception) { + connection.rollback(); + return new InsertOutcome(false, exception.getSQLState(), exception.getMessage()); + } + } catch (Exception exception) { + return new InsertOutcome(false, null, exception.getMessage()); + } + } + + private void insertSubject(Connection connection, String name, long departmentId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO subjects (name, department_id) + VALUES (?, ?) + """)) { + statement.setString(1, name); + statement.setLong(2, departmentId); + statement.executeUpdate(); + } + } + + private long queryLong(Connection connection, String sql) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql); + var resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + + private record InsertOutcome(boolean success, String sqlState, String message) { + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java new file mode 100644 index 0000000..f888261 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java @@ -0,0 +1,257 @@ +package com.magistr.app.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Testcontainers +@DisplayName("Исторические назначения преподавателей базовой схемы") +class TeacherDepartmentInvariantIntegrationTest { + + private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap"; + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + + @Test + @DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр") + void baselineEnforcesTeacherDepartmentTimeline() throws SQLException { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + try (Connection connection = POSTGRES.createConnection("")) { + long teacherId = seedTeacherId(connection); + long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments"); + long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments"); + updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31)); + + insertAssignment( + connection, + teacherId, + secondDepartmentId, + LocalDate.of(2026, 8, 1), + null, + true + ); + insertAssignment( + connection, + teacherId, + firstDepartmentId, + LocalDate.of(2026, 8, 1), + null, + false + ); + + assertSqlState( + () -> insertAssignment( + connection, + teacherId, + firstDepartmentId, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 1), + true + ), + "23P01", + OVERLAP_CONSTRAINT + ); + assertThat(queryLong( + connection, + "SELECT count(*) FROM pg_constraint WHERE conname = ?", + OVERLAP_CONSTRAINT + )).isOne(); + } + } + + @Test + @DisplayName("V1 отклоняет одну из двух конкурентных пересекающихся основных кафедр") + void baselineProtectsConcurrentPrimaryAssignments() throws Exception { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + long teacherId; + long firstDepartmentId; + long secondDepartmentId; + try (Connection connection = POSTGRES.createConnection("")) { + teacherId = seedTeacherId(connection); + firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments"); + secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments"); + updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31)); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = List.of( + executor.submit(() -> concurrentInsert( + teacherId, + firstDepartmentId, + LocalDate.of(2090, 1, 1), + LocalDate.of(2090, 12, 31), + ready, + start + )), + executor.submit(() -> concurrentInsert( + teacherId, + secondDepartmentId, + LocalDate.of(2090, 6, 1), + LocalDate.of(2091, 5, 31), + ready, + start + )) + ); + + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + List outcomes = List.of( + futures.get(0).get(10, TimeUnit.SECONDS), + futures.get(1).get(10, TimeUnit.SECONDS) + ); + + assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1); + assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement() + .satisfies(outcome -> { + assertThat(outcome.sqlState()).isIn("23P01", "40P01"); + if ("23P01".equals(outcome.sqlState())) { + assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT); + } + }); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private InsertOutcome concurrentInsert(long teacherId, + long departmentId, + LocalDate validFrom, + LocalDate validTo, + CountDownLatch ready, + CountDownLatch start) { + try (Connection connection = POSTGRES.createConnection("")) { + connection.setAutoCommit(false); + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + return new InsertOutcome(false, null, "Не получен общий сигнал запуска"); + } + try { + insertAssignment(connection, teacherId, departmentId, validFrom, validTo, true); + connection.commit(); + return new InsertOutcome(true, null, null); + } catch (SQLException exception) { + connection.rollback(); + return new InsertOutcome(false, exception.getSQLState(), exception.getMessage()); + } + } catch (Exception exception) { + return new InsertOutcome(false, null, exception.getMessage()); + } + } + + private Flyway baselineFlyway() { + return Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("1")) + .cleanDisabled(false) + .load(); + } + + private long seedTeacherId(Connection connection) throws SQLException { + return queryLong( + connection, + "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1" + ); + } + + private void updateOpenPrimaryEnd(Connection connection, + long teacherId, + LocalDate validTo) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + UPDATE teacher_department_assignments + SET valid_to = ? + WHERE teacher_id = ? + AND is_primary = TRUE + AND valid_to IS NULL + """)) { + statement.setDate(1, Date.valueOf(validTo)); + statement.setLong(2, teacherId); + assertThat(statement.executeUpdate()).isEqualTo(1); + } + } + + private void insertAssignment(Connection connection, + long teacherId, + long departmentId, + LocalDate validFrom, + LocalDate validTo, + boolean primary) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO teacher_department_assignments ( + teacher_id, + department_id, + valid_from, + valid_to, + is_primary + ) VALUES (?, ?, ?, ?, ?) + """)) { + statement.setLong(1, teacherId); + statement.setLong(2, departmentId); + statement.setDate(3, Date.valueOf(validFrom)); + statement.setObject(4, validTo == null ? null : Date.valueOf(validTo)); + statement.setBoolean(5, primary); + statement.executeUpdate(); + } + } + + private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void assertSqlState(ThrowingSqlRunnable operation, + String expectedSqlState, + String expectedMessagePart) { + Throwable failure = catchThrowable(operation::run); + assertThat(failure).isInstanceOf(SQLException.class); + SQLException sqlFailure = (SQLException) failure; + assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState); + assertThat(sqlFailure.getMessage()).contains(expectedMessagePart); + } + + @FunctionalInterface + private interface ThrowingSqlRunnable { + void run() throws SQLException; + } + + private record InsertOutcome(boolean success, String sqlState, String message) { + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java new file mode 100644 index 0000000..829313b --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java @@ -0,0 +1,316 @@ +package com.magistr.app.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Testcontainers +@DisplayName("Инварианты временных слотов базовой схемы") +class TimeSlotInvariantMigrationIntegrationTest { + + private static final String DURATION_CONSTRAINT = "chk_time_slots_duration_matches_range"; + private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap"; + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + + @Test + @DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты") + void migrationEnforcesAllTimeSlotInvariants() throws SQLException { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + assertThat(flyway.info().current()).isNotNull(); + assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1"); + + try (Connection connection = POSTGRES.createConnection("")) { + long manualScopeId = insertManualScope(connection, "migration_checks"); + long firstSlotId = insertTimeSlot(connection, manualScopeId, 101, "08:00", "09:30", 90); + insertTimeSlot(connection, manualScopeId, 102, "09:30", "11:00", 90); + + assertSqlState( + () -> insertTimeSlot(connection, manualScopeId, 103, "09:00", "10:00", 60), + "23P01", + OVERLAP_CONSTRAINT + ); + assertSqlState( + () -> insertTimeSlot(connection, manualScopeId, 104, "11:10", "12:10", 30), + "23514", + DURATION_CONSTRAINT + ); + + long usedBaseSlotId = queryLong( + connection, + "SELECT time_slot_id FROM schedule_rule_slots ORDER BY id LIMIT 1" + ); + assertSqlState( + () -> updateLong( + connection, + "UPDATE time_slots SET time_slot_scope_id = ? WHERE id = ?", + manualScopeId, + usedBaseSlotId + ), + "23514", + "нельзя переносить из базовой сетки" + ); + assertSqlState( + () -> copyRuleSlotWithTimeSlot(connection, firstSlotId), + "23514", + "только слоты базовой сетки" + ); + + long defaultScopeId = queryLong( + connection, + "SELECT id FROM time_slot_scopes WHERE apply_mode = 'DEFAULT'" + ); + assertSqlState( + () -> updateString( + connection, + "UPDATE time_slot_scopes SET apply_mode = ? WHERE id = ?", + "MANUAL", + defaultScopeId + ), + "23514", + "нельзя сделать небазовой" + ); + + assertThat(constraintCount(connection, DURATION_CONSTRAINT)).isOne(); + assertThat(constraintCount(connection, OVERLAP_CONSTRAINT)).isOne(); + } + } + + @Test + @DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся интервалов") + void migrationProtectsConcurrentWrites() throws Exception { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + long manualScopeId; + try (Connection connection = POSTGRES.createConnection("")) { + manualScopeId = insertManualScope(connection, "concurrent_slots"); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = List.of( + executor.submit(() -> concurrentInsert( + manualScopeId, 301, "12:00", "13:30", ready, start)), + executor.submit(() -> concurrentInsert( + manualScopeId, 302, "13:00", "14:30", ready, start)) + ); + + assertThat(ready.await(5, TimeUnit.SECONDS)) + .as("Обе конкурентные транзакции должны подготовиться") + .isTrue(); + start.countDown(); + + List outcomes = List.of( + futures.get(0).get(10, TimeUnit.SECONDS), + futures.get(1).get(10, TimeUnit.SECONDS) + ); + assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1); + assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement() + .satisfies(outcome -> { + assertThat(outcome.sqlState()).isIn("23P01", "40P01"); + if ("23P01".equals(outcome.sqlState())) { + assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT); + } + }); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private InsertOutcome concurrentInsert(long scopeId, + int orderNumber, + String startTime, + String endTime, + CountDownLatch ready, + CountDownLatch start) { + try (Connection connection = POSTGRES.createConnection("")) { + connection.setAutoCommit(false); + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + return new InsertOutcome(false, null, "Не получен общий сигнал запуска"); + } + try { + insertTimeSlot(connection, scopeId, orderNumber, startTime, endTime, 90); + connection.commit(); + return new InsertOutcome(true, null, null); + } catch (SQLException exception) { + connection.rollback(); + return new InsertOutcome(false, exception.getSQLState(), exception.getMessage()); + } + } catch (Exception exception) { + return new InsertOutcome(false, null, exception.getMessage()); + } + } + + private Flyway baselineFlyway() { + return flyway("1"); + } + + private Flyway flyway(String targetVersion) { + return Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion(targetVersion)) + .cleanDisabled(false) + .load(); + } + + private long insertManualScope(Connection connection, String code) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO time_slot_scopes ( + code, + name, + apply_mode, + system_scope, + display_order + ) VALUES (?, ?, 'MANUAL', FALSE, 900) + RETURNING id + """)) { + statement.setString(1, code); + statement.setString(2, "Тестовая ручная сетка"); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private long insertTimeSlot(Connection connection, + long scopeId, + int orderNumber, + String startTime, + String endTime, + int durationMinutes) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO time_slots ( + time_slot_scope_id, + order_number, + start_time, + end_time, + duration_minutes + ) VALUES (?, ?, ?::TIME, ?::TIME, ?) + RETURNING id + """)) { + statement.setLong(1, scopeId); + statement.setInt(2, orderNumber); + statement.setString(3, startTime); + statement.setString(4, endTime); + statement.setInt(5, durationMinutes); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void copyRuleSlotWithTimeSlot(Connection connection, long timeSlotId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO schedule_rule_slots ( + schedule_rule_id, + day_of_week, + parity, + time_slot_id, + teacher_id, + classroom_id, + lesson_type_id, + lesson_format + ) + SELECT + schedule_rule_id, + day_of_week, + parity, + ?, + teacher_id, + classroom_id, + lesson_type_id, + lesson_format + FROM schedule_rule_slots + ORDER BY id + LIMIT 1 + """)) { + statement.setLong(1, timeSlotId); + statement.executeUpdate(); + } + } + + private void updateLong(Connection connection, String sql, long value, long id) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, value); + statement.setLong(2, id); + statement.executeUpdate(); + } + } + + private void updateString(Connection connection, String sql, String value, long id) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, value); + statement.setLong(2, id); + statement.executeUpdate(); + } + } + + private long constraintCount(Connection connection, String constraintName) throws SQLException { + return queryLong( + connection, + "SELECT count(*) FROM pg_constraint WHERE conrelid = 'time_slots'::regclass AND conname = ?", + constraintName + ); + } + + private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).as("SQL-запрос должен вернуть строку: %s", sql).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void assertSqlState(ThrowingSqlRunnable operation, + String expectedSqlState, + String expectedMessagePart) { + Throwable failure = catchThrowable(operation::run); + assertThat(failure).isInstanceOf(SQLException.class); + SQLException sqlFailure = (SQLException) failure; + assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState); + assertThat(sqlFailure.getMessage()).contains(expectedMessagePart); + } + + @FunctionalInterface + private interface ThrowingSqlRunnable { + void run() throws SQLException; + } + + private record InsertOutcome(boolean success, String sqlState, String message) { + } +} diff --git a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java index c5e36c0..c208dce 100644 --- a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java +++ b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java @@ -2,6 +2,7 @@ package com.magistr.app.model; import org.junit.jupiter.api.Test; +import java.time.Instant; import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThat; @@ -37,4 +38,17 @@ class LifecycleEntityTest { assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue(); assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse(); } + + @Test + void archiveUsesExplicitBusinessDateAndUtcMoment() { + Subject subject = new Subject(); + LocalDate businessDate = LocalDate.of(2026, 1, 2); + Instant archiveMoment = Instant.parse("2026-01-01T21:00:00Z"); + + subject.archive("Тестовая архивация", businessDate, archiveMoment); + + assertThat(subject.getActiveTo()).isEqualTo(businessDate); + assertThat(subject.getArchivedAt()).isEqualTo(archiveMoment); + assertThat(subject.getArchiveReason()).isEqualTo("Тестовая архивация"); + } } diff --git a/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java new file mode 100644 index 0000000..531d425 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java @@ -0,0 +1,245 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.AcademicYearDto; +import com.magistr.app.dto.SemesterDto; +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.Semester; +import com.magistr.app.model.SemesterType; +import com.magistr.app.repository.AcademicYearRepository; +import com.magistr.app.repository.SemesterRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AcademicPeriodServiceTest { + + private static final long YEAR_ID = 10L; + private static final long SEMESTER_ID = 20L; + private static final LocalDate YEAR_START = LocalDate.of(2026, 9, 1); + private static final LocalDate YEAR_END = LocalDate.of(2027, 6, 30); + + @Mock + private AcademicYearRepository academicYearRepository; + @Mock + private SemesterRepository semesterRepository; + @Mock + private ScheduleGeneratorService scheduleGeneratorService; + + private AcademicPeriodService service; + private AcademicYear year; + + @BeforeEach + void setUp() { + service = new AcademicPeriodService( + academicYearRepository, + semesterRepository, + scheduleGeneratorService + ); + year = year(); + when(academicYearRepository.saveAndFlush(any(AcademicYear.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(semesterRepository.saveAndFlush(any(Semester.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void createsNonOverlappingYearWithTrimmedTitle() { + AcademicYear saved = service.createYear(yearRequest( + " 2026-2027 ", + YEAR_START, + YEAR_END + )); + + assertThat(saved.getTitle()).isEqualTo("2026-2027"); + assertThat(saved.getStartDate()).isEqualTo(YEAR_START); + verify(scheduleGeneratorService).clearCache(); + } + + @Test + void rejectsDuplicateTitleOrOverlappingYear() { + when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true); + + assertThatThrownBy(() -> service.createYear(yearRequest( + "2026-2027", + YEAR_START, + YEAR_END + ))) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Название или период учебного года конфликтует с существующим учебным годом"); + + verify(academicYearRepository, never()).saveAndFlush(any()); + } + + @Test + void rejectsYearUpdateThatWouldExcludeExistingSemester() { + Semester autumn = semester( + SEMESTER_ID, + SemesterType.autumn, + YEAR_START, + LocalDate.of(2027, 1, 31) + ); + when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); + when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID)) + .thenReturn(List.of(autumn)); + + assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest( + "2026-2027", + YEAR_START.plusDays(1), + YEAR_END + ))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Новые границы учебного года не включают все его семестры"); + + verify(academicYearRepository, never()).saveAndFlush(any()); + } + + @Test + void rejectsSemesterOutsideAcademicYear() { + when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); + + assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest( + SemesterType.autumn, + YEAR_START.minusDays(1), + LocalDate.of(2027, 1, 31) + ))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Семестр должен полностью находиться в границах учебного года"); + + verify(semesterRepository, never()).saveAndFlush(any()); + } + + @Test + void rejectsDuplicateTypeOrOverlappingSemester() { + when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); + when(semesterRepository.existsOverlapping( + YEAR_ID, + YEAR_START, + LocalDate.of(2027, 2, 1) + )).thenReturn(true); + + assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest( + SemesterType.autumn, + YEAR_START, + LocalDate.of(2027, 2, 1) + ))) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года"); + + verify(semesterRepository, never()).saveAndFlush(any()); + } + + @Test + void acceptsAdjacentSemesters() { + when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); + + Semester saved = service.createSemester(YEAR_ID, semesterRequest( + SemesterType.spring, + LocalDate.of(2027, 2, 1), + YEAR_END + )); + + assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1)); + assertThat(saved.getAcademicYear()).isSameAs(year); + verify(scheduleGeneratorService).clearCache(); + } + + @Test + void validatesDuplicateSemesterTypeDuringUpdate() { + Semester existing = semester( + SEMESTER_ID, + SemesterType.spring, + LocalDate.of(2027, 2, 1), + YEAR_END + ); + when(semesterRepository.findById(SEMESTER_ID)).thenReturn(Optional.of(existing)); + when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); + when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(existing)); + when(semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot( + YEAR_ID, + SemesterType.autumn, + SEMESTER_ID + )).thenReturn(true); + + assertThatThrownBy(() -> service.updateSemester(SEMESTER_ID, semesterRequest( + SemesterType.autumn, + LocalDate.of(2027, 2, 1), + YEAR_END + ))) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года"); + + verify(semesterRepository, never()).saveAndFlush(any()); + } + + @Test + void mapsDatabaseRaceToSafeConflict() { + when(academicYearRepository.saveAndFlush(any(AcademicYear.class))) + .thenThrow(new DataIntegrityViolationException("ex_academic_years_no_overlap")); + + assertThatThrownBy(() -> service.createYear(yearRequest( + "2026-2027", + YEAR_START, + YEAR_END + ))) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Название или период учебного года конфликтует с существующим учебным годом") + .hasNoCause(); + } + + private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) { + return new AcademicYearDto(null, title, startDate, endDate, List.of()); + } + + private SemesterDto semesterRequest(SemesterType semesterType, + LocalDate startDate, + LocalDate endDate) { + return new SemesterDto( + null, + YEAR_ID, + "2026-2027", + semesterType, + startDate, + endDate + ); + } + + private AcademicYear year() { + AcademicYear academicYear = new AcademicYear(); + academicYear.setId(YEAR_ID); + academicYear.setTitle("2026-2027"); + academicYear.setStartDate(YEAR_START); + academicYear.setEndDate(YEAR_END); + return academicYear; + } + + private Semester semester(Long id, + SemesterType semesterType, + LocalDate startDate, + LocalDate endDate) { + Semester semester = new Semester(); + semester.setId(id); + semester.setAcademicYear(year); + semester.setSemesterType(semesterType); + semester.setStartDate(startDate); + semester.setEndDate(endDate); + return semester; + } +} diff --git a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java new file mode 100644 index 0000000..cb5242e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java @@ -0,0 +1,335 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.AcademicCalendarDto; +import com.magistr.app.dto.CreateGroupRequest; +import com.magistr.app.dto.GroupCalendarAssignmentDto; +import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarRepository; +import com.magistr.app.repository.AcademicCalendarSubjectRepository; +import com.magistr.app.repository.AcademicYearRepository; +import com.magistr.app.repository.EducationFormRepository; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AcademicStructureServiceTest { + + private static final long YEAR_ID = 10L; + private static final long SPECIALITY_ID = 20L; + private static final long PROFILE_ID = 30L; + private static final long FORM_ID = 40L; + private static final long OTHER_FORM_ID = 41L; + private static final long CALENDAR_ID = 50L; + private static final long GROUP_ID = 60L; + + @Mock + private AcademicCalendarRepository calendarRepository; + @Mock + private AcademicCalendarDayRepository calendarDayRepository; + @Mock + private AcademicCalendarSubjectRepository calendarSubjectRepository; + @Mock + private AcademicYearRepository academicYearRepository; + @Mock + private SpecialtiesRepository specialtiesRepository; + @Mock + private SpecialtyProfileRepository profileRepository; + @Mock + private EducationFormRepository educationFormRepository; + @Mock + private GroupRepository groupRepository; + @Mock + private StudentGroupCalendarAssignmentRepository assignmentRepository; + @Mock + private ScheduleGeneratorService scheduleGeneratorService; + + private AcademicStructureService service; + private AcademicYear academicYear; + private Speciality speciality; + private SpecialtyProfile profile; + private EducationForm studyForm; + private EducationForm otherStudyForm; + private AcademicCalendar calendar; + private StudentGroup group; + + @BeforeEach + void setUp() { + service = new AcademicStructureService( + calendarRepository, + calendarDayRepository, + calendarSubjectRepository, + academicYearRepository, + specialtiesRepository, + profileRepository, + educationFormRepository, + groupRepository, + assignmentRepository, + scheduleGeneratorService + ); + academicYear = academicYear(); + speciality = speciality(); + profile = profile(speciality); + studyForm = studyForm(FORM_ID, "Бакалавриат"); + otherStudyForm = studyForm(OTHER_FORM_ID, "Магистратура"); + calendar = calendar(); + group = group(2025); + + when(calendarRepository.findByIdWithDetailsForUpdate(CALENDAR_ID)) + .thenReturn(Optional.of(calendar)); + when(academicYearRepository.findById(YEAR_ID)).thenReturn(Optional.of(academicYear)); + when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(academicYear)); + when(specialtiesRepository.findById(SPECIALITY_ID)).thenReturn(Optional.of(speciality)); + when(profileRepository.findById(PROFILE_ID)).thenReturn(Optional.of(profile)); + when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm)); + when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm)); + when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group)); + when(calendarRepository.saveAndFlush(any(AcademicCalendar.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(groupRepository.saveAndFlush(any(StudentGroup.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() { + when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID)) + .thenReturn(List.of(assignment(group, calendar))); + + assertThatThrownBy(() -> service.updateCalendar( + CALENDAR_ID, + calendarRequest(4, OTHER_FORM_ID) + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Нельзя изменить график: сохранённые назначения групп станут несовместимыми"); + + assertThat(calendar.getStudyForm()).isSameAs(studyForm); + verify(calendarRepository, never()).saveAndFlush(any()); + } + + @Test + void rejectsCourseCountBelowExistingGrid() { + when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( + CALENDAR_ID, 3)).thenReturn(true); + + assertThatThrownBy(() -> service.updateCalendar( + CALENDAR_ID, + calendarRequest(3, FORM_ID) + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"); + + assertThat(calendar.getCourseCount()).isEqualTo(4); + } + + @Test + void rejectsCourseCountBelowExistingSubjects() { + when(calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan( + CALENDAR_ID, 6)).thenReturn(true); + + assertThatThrownBy(() -> service.updateCalendar( + CALENDAR_ID, + calendarRequest(3, FORM_ID) + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров"); + } + + @Test + void rejectsGroupDimensionChangeAndKeepsAssignment() { + when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID)) + .thenReturn(List.of(assignment(group, calendar))); + + assertThatThrownBy(() -> service.updateGroup( + GROUP_ID, + groupRequest(2025, OTHER_FORM_ID) + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми"); + + assertThat(group.getEducationForm()).isSameAs(studyForm); + verify(groupRepository, never()).saveAndFlush(any()); + verify(assignmentRepository, never()).delete(any()); + } + + @Test + void acceptsCompatibleGroupUpdateAndClearsCache() { + when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID)) + .thenReturn(List.of(assignment(group, calendar))); + + StudentGroup saved = service.updateGroup(GROUP_ID, groupRequest(2025, FORM_ID)); + + assertThat(saved.getName()).isEqualTo("ИВТ-26"); + assertThat(saved.getEducationForm()).isSameAs(studyForm); + verify(scheduleGeneratorService).clearCache(); + } + + @Test + void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() { + group.setYearStartStudy(2020); + + assertThatThrownBy(() -> service.saveAssignment( + GROUP_ID, + assignmentRequest() + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Календарный график не соответствует учебному году, параметрам или курсу группы"); + + verify(assignmentRepository, never()).saveAndFlush(any()); + } + + @Test + void savesCompatibleAssignmentWithoutDeletingExistingData() { + when(assignmentRepository.findByGroupIdAndAcademicYearIdForUpdate(GROUP_ID, YEAR_ID)) + .thenReturn(Optional.empty()); + + StudentGroupCalendarAssignment saved = service.saveAssignment( + GROUP_ID, + assignmentRequest() + ); + + assertThat(saved.getStudentGroup()).isSameAs(group); + assertThat(saved.getAcademicCalendar()).isSameAs(calendar); + verify(assignmentRepository, never()).delete(any()); + verify(scheduleGeneratorService).clearCache(); + } + + private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) { + return new AcademicCalendarDto( + CALENDAR_ID, + "График 2026", + YEAR_ID, + null, + null, + null, + SPECIALITY_ID, + null, + null, + PROFILE_ID, + null, + studyFormId, + null, + courseCount + ); + } + + private CreateGroupRequest groupRequest(int yearStartStudy, long studyFormId) { + CreateGroupRequest request = new CreateGroupRequest(); + request.setName("ИВТ-26"); + request.setGroupSize(25L); + request.setEducationFormId(studyFormId); + request.setDepartmentId(1L); + request.setYearStartStudy(yearStartStudy); + request.setSpecialtyId(SPECIALITY_ID); + request.setSpecialtyProfileId(PROFILE_ID); + return request; + } + + private GroupCalendarAssignmentDto assignmentRequest() { + return new GroupCalendarAssignmentDto( + null, + GROUP_ID, + null, + YEAR_ID, + null, + CALENDAR_ID, + null, + List.of() + ); + } + + private AcademicYear academicYear() { + AcademicYear year = new AcademicYear(); + year.setId(YEAR_ID); + year.setTitle("2025-2026"); + year.setStartDate(LocalDate.of(2025, 9, 1)); + year.setEndDate(LocalDate.of(2026, 6, 30)); + return year; + } + + private Speciality speciality() { + Speciality value = new Speciality(); + value.setId(SPECIALITY_ID); + value.setSpecialityCode("09.03.01"); + value.setSpecialityName("Информатика"); + return value; + } + + private SpecialtyProfile profile(Speciality owner) { + SpecialtyProfile value = new SpecialtyProfile(); + value.setId(PROFILE_ID); + value.setName("Без профиля"); + value.setSpeciality(owner); + return value; + } + + private EducationForm studyForm(long id, String name) { + EducationForm value = new EducationForm(); + value.setId(id); + value.setName(name); + return value; + } + + private AcademicCalendar calendar() { + AcademicCalendar value = new AcademicCalendar(); + value.setId(CALENDAR_ID); + value.setTitle("Исходный график"); + value.setAcademicYear(academicYear); + value.setSpeciality(speciality); + value.setSpecialtyProfile(profile); + value.setStudyForm(studyForm); + value.setCourseCount(4); + return value; + } + + private StudentGroup group(int yearStartStudy) { + StudentGroup value = new StudentGroup(); + value.setId(GROUP_ID); + value.setName("ИВТ-25"); + value.setGroupSize(25L); + value.setEducationForm(studyForm); + value.setDepartmentId(1L); + value.setYearStartStudy(yearStartStudy); + value.setSpeciality(speciality); + value.setSpecialtyProfile(profile); + return value; + } + + private StudentGroupCalendarAssignment assignment(StudentGroup assignedGroup, + AcademicCalendar assignedCalendar) { + StudentGroupCalendarAssignment value = new StudentGroupCalendarAssignment(); + value.setId(70L); + value.setStudentGroup(assignedGroup); + value.setAcademicYear(academicYear); + value.setAcademicCalendar(assignedCalendar); + return value; + } +} diff --git a/backend/src/test/java/com/magistr/app/service/BusinessTimeServiceTest.java b/backend/src/test/java/com/magistr/app/service/BusinessTimeServiceTest.java new file mode 100644 index 0000000..5c34a51 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/BusinessTimeServiceTest.java @@ -0,0 +1,36 @@ +package com.magistr.app.service; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class BusinessTimeServiceTest { + + private static final ZoneId MOSCOW = ZoneId.of("Europe/Moscow"); + + @Test + void keepsBusinessDateBeforeMoscowMidnight() { + BusinessTimeService service = new BusinessTimeService( + Clock.fixed(Instant.parse("2026-01-01T20:59:59Z"), ZoneId.of("UTC")), + MOSCOW + ); + + assertThat(service.today()).isEqualTo(LocalDate.of(2026, 1, 1)); + assertThat(service.now()).isEqualTo(Instant.parse("2026-01-01T20:59:59Z")); + } + + @Test + void changesBusinessDateExactlyAtMoscowMidnight() { + BusinessTimeService service = new BusinessTimeService( + Clock.fixed(Instant.parse("2026-01-01T21:00:00Z"), ZoneId.of("UTC")), + MOSCOW + ); + + assertThat(service.today()).isEqualTo(LocalDate.of(2026, 1, 2)); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java new file mode 100644 index 0000000..9bb02cb --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java @@ -0,0 +1,265 @@ +package com.magistr.app.service; + +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.HashSet; +import java.util.List; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("Число запросов генератора расписания") +class ScheduleGenerationQueryCountTest { + + private static final LocalDate START_DATE = LocalDate.of(2026, 9, 1); + private static final LocalDate END_DATE = LocalDate.of(2026, 12, 29); + private static final int GROUP_COUNT = 30; + private static final int RULE_COUNT = 20; + + @Test + @DisplayName("120 дат, десятки групп и правил обрабатываются семью batch-запросами") + void queryCountDoesNotGrowAsDatesTimesGroupsTimesRules() { + SemesterRepository semesterRepository = mock(SemesterRepository.class); + StudentGroupCalendarAssignmentRepository assignmentRepository = + mock(StudentGroupCalendarAssignmentRepository.class); + AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class); + ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class); + TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class); + TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class); + TimeSlotRepository timeSlotRepository = mock(TimeSlotRepository.class); + GroupRepository groupRepository = mock(GroupRepository.class); + UserRepository userRepository = mock(UserRepository.class); + + AcademicYear academicYear = academicYear(); + Semester semester = semester(academicYear); + AcademicCalendar calendar = calendar(academicYear); + List groups = groups(); + List assignments = groups.stream() + .map(group -> assignment(group, academicYear, calendar)) + .toList(); + List calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1)) + .map(date -> calendarDay(calendar, date)) + .toList(); + List rules = IntStream.rangeClosed(1, RULE_COUNT) + .mapToObj(index -> rule(index, semester, groups)) + .toList(); + TimeSlotScope defaultScope = timeSlotScope(40L, "DEFAULT", null); + TimeSlotScope weekdayScope = timeSlotScope(41L, "WEEKDAY", 2); + TimeSlot baseSlot = timeSlot(50L, defaultScope, LocalTime.of(8, 0), LocalTime.of(9, 30)); + TimeSlot effectiveSlot = timeSlot(51L, weekdayScope, LocalTime.of(8, 15), LocalTime.of(9, 45)); + addRenderedLecture(rules.get(0), baseSlot); + + when(semesterRepository.findOverlappingForSchedule(START_DATE, END_DATE)) + .thenReturn(List.of(semester)); + when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection())) + .thenReturn(assignments); + when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE))) + .thenReturn(calendarDays); + when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection())) + .thenReturn(rules); + when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of()); + when(scopeRepository.findByApplyModeOrderByDisplayOrderAsc("WEEKDAY")) + .thenReturn(List.of(weekdayScope)); + when(timeSlotRepository.findAllForSchedule()).thenReturn(List.of(baseSlot, effectiveSlot)); + + AcademicDateService academicDateService = new AcademicDateService( + semesterRepository, + calendarDayRepository, + assignmentRepository, + false + ); + ScheduleGeneratorService generator = new ScheduleGeneratorService( + ruleRepository, + groupRepository, + userRepository, + academicDateService, + timeSlotRepository, + scopeRepository, + dateAssignmentRepository + ); + + var result = generator.buildScheduleForGroups(groups, START_DATE, END_DATE); + + assertThat(START_DATE.datesUntil(END_DATE.plusDays(1)).count()).isEqualTo(120); + assertThat(groups).hasSize(GROUP_COUNT); + assertThat(rules).hasSize(RULE_COUNT); + assertThat(result).hasSize(GROUP_COUNT).allSatisfy(lesson -> { + assertThat(lesson.date()).isEqualTo(START_DATE); + assertThat(lesson.timeSlotId()).isEqualTo(effectiveSlot.getId()); + assertThat(lesson.startTime()).isEqualTo(effectiveSlot.getStartTime()); + assertThat(lesson.remainingLessonTypeAcademicHoursAfterLesson()).isZero(); + assertThat(lesson.groupIds()).hasSize(1); + }); + + verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE); + verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection()); + verify(calendarDayRepository).findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE)); + verify(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()); + verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE); + verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY"); + verify(timeSlotRepository).findAllForSchedule(); + + verify(semesterRepository, never()) + .findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any()); + verify(assignmentRepository, never()).findForSchedule(any(), any()); + verify(calendarDayRepository, never()) + .findByCalendarIdAndCourseNumberAndDate(any(), any(), any()); + verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any()); + verify(dateAssignmentRepository, never()).findByDate(any()); + verify(scopeRepository, never()).findByApplyModeAndDayOfWeek(any(), any()); + verify(timeSlotRepository, never()).findByTimeSlotScopeIdAndOrderNumber(any(), any()); + + int repositoryCalls = Stream.of( + semesterRepository, + assignmentRepository, + calendarDayRepository, + ruleRepository, + dateAssignmentRepository, + scopeRepository, + timeSlotRepository, + groupRepository, + userRepository + ) + .mapToInt(repository -> mockingDetails(repository).getInvocations().size()) + .sum(); + assertThat(repositoryCalls).isEqualTo(7); + } + + private AcademicYear academicYear() { + AcademicYear academicYear = new AcademicYear(); + academicYear.setId(1L); + academicYear.setTitle("2026/2027"); + academicYear.setStartDate(START_DATE); + academicYear.setEndDate(LocalDate.of(2027, 8, 31)); + return academicYear; + } + + private Semester semester(AcademicYear academicYear) { + Semester semester = new Semester(); + semester.setId(10L); + semester.setAcademicYear(academicYear); + semester.setSemesterType(SemesterType.autumn); + semester.setStartDate(START_DATE); + semester.setEndDate(END_DATE); + return semester; + } + + private AcademicCalendar calendar(AcademicYear academicYear) { + AcademicCalendar calendar = new AcademicCalendar(); + calendar.setId(20L); + calendar.setTitle("Основной учебный график"); + calendar.setAcademicYear(academicYear); + calendar.setCourseCount(4); + return calendar; + } + + private List groups() { + return IntStream.rangeClosed(1, GROUP_COUNT) + .mapToObj(index -> { + StudentGroup group = new StudentGroup(); + group.setId((long) index); + group.setName("Группа " + index); + group.setYearStartStudy(2026); + return group; + }) + .toList(); + } + + private StudentGroupCalendarAssignment assignment(StudentGroup group, + AcademicYear academicYear, + AcademicCalendar calendar) { + StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment(); + assignment.setId(100L + group.getId()); + assignment.setStudentGroup(group); + assignment.setAcademicYear(academicYear); + assignment.setAcademicCalendar(calendar); + return assignment; + } + + private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) { + AcademicCalendarActivityType theory = new AcademicCalendarActivityType(); + theory.setId(30L); + theory.setCode("Т"); + theory.setName("Теоретическое обучение"); + theory.setAllowSchedule(true); + + AcademicCalendarDay day = new AcademicCalendarDay(); + day.setAcademicCalendar(calendar); + day.setCourseNumber(1); + day.setDate(date); + day.setActivityType(theory); + return day; + } + + private ScheduleRule rule(int index, Semester semester, List groups) { + Subject subject = new Subject((long) index, "Дисциплина " + index, "D" + index, 1L); + ScheduleRule rule = new ScheduleRule(); + rule.setId((long) index); + rule.setSubject(subject); + rule.setSemester(semester); + rule.setGroups(new HashSet<>(groups)); + return rule; + } + + private TimeSlotScope timeSlotScope(Long id, String applyMode, Integer dayOfWeek) { + TimeSlotScope scope = new TimeSlotScope(); + scope.setId(id); + scope.setCode(applyMode + "-" + id); + scope.setName("Сетка " + id); + scope.setApplyMode(applyMode); + scope.setDayOfWeek(dayOfWeek); + scope.setDisplayOrder(id.intValue()); + return scope; + } + + private TimeSlot timeSlot(Long id, TimeSlotScope scope, LocalTime startTime, LocalTime endTime) { + TimeSlot slot = new TimeSlot(); + slot.setId(id); + slot.setOrderNumber(1); + slot.setTimeSlotScope(scope); + slot.setStartTime(startTime); + slot.setEndTime(endTime); + slot.setDurationMinutes(90); + return slot; + } + + private void addRenderedLecture(ScheduleRule rule, TimeSlot timeSlot) { + User teacher = new User(); + teacher.setId(60L); + teacher.setUsername("teacher"); + teacher.setFullName("Тестовый преподаватель"); + teacher.setRole(Role.TEACHER); + + Classroom classroom = new Classroom(); + classroom.setId(70L); + classroom.setName("А-101"); + classroom.setIsAvailable(true); + + LessonType lessonType = new LessonType(); + lessonType.setId(80L); + lessonType.setLessonType("Лекция"); + + ScheduleRuleSlot slot = new ScheduleRuleSlot(); + slot.setId(90L); + slot.setScheduleRule(rule); + slot.setDayOfWeek(2); + slot.setParity(ScheduleParity.BOTH); + slot.setTimeSlot(timeSlot); + slot.setTeacher(teacher); + slot.setClassroom(classroom); + slot.setLessonType(lessonType); + slot.setLessonFormat("Очно"); + + rule.setLectureAcademicHours(2); + rule.setSlots(new HashSet<>(List.of(slot))); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java index b564073..6eff294 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java @@ -20,6 +20,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -67,7 +69,7 @@ class ScheduleQueryServiceTeacherOverrideTest { assertThat(sourceSchedule).isEmpty(); verify(overrideRepository, times(2)).findByLessonDateBetweenWithDetails(DATE, DATE); verify(groupRepository).findAll(); - verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE); + verifyBaseDayBatch(); } @Test @@ -85,7 +87,7 @@ class ScheduleQueryServiceTeacherOverrideTest { assertThat(result).isEmpty(); verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE); verify(groupRepository).findAll(); - verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE); + verifyBaseDayBatch(); } @Test @@ -109,7 +111,7 @@ class ScheduleQueryServiceTeacherOverrideTest { .containsExactly(101L, 102L); verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE); verify(groupRepository).findAll(); - verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE); + verifyBaseDayBatch(); } @Test @@ -128,7 +130,7 @@ class ScheduleQueryServiceTeacherOverrideTest { assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L)); verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE); verify(groupRepository).findAll(); - verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE); + verifyBaseDayBatch(); } @Test @@ -146,7 +148,7 @@ class ScheduleQueryServiceTeacherOverrideTest { 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)) + when(generatorService.buildScheduleForGroups(any(), eq(DATE), eq(DATE))) .thenReturn(List.of(cancelledLesson, movedLesson)); when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE)) .thenReturn(List.of(cancellation, move)); @@ -173,7 +175,15 @@ class ScheduleQueryServiceTeacherOverrideTest { 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); + when(generatorService.buildScheduleForGroups(any(), eq(DATE), eq(DATE))).thenReturn(lessons); + } + + private void verifyBaseDayBatch() { + verify(generatorService).buildScheduleForGroups( + argThat(groups -> groups.size() == 1 && groups.iterator().next().getId().equals(GROUP_ID)), + eq(DATE), + eq(DATE) + ); } private StudentGroup group() { diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java index 7fe126a..9873600 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java @@ -4,13 +4,18 @@ import com.magistr.app.model.StudentGroup; import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.ScheduleOverrideRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.time.LocalDate; import java.util.List; import java.util.stream.IntStream; +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.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -68,6 +73,40 @@ class ScheduleQueryServiceTest { verify(groupRepository, never()).findAll(); } + @ParameterizedTest + @ValueSource(ints = {51, 100}) + void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) { + ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class); + GroupRepository groupRepository = mock(GroupRepository.class); + ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class); + StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class); + LocalDate startDate = LocalDate.of(2026, 5, 1); + LocalDate endDate = LocalDate.of(2026, 5, 7); + when(groupRepository.findAll()) + .thenReturn(IntStream.rangeClosed(1, groupCount) + .mapToObj(this::group) + .toList()); + when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true); + when(generatorService.buildScheduleForGroups(any(), any(), any())).thenReturn(List.of()); + when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of()); + ScheduleQueryService service = new ScheduleQueryService( + generatorService, + groupRepository, + overrideRepository, + groupLifecycleService + ); + + var result = service.searchForAggregation(null, null, startDate, endDate); + + assertThat(result).isEmpty(); + verify(generatorService).buildScheduleForGroups( + argThat(groups -> groups.size() == groupCount), + eq(startDate), + eq(endDate) + ); + verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate); + } + private StudentGroup group(int id) { StudentGroup group = new StudentGroup(); group.setId((long) id); diff --git a/backend/src/test/java/com/magistr/app/service/SubjectImportServiceTest.java b/backend/src/test/java/com/magistr/app/service/SubjectImportServiceTest.java new file mode 100644 index 0000000..4c90856 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/SubjectImportServiceTest.java @@ -0,0 +1,110 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.CreateSubjectRequest; +import com.magistr.app.model.Subject; +import com.magistr.app.repository.SubjectRepository; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +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.anyList; +import static org.mockito.Mockito.*; + +class SubjectImportServiceTest { + + @Test + void rejectsSubjectOwnedByAnotherDepartmentWithoutMutation() { + SubjectRepository repository = mock(SubjectRepository.class); + Subject foreign = subject(10L, "Информатика", "INF", 2L); + when(repository.findByNameIgnoreCase("информатика")).thenReturn(Optional.of(foreign)); + SubjectImportService service = new SubjectImportService(repository); + + assertThatThrownBy(() -> service.importSubjects( + List.of(request("информатика", "NEW", 1L)), + 1L + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Дисциплина «информатика» уже принадлежит другой кафедре"); + + assertThat(foreign.getDepartmentId()).isEqualTo(2L); + assertThat(foreign.getCode()).isEqualTo("INF"); + verify(repository, never()).saveAllAndFlush(anyList()); + } + + @Test + void repeatedOwnImportUpdatesSameSubjectAndRestoresIt() { + SubjectRepository repository = mock(SubjectRepository.class); + Subject own = subject(10L, "Информатика", "OLD", 1L); + own.archive("Архив"); + when(repository.findByNameIgnoreCase("информатика")).thenReturn(Optional.of(own)); + when(repository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + SubjectImportService service = new SubjectImportService(repository); + + List firstResult = service.importSubjects( + List.of(request("информатика", " INF-2 ", 1L)), + 1L + ); + List repeatedResult = service.importSubjects( + List.of(request("информатика", "INF-2", 1L)), + 1L + ); + + assertThat(firstResult).containsExactly(own); + assertThat(repeatedResult).containsExactly(own); + assertThat(own.getId()).isEqualTo(10L); + assertThat(own.getDepartmentId()).isEqualTo(1L); + assertThat(own.getCode()).isEqualTo("INF-2"); + assertThat(own.isArchivedRecord()).isFalse(); + verify(repository, times(2)).saveAllAndFlush(List.of(own)); + } + + @Test + void duplicateNamesInsidePayloadAreImportedOnceCaseInsensitively() { + SubjectRepository repository = mock(SubjectRepository.class); + when(repository.findByNameIgnoreCase(anyString())).thenReturn(Optional.empty()); + when(repository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + SubjectImportService service = new SubjectImportService(repository); + + List result = service.importSubjects( + List.of( + request("Информатика", "OLD", 1L), + request(" информатика ", "NEW", 1L) + ), + 1L + ); + + assertThat(result).singleElement().satisfies(subject -> { + assertThat(subject.getName()).isEqualTo("информатика"); + assertThat(subject.getCode()).isEqualTo("NEW"); + assertThat(subject.getDepartmentId()).isEqualTo(1L); + }); + } + + @Test + void mapsConcurrentUniqueViolationToConflict() { + SubjectRepository repository = mock(SubjectRepository.class); + when(repository.findByNameIgnoreCase("Физика")).thenReturn(Optional.empty()); + when(repository.saveAllAndFlush(anyList())) + .thenThrow(new DataIntegrityViolationException("uq_subjects_name_ci")); + SubjectImportService service = new SubjectImportService(repository); + + assertThatThrownBy(() -> service.importSubjects( + List.of(request("Физика", "PHY", 1L)), + 1L + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Дисциплина с таким названием уже существует"); + } + + private CreateSubjectRequest request(String name, String code, Long departmentId) { + return new CreateSubjectRequest(null, name, code, departmentId); + } + + private Subject subject(Long id, String name, String code, Long departmentId) { + return new Subject(id, name, code, departmentId); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java b/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java new file mode 100644 index 0000000..69d529e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java @@ -0,0 +1,209 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.DepartmentTransferRequest; +import com.magistr.app.model.Department; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; +import com.magistr.app.model.TeacherDepartmentAssignment; +import com.magistr.app.model.User; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +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 java.time.LocalDate; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TeacherDepartmentServiceTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 7, 18); + + @Mock + private TeacherDepartmentAssignmentRepository assignmentRepository; + @Mock + private UserRepository userRepository; + @Mock + private DepartmentRepository departmentRepository; + + private TeacherDepartmentService service; + private Department oldDepartment; + private Department newDepartment; + private User teacher; + + @BeforeEach + void setUp() { + service = new TeacherDepartmentService( + assignmentRepository, + userRepository, + departmentRepository + ); + oldDepartment = new Department(1L, "Кафедра ИБ", 1L); + newDepartment = new Department(2L, "Кафедра ВТ", 2L); + teacher = teacher(); + } + + @Test + void futureTransferKeepsCurrentLegacyDepartmentUntilEffectiveDate() { + LocalDate validFrom = TODAY.plusMonths(1); + TeacherDepartmentAssignment current = assignment( + oldDepartment, + LocalDate.of(2025, 9, 1), + null, + true + ); + prepareTransfer(current); + + TeacherDepartmentAssignment saved = service.transferTeacher( + teacher.getId(), + new DepartmentTransferRequest(newDepartment.getId(), validFrom, "Будущий перевод"), + TODAY, + 99L + ); + + assertThat(current.getValidTo()).isEqualTo(validFrom.minusDays(1)); + assertThat(saved.getDepartment()).isEqualTo(newDepartment); + assertThat(saved.getValidFrom()).isEqualTo(validFrom); + assertThat(saved.getValidTo()).isNull(); + assertThat(teacher.getDepartmentId()).isEqualTo(oldDepartment.getId()); + verify(userRepository, never()).save(any()); + } + + @Test + void effectiveTransferSynchronizesLegacyDepartment() { + TeacherDepartmentAssignment current = assignment( + oldDepartment, + LocalDate.of(2025, 9, 1), + null, + true + ); + prepareTransfer(current); + + service.transferTeacher( + teacher.getId(), + new DepartmentTransferRequest(newDepartment.getId(), TODAY, null), + TODAY, + 99L + ); + + assertThat(current.getValidTo()).isEqualTo(TODAY.minusDays(1)); + assertThat(teacher.getDepartmentId()).isEqualTo(newDepartment.getId()); + verify(userRepository).save(teacher); + } + + @Test + void historicalDepartmentIncludesTeacherArchivedAfterTargetDate() { + teacher.setStatus(LifecycleEntity.STATUS_ARCHIVED); + teacher.setActiveTo(LocalDate.of(2026, 1, 31)); + TeacherDepartmentAssignment assignment = assignment( + oldDepartment, + LocalDate.of(2025, 9, 1), + null, + true + ); + when(assignmentRepository.findDepartmentTeachersAtDate( + oldDepartment.getId(), + LocalDate.of(2025, 12, 1) + )).thenReturn(List.of(assignment)); + when(assignmentRepository.findDepartmentTeachersAtDate( + oldDepartment.getId(), + LocalDate.of(2026, 2, 1) + )).thenReturn(List.of(assignment)); + + assertThat(service.findTeachersForDepartmentAtDate( + oldDepartment.getId(), + LocalDate.of(2025, 12, 1) + )).containsExactly(teacher); + assertThat(service.findTeachersForDepartmentAtDate( + oldDepartment.getId(), + LocalDate.of(2026, 2, 1) + )).isEmpty(); + } + + @Test + void primaryAssignmentWinsOverAdditionalAssignment() { + TeacherDepartmentAssignment additional = assignment( + newDepartment, + LocalDate.of(2025, 9, 1), + null, + false + ); + TeacherDepartmentAssignment primary = assignment( + oldDepartment, + LocalDate.of(2025, 9, 1), + null, + true + ); + + assertThat(service.resolveDepartmentIdAtDate( + List.of(additional, primary), + TODAY + )).contains(oldDepartment.getId()); + } + + @Test + void additionalAssignmentIsUsedWhenPrimaryIsOutsideTargetDate() { + TeacherDepartmentAssignment oldPrimary = assignment( + oldDepartment, + LocalDate.of(2025, 9, 1), + TODAY.minusDays(1), + true + ); + TeacherDepartmentAssignment additional = assignment( + newDepartment, + LocalDate.of(2025, 9, 1), + null, + false + ); + + assertThat(service.resolveDepartmentIdAtDate( + List.of(oldPrimary, additional), + TODAY + )).contains(newDepartment.getId()); + } + + private void prepareTransfer(TeacherDepartmentAssignment current) { + when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findByIdForUpdate(teacher.getId())).thenReturn(Optional.of(teacher)); + when(departmentRepository.findById(newDepartment.getId())) + .thenReturn(Optional.of(newDepartment)); + when(assignmentRepository.findPrimaryHistoryForUpdate(teacher.getId())) + .thenReturn(List.of(current)); + } + + private TeacherDepartmentAssignment assignment(Department department, + LocalDate validFrom, + LocalDate validTo, + boolean primary) { + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(teacher); + assignment.setDepartment(department); + assignment.setValidFrom(validFrom); + assignment.setValidTo(validTo); + assignment.setPrimaryAssignment(primary); + return assignment; + } + + private User teacher() { + User value = new User(); + value.setId(10L); + value.setUsername("teacher"); + value.setRole(Role.TEACHER); + value.setFullName("Петров Пётр Петрович"); + value.setJobTitle("Доцент"); + value.setDepartmentId(oldDepartment.getId()); + return value; + } +} diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java index 5885699..3c7a4f0 100644 --- a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java @@ -287,7 +287,7 @@ class TenantLifecyclePostgreSqlIntegrationTest { int changed = statement.executeUpdate(""" UPDATE flyway_schema_history SET checksum = checksum + 1 - WHERE version = '4' + WHERE version = '1' """); assertThat(changed).isEqualTo(1); } diff --git a/backend/src/test/java/com/magistr/app/service/TimeSlotServiceTest.java b/backend/src/test/java/com/magistr/app/service/TimeSlotServiceTest.java new file mode 100644 index 0000000..859ce1c --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/TimeSlotServiceTest.java @@ -0,0 +1,226 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.TimeSlotDto; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.model.TimeSlotScope; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.TimeSlotScopeRepository; +import org.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 java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TimeSlotServiceTest { + + private static final long DEFAULT_SCOPE_ID = 10L; + private static final long MANUAL_SCOPE_ID = 20L; + private static final long SLOT_ID = 30L; + + @Mock + private TimeSlotRepository timeSlotRepository; + @Mock + private TimeSlotScopeRepository timeSlotScopeRepository; + @Mock + private ScheduleRuleSlotRepository scheduleRuleSlotRepository; + + private TimeSlotService service; + private TimeSlotScope defaultScope; + private TimeSlotScope manualScope; + + @BeforeEach + void setUp() { + service = new TimeSlotService( + timeSlotRepository, + timeSlotScopeRepository, + scheduleRuleSlotRepository + ); + defaultScope = scope(DEFAULT_SCOPE_ID, "DEFAULT"); + manualScope = scope(MANUAL_SCOPE_ID, "MANUAL"); + when(timeSlotRepository.saveAndFlush(any(TimeSlot.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void calculatesDurationOnBackendAndIgnoresRequestValue() { + when(timeSlotScopeRepository.findByIdForUpdate(DEFAULT_SCOPE_ID)) + .thenReturn(Optional.of(defaultScope)); + + TimeSlot saved = service.create(request( + DEFAULT_SCOPE_ID, + 1, + LocalTime.of(8, 0), + LocalTime.of(9, 30), + 999 + )); + + assertThat(saved.getDurationMinutes()).isEqualTo(90); + ArgumentCaptor captor = ArgumentCaptor.forClass(TimeSlot.class); + verify(timeSlotRepository).saveAndFlush(captor.capture()); + assertThat(captor.getValue().getDurationMinutes()).isEqualTo(90); + } + + @Test + void rejectsOverlapWithinScope() { + when(timeSlotScopeRepository.findByIdForUpdate(DEFAULT_SCOPE_ID)) + .thenReturn(Optional.of(defaultScope)); + when(timeSlotRepository.existsOverlapping( + DEFAULT_SCOPE_ID, + LocalTime.of(9, 0), + LocalTime.of(10, 0) + )).thenReturn(true); + + assertThatThrownBy(() -> service.create(request( + DEFAULT_SCOPE_ID, + 2, + LocalTime.of(9, 0), + LocalTime.of(10, 0), + null + ))) + .isInstanceOf(ScheduleConflictException.class) + .hasMessage("Номер пары или интервал времени уже занят в выбранной сетке"); + + verify(timeSlotRepository, never()).saveAndFlush(any()); + } + + @Test + void allowsAdjacentIntervals() { + when(timeSlotScopeRepository.findByIdForUpdate(DEFAULT_SCOPE_ID)) + .thenReturn(Optional.of(defaultScope)); + when(timeSlotRepository.existsOverlapping( + DEFAULT_SCOPE_ID, + LocalTime.of(9, 30), + LocalTime.of(11, 0) + )).thenReturn(false); + + TimeSlot saved = service.create(request( + DEFAULT_SCOPE_ID, + 2, + LocalTime.of(9, 30), + LocalTime.of(11, 0), + null + )); + + assertThat(saved.getStartTime()).isEqualTo(LocalTime.of(9, 30)); + assertThat(saved.getDurationMinutes()).isEqualTo(90); + } + + @Test + void rejectsMovingUsedBaseSlotToManualScope() { + TimeSlot existing = slot(SLOT_ID, defaultScope); + when(timeSlotRepository.findByIdForUpdate(SLOT_ID)).thenReturn(Optional.of(existing)); + when(timeSlotScopeRepository.findByIdForUpdate(DEFAULT_SCOPE_ID)) + .thenReturn(Optional.of(defaultScope)); + when(timeSlotScopeRepository.findByIdForUpdate(MANUAL_SCOPE_ID)) + .thenReturn(Optional.of(manualScope)); + when(scheduleRuleSlotRepository.existsByTimeSlotId(SLOT_ID)).thenReturn(true); + + assertThatThrownBy(() -> service.update(SLOT_ID, request( + MANUAL_SCOPE_ID, + 1, + LocalTime.of(8, 0), + LocalTime.of(9, 30), + null + ))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Слот базовой сетки, используемый в правилах расписания, нельзя перенести в другую сетку"); + + verify(timeSlotRepository, never()).saveAndFlush(any()); + } + + @Test + void calculatesDurationAgainOnUpdate() { + TimeSlot existing = slot(SLOT_ID, defaultScope); + when(timeSlotRepository.findByIdForUpdate(SLOT_ID)).thenReturn(Optional.of(existing)); + when(timeSlotScopeRepository.findByIdForUpdate(DEFAULT_SCOPE_ID)) + .thenReturn(Optional.of(defaultScope)); + + TimeSlot saved = service.update(SLOT_ID, request( + DEFAULT_SCOPE_ID, + 1, + LocalTime.of(8, 15), + LocalTime.of(9, 45), + 5 + )); + + assertThat(saved.getDurationMinutes()).isEqualTo(90); + assertThat(saved.getStartTime()).isEqualTo(LocalTime.of(8, 15)); + } + + @Test + void rejectsInvalidOrSubMinuteRangeBeforeLocking() { + assertThatThrownBy(() -> service.create(request( + DEFAULT_SCOPE_ID, + 1, + LocalTime.of(10, 0), + LocalTime.of(10, 0), + null + ))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Время начала должно быть раньше времени окончания"); + + assertThatThrownBy(() -> service.create(request( + DEFAULT_SCOPE_ID, + 1, + LocalTime.of(10, 0, 0), + LocalTime.of(10, 0, 30), + null + ))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Длительность временного слота должна быть не меньше одной минуты"); + + verify(timeSlotScopeRepository, never()).findByIdForUpdate(any()); + } + + private TimeSlotDto request(Long scopeId, + Integer orderNumber, + LocalTime startTime, + LocalTime endTime, + Integer durationMinutes) { + return new TimeSlotDto( + null, + orderNumber, + scopeId, + null, + null, + null, + startTime, + endTime, + durationMinutes + ); + } + + private TimeSlotScope scope(Long id, String applyMode) { + TimeSlotScope scope = new TimeSlotScope(); + scope.setId(id); + scope.setApplyMode(applyMode); + return scope; + } + + private TimeSlot slot(Long id, TimeSlotScope scope) { + TimeSlot slot = new TimeSlot(); + slot.setId(id); + slot.setTimeSlotScope(scope); + slot.setOrderNumber(1); + slot.setStartTime(LocalTime.of(8, 0)); + slot.setEndTime(LocalTime.of(9, 30)); + slot.setDurationMinutes(90); + return slot; + } +} diff --git a/compose.yaml b/compose.yaml index 05cf62d..8f263ce 100755 --- a/compose.yaml +++ b/compose.yaml @@ -1,36 +1,67 @@ services: backend: - container_name: backend - restart: always + restart: unless-stopped build: context: ./backend dockerfile: Dockerfile environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-app_db} + POSTGRES_USER: ${POSTGRES_USER:-myuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET должен быть задан в .env} + JWT_ACCESS_TOKEN_TTL: ${JWT_ACCESS_TOKEN_TTL:-15m} + JWT_REFRESH_TOKEN_TTL: ${JWT_REFRESH_TOKEN_TTL:-7d} + JWT_REFRESH_COOKIE_NAME: ${JWT_REFRESH_COOKIE_NAME:-magistr_refresh} + JWT_REFRESH_COOKIE_SECURE: ${JWT_REFRESH_COOKIE_SECURE:-false} + JWT_REFRESH_CLEANUP_ENABLED: ${JWT_REFRESH_CLEANUP_ENABLED:-true} + JWT_REFRESH_CLEANUP_RETENTION: ${JWT_REFRESH_CLEANUP_RETENTION:-30d} + JWT_REFRESH_CLEANUP_BATCH_SIZE: ${JWT_REFRESH_CLEANUP_BATCH_SIZE:-500} + JWT_REFRESH_CLEANUP_MAX_BATCHES: ${JWT_REFRESH_CLEANUP_MAX_BATCHES:-20} + JWT_REFRESH_CLEANUP_INTERVAL_MS: ${JWT_REFRESH_CLEANUP_INTERVAL_MS:-3600000} + JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS: ${JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS:-60000} + LOGIN_RATE_MAX_FAILURES: ${LOGIN_RATE_MAX_FAILURES:-5} + LOGIN_RATE_ATTEMPT_WINDOW: ${LOGIN_RATE_ATTEMPT_WINDOW:-15m} + LOGIN_RATE_BASE_BLOCK_DURATION: ${LOGIN_RATE_BASE_BLOCK_DURATION:-1m} + LOGIN_RATE_MAX_BLOCK_DURATION: ${LOGIN_RATE_MAX_BLOCK_DURATION:-15m} + LOGIN_AUDIT_CLEANUP_ENABLED: ${LOGIN_AUDIT_CLEANUP_ENABLED:-true} + LOGIN_AUDIT_RETENTION: ${LOGIN_AUDIT_RETENTION:-90d} + LOGIN_AUDIT_CLEANUP_BATCH_SIZE: ${LOGIN_AUDIT_CLEANUP_BATCH_SIZE:-500} + LOGIN_AUDIT_CLEANUP_MAX_BATCHES: ${LOGIN_AUDIT_CLEANUP_MAX_BATCHES:-20} + LOGIN_AUDIT_CLEANUP_INTERVAL_MS: ${LOGIN_AUDIT_CLEANUP_INTERVAL_MS:-86400000} + LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS: ${LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS:-120000} + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-172.16.0.0/12} + BUSINESS_TIME_ZONE: ${BUSINESS_TIME_ZONE:-Europe/Moscow} + TZ: ${TZ:-Europe/Moscow} + JAVA_TOOL_OPTIONS: -Duser.timezone=${TZ:-Europe/Moscow} + OTEL_SDK_DISABLED: ${OTEL_SDK_DISABLED:-true} depends_on: db: condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "wget --quiet --spider http://127.0.0.1:8080/actuator/health/liveness" ] + interval: 5s + timeout: 3s + retries: 12 + start_period: 20s networks: - - proxy + - magistr frontend: - container_name: frontend - restart: always + restart: unless-stopped build: context: ./frontend dockerfile: Dockerfile + ports: + - "${HTTP_PORT:-80}:80" networks: - - proxy + - magistr depends_on: - - backend + backend: + condition: service_healthy db: - image: postgres:alpine3.23 - container_name: db - restart: always - ports: - - "5432:5432" + image: postgres:16.3-alpine3.20 + restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-myuser} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env} @@ -41,8 +72,13 @@ services: timeout: 5s retries: 5 networks: - - proxy + - magistr + volumes: + - postgres_data:/var/lib/postgresql/data networks: - proxy: - external: true + magistr: + driver: bridge + +volumes: + postgres_data: diff --git a/docs/API.md b/docs/API.md index a9d5b2a..d19efdd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,7 +3,8 @@ Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны под `/actuator/health/`. Ответы возвращаются в формате JSON. -Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат: +Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404`, +`409` и `500` используется общий JSON-формат: ```json { @@ -17,6 +18,11 @@ Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`. +Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK +возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским +сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не +передаются; неизвестное нарушение получает обобщённое сообщение. + --- ## Служебные проверки состояния @@ -108,7 +114,36 @@ } ``` +Для несуществующего пользователя, неверного пароля и архивной учётной записи возвращается +одинаковый ответ `401`, поэтому endpoint не раскрывает наличие или состояние пользователя. + +**Временная блокировка (429):** + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 60 +``` + +```json +{ + "success": false, + "message": "Слишком много попыток входа. Повторите позже", + "token": null, + "role": null, + "redirect": null, + "departmentId": null, + "userId": null +} +``` + +Backend ведёт общий для всех pod счётчик по tenant, нормализованному `username` и IP. +После пяти отказов по умолчанию комбинация блокируется на 60 секунд; следующие отказы +после окончания блокировки увеличивают задержку до максимума 15 минут. `Retry-After` +содержит оставшееся целое число секунд. Успешный вход сбрасывает счётчик. + > После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer `. +> Штатный web-клиент хранит access JWT только в памяти страницы; после reload он получает +> новый access JWT через `HttpOnly` refresh-cookie и `POST /api/auth/refresh`. Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. @@ -198,7 +233,9 @@ Refresh-токен ротируется при каждом успешном о Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`. -Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру. +Выборка использует записи `teacher_department_assignments`, действующие на текущую дату. +Дополнительная связь (`is_primary=false`) также включает преподавателя в список кафедры. +Роль `DEPARTMENT` может запрашивать только свою кафедру. ### `POST /api/users` @@ -247,9 +284,16 @@ Refresh-токен ротируется при каждом успешном о } ``` +Если `validFrom` находится в будущем, текущая основная кафедра остаётся действующей до дня, +предшествующего переводу. Поле совместимости `users.department_id` переключается только +когда новая основная запись уже действует на текущую дату. Пересекающиеся периоды двух +основных кафедр одного преподавателя отклоняются с `409 Conflict`. + ### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01` -Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру. +Список преподавателей кафедры на конкретную дату по таблице истории назначений. Архивный +преподаватель входит в исторический ответ, если на указанную дату действовали и пользователь, +и его назначение. Роль `DEPARTMENT` может запрашивать только свою кафедру. --- @@ -294,7 +338,7 @@ Refresh-токен ротируется при каждом успешном о - `DEPARTMENT` не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API; - `DEPARTMENT` создаёт и комментирует дисциплины через `/api/department/*`, где кафедра берётся из текущего пользователя; -- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только преподавателей и дисциплины своей кафедры; +- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только дисциплины своей кафедры и преподавателей с основной или дополнительной связью с ней на текущую дату; - `SCHEDULE_VIEWER` имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности. --- @@ -386,11 +430,18 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "orderNumber": 1, "scopeId": 1, "startTime": "08:00:00", - "endTime": "09:30:00", - "durationMinutes": 90 + "endTime": "09:30:00" } ``` +`durationMinutes` в запросе не требуется и не считается доверенным значением: backend +всегда вычисляет длительность как разницу `endTime - startTime` и возвращает результат в +ответе. Начало должно быть раньше окончания, а длительность — не меньше одной минуты. +Внутри одной сетки запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы; +соседние слоты, у которых окончание первого совпадает с началом второго, разрешены. +Конфликт номера или интервала возвращает `409 Conflict`. Слот, на который уже ссылается +правило расписания, нельзя перенести из базовой сетки `DEFAULT`. + **Создание ручной сетки:** ```json { @@ -424,6 +475,31 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | `PUT` | `/api/admin/calendar/activity-types/{id}` | Обновить код активности | | `DELETE` | `/api/admin/calendar/activity-types/{id}` | Удалить код активности | +**Тело учебного года:** +```json +{ + "title": "2026-2027", + "startDate": "2026-09-01", + "endDate": "2027-06-30" +} +``` + +**Тело семестра:** +```json +{ + "semesterType": "autumn", + "startDate": "2026-09-01", + "endDate": "2027-01-31" +} +``` + +Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного +года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий +период разрешено начать на следующий календарный день после окончания предыдущего. +Повторное название года, повторный тип семестра или пересечение возвращаются как +`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`. +При сужении учебного года все существующие семестры должны оставаться внутри новых границ. + **Тело создания/обновления кода активности:** ```json { @@ -465,6 +541,13 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `courseCount` должен быть в диапазоне `1..8`. +При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет +все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, +профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает +`409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить +`courseCount`, пока существуют строки старших курсов или дисциплины семестров выше +`courseCount * 2`. + **Ячейка сетки графика:** ```json { @@ -632,6 +715,10 @@ CRUD доступен по: разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру. +Ограничение относится только к интерактивному `/api/schedule/search`. Endpoints +`/api/workload/*` используют отдельный агрегирующий путь и обрабатывают все активные группы +разрешённого кафедрального scope, в том числе при количестве больше 50. + Пример: ```http @@ -691,6 +778,21 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27 Общие параметры для отчётов: `startDate`, `endDate`, опционально `departmentId`. +Роли `ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER` могут не передавать `departmentId` +для глобального отчёта или выбрать конкретную кафедру. Для роли `DEPARTMENT` backend всегда +использует кафедру из access JWT: отсутствие параметра означает свою кафедру, а попытка +передать чужой `departmentId` возвращает `403 Forbidden`. То же ограничение применяется к +`GET /api/workload/free-classrooms`, хотя этот endpoint не принимает `departmentId`. + +Кафедра каждой записи определяется по `teacher_department_assignments` на дату занятия: +сначала используется основное, затем дополнительное назначение. Поэтому период, включающий +дату перевода, разделяет нагрузку одного преподавателя между прежней и новой кафедрами, +а историческая нагрузка не зависит от текущего значения `users.department_id`. + +Workload и поиск свободных аудиторий строятся через специализированный агрегирующий метод +`ScheduleQueryService`: он сохраняет проверку диапазона, применение overrides, фильтр пары и +дедупликацию, но не наследует UI-лимит 50 групп из `/api/schedule/search`. + Пример: ```http @@ -727,7 +829,15 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 | `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя | | `GET` | `/api/department/schedule` | Расписание кафедры | -`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений. +`POST /api/department/subjects/import` нормализует пробелы по краям и сравнивает названия +без учёта регистра. Повторный импорт дисциплины своей кафедры обновляет код и восстанавливает +архивную запись, не создавая новый ID. Если такое название уже принадлежит другой кафедре, +API возвращает `409 Conflict`; владелец записи не изменяется. Повторы одного названия внутри +payload обрабатываются один раз, используется последнее значение. + +`GET /api/department/teachers` возвращает актуальных преподавателей кафедры только по +`teacher_department_assignments`. Основные и дополнительные назначения учитываются на +текущую дату; архивные и ещё не начавшиеся назначения исключаются. `POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`. @@ -793,6 +903,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 При создании специальности автоматически создаётся профиль `Без профиля`. +`EDUCATION_OFFICE` имеет read-only доступ к `GET /api/specialties`, +`GET /api/specialties/profiles` и `GET /api/specialties/{id}/profiles`, необходимый для +инициализации календарных учебных графиков. Создание, изменение, архивирование специальностей +и CRUD профилей остаются доступны только `ADMIN`. + **Тело создания/обновления профиля:** ```json { @@ -880,6 +995,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 } ``` +Если у группы уже есть календарные назначения, изменение года начала обучения, +специальности, профиля или формы обучения повторно проверяется для каждого учебного года. +Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без +изменений. + ### `DELETE /api/groups/{id}` Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой. @@ -963,7 +1083,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 } ``` -Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. +Назначаемый график должен относиться к тому же учебному году, специальности, профилю и +форме обучения, что и группа. Вычисленный курс группы должен находиться в диапазоне +`1..courseCount`. Сохранение выполняется транзакционно с блокировкой группы, графика, +учебного года и существующего назначения; конкурентный конфликт возвращает `409 Conflict`. --- @@ -1074,6 +1197,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 ## Формы обучения +`GET /api/education-forms` доступен `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT` и +`SCHEDULE_VIEWER`. Создание `POST /api/education-forms` и удаление +`DELETE /api/education-forms/{id}` разрешены `ADMIN` и `EDUCATION_OFFICE`; удаление по-прежнему +отклоняется, если форма используется группой или календарным графиком. + ### `GET /api/education-forms` Список форм обучения. @@ -1125,6 +1253,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 } ``` +Для роли `DEPARTMENT` дисциплина должна принадлежать кафедре текущего пользователя, а у +преподавателя на текущую дату должна действовать основная или дополнительная связь с этой +кафедрой. + ### `DELETE /api/teacher-subjects` ```json diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 400e350..8443647 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ ```mermaid graph TD Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"] - Caddy -->|:80| Frontend["Frontend
(Apache httpd:alpine)"] + Caddy -->|:80| Frontend["Frontend
(Apache httpd + строгий CSP)"] Caddy -->|/api/*| Backend["Backend
(Spring Boot 3.2.5)"] Backend --> TenantRouter{"TenantRoutingDataSource"} @@ -19,12 +19,13 @@ graph TD ## Компоненты -### Frontend (Apache httpd:alpine) +### Frontend (Apache httpd) - **Тип:** Статические файлы (HTML/CSS/JS) -- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server +- **Контейнер:** multi-stage Node/esbuild → Apache HTTP Server на Alpine - **Порт:** 80 - **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/` - **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`) +- **Browser security:** same-origin runtime-ресурсы и CSP без `unsafe-inline`/`unsafe-eval` ### Backend (Spring Boot 3.2.5) - **Тип:** REST API сервер @@ -164,6 +165,11 @@ sequenceDiagram из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же ограничения компенсации по `resourceVersion`. +Пользовательские ошибки tenant-контура и production-сообщения журнала формулируются на +русском языке. JDBC/Flyway-текст не возвращается в DOM или HTTP-ответ; на уровнях +`WARN`/`ERROR` фиксируется безопасный тип исключения, а стек доступен только в русскоязычной +записи уровня `DEBUG`. + Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса. Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный @@ -241,11 +247,62 @@ Tenant interceptor исключает `/actuator/**`, а authorization intercept ## Транзакционные изменения расписания +`GlobalExceptionHandler` является последней границей между ошибками persistence-слоя и +HTTP-клиентом. `DataIntegrityViolationException` сопоставляется с известными ограничениями +V1 и безопасными статусами `400`/`409`; неизвестные SQLState и constraints получают +обобщённый `409`. Constraint и SQLState доступны только в структурированном журнале, а +JDBC/SQL-текст и stack trace не включаются в пользовательский ответ. Контроллеры не должны +формировать HTTP-body из `Exception.getMessage()`. + `ScheduleRuleAdminController` является тонким HTTP-адаптером. Чтение, создание, изменение и архивация правил выполняются через `ScheduleRuleService`; публичные write-методы сервиса образуют транзакционные границы, поэтому ошибки валидации и конфликты выходят за Spring proxy и приводят к rollback. +`AcademicCalendarAdminController` делегирует CRUD учебных годов и семестров +`AcademicPeriodService`. Сервис нормализует названия, проверяет включительные диапазоны, +дубли и пересечения до мутации. Semester create/update блокируют родительский учебный год, +update семестра затем перечитывает собственную строку с `PESSIMISTIC_WRITE`. Кэш расписания +очищается только после commit. Межподовые гонки годов и семестров окончательно закрывают +GiST exclusion constraints и триггеры PostgreSQL, связывающие границы семестра с годом. + +`AcademicCalendarController` и `GroupController` делегируют изменение ключевых измерений и +сохранение назначений `AcademicStructureService`. Сервис блокирует изменяемую группу или +график, повторно проверяет назначения, курсы, сетку и дисциплины до мутации, а кэш очищает +только после commit. Триггеры V1 дублируют совместимость на уровне PostgreSQL и блокируют +ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт +устаревшее назначение. + +`TeacherDepartmentService` является единым источником датированных решений о кафедре +преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты +нагрузки читают `teacher_department_assignments` на целевую дату, не используют +`users.department_id` как fallback и учитывают дополнительные назначения. При построении +нагрузки назначения загружаются одним batch-запросом на весь диапазон, а кафедра разрешается +для даты каждого занятия, поэтому перевод внутри периода корректно разделяет агрегаты. + +Перевод блокирует пользователя и историю его основных назначений. Будущий перевод закрывает +текущий период днём перед датой вступления в силу, но не меняет legacy-зеркало текущей +кафедры заранее. GiST exclusion constraint в V1 окончательно запрещает пересекающиеся +основные периоды при гонке нескольких backend-pod. + +`WorkloadController` до обращения к `ScheduleQueryService` вычисляет разрешённый scope из +`AuthContext`. Для `DEPARTMENT` обязательна кафедра из подписанного JWT; отсутствие +параметра не расширяет выборку, а несовпадающий `departmentId` отклоняется с `403`. Это же +правило применяется к свободным аудиториям. Глобальный или явно выбранный scope остаётся у +`ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`. + +После проверки scope контроллер вызывает специализированный +`ScheduleQueryService.searchForAggregation()`. Этот путь загружает все активные группы +scope, генерирует их расписание и применяет единый снимок overrides без интерактивного +лимита 50 групп. Публичный `search()` по-прежнему применяет лимит к широкому UI-запросу; +общие range validation, фильтрация и дедупликация находятся в одном внутреннем алгоритме. + +Импорт дисциплин из `DepartmentWorkspaceController` делегирован транзакционному +`SubjectImportService`. Сервис сначала нормализует и дедуплицирует весь payload, затем до +мутации проверяет глобального владельца названия. Собственная запись обновляется на месте и +при необходимости восстанавливается; чужая приводит к `409`. Case-insensitive уникальный +индекс V1 окончательно закрывает конкурентное создание одинакового названия. + Создание правила до остальных запросов к БД захватывает `PESSIMISTIC_WRITE` на строке целевого семестра. Update сначала блокирует строку правила, затем старый и новый семестры в порядке ID; архивация блокирует правило и его семестр. После блокировок сервис заново @@ -271,6 +328,14 @@ rollback. Инвалидация кэша зарегистрирована че строка перечитывается с `PESSIMISTIC_WRITE`, поэтому параллельное изменение не приводит к lost update или проверке устаревшего снимка. +Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов. +`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`. +`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор +назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона. +`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup +по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время +одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует. + Teacher-only чтение строит базовое расписание напрямую через `ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой @@ -287,17 +352,51 @@ Teacher-only чтение строит базовое расписание на 1. Клиент отправляет `POST /api/auth/login` с `username` и `password`. 2. Backend проверяет пароль через `BCryptPasswordEncoder`. 3. При успехе возвращается access JWT для заголовка `Authorization: Bearer ` и устанавливается `HttpOnly` refresh-cookie. -4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш. +4. Access JWT и профиль клиента хранятся только в памяти страницы; refresh-токен хранится + только в cookie, а в БД сохраняется SHA-256 хэш. 5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается. 6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie. +`LoginRateLimitService` выполняет проверку пароля и изменение счётчика в одной транзакции. +Строка `auth_login_rate_limits` с ключом tenant + нормализованный username + IP блокируется +через `PESSIMISTIC_WRITE`, поэтому параллельные попытки на разных backend-pod сериализуются +общей tenant-БД. После порога применяется прогрессивная временная блокировка, API отвечает +`429` и передаёт `Retry-After`. Неизвестная, архивная и ошибочная учётная запись проходят +одинаковый bcrypt-путь и получают одинаковый `401`, что не позволяет определить наличие +пользователя по ответу. Отказы сохраняются в `auth_login_attempt_audit` и пишутся в журнал +только с коротким fingerprint имени; пароль не сохраняется и не логируется. + +`ClientIpResolver` принимает `X-Forwarded-For` только если непосредственный источник входит +в `TRUSTED_PROXY_CIDRS`. Цепочка разбирается справа налево до первого недоверенного адреса; +заголовок от прямого клиента или некорректная цепочка игнорируются. Для Compose доверена +только внутренняя Docker-сеть, а production ConfigMap задаёт pod-сеть Traefik. При изменении +Docker/K3s CIDR это значение необходимо синхронно заменить фактической сетью proxy. + +`LoginAttemptAuditCleanupJob` раз в сутки удаляет старше 90 дней audit-записи и неактивные +счётчики ограниченными пачками отдельно в каждой tenant-БД. `FOR UPDATE SKIP LOCKED` +позволяет нескольким pod безопасно выполнять очистку одновременно; активная блокировка +никогда не удаляется. + Ротация 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`, если роли недостаточно. +`RefreshTokenCleanupJob` раз в час берёт атомарный снимок активных тенантов из +`TenantRoutingDataSource`, устанавливает `TenantContext` до открытия транзакции и очищает +каждую tenant-БД ограниченными пачками. По умолчанию истёкшие и отозванные строки хранятся +30 дней для аудита; активные и более свежие строки не удаляются. PostgreSQL +`FOR UPDATE SKIP LOCKED` позволяет нескольким backend-pod разбирать непересекающиеся пачки, +а повторный проход идемпотентен. Внутри одного pod наложение запусков запрещено локальным +guard, а число пачек одного прохода ограничено. + +Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, +`exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение +`tenant` с `TenantContext`, затем применяет `@RequireRoles`. После перехода или reload +frontend восстанавливает access JWT и профиль в памяти через refresh-cookie; Web Storage +для данных авторизации не используется. Backend возвращает `401`, если токен отсутствует +или некорректен, и `403`, если роли недостаточно. `AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает `ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной @@ -305,6 +404,12 @@ Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `de или публичный endpoint не может получить пользователя от предыдущего запроса того же потока контейнера. +Frontend-матрица вкладок централизована в `admin/js/role-capabilities.js` и используется +основным admin SPA и settings SPA. Для `EDUCATION_OFFICE` backend разрешает read-only GET +специальностей/профилей и GET/POST/DELETE форм обучения; write-операции специальностей +наследуют class-level `ADMIN`. MockMvc role-тест проходит через реальный +`AuthorizationInterceptor`, поэтому видимые штатные экраны не зависят только от скрытия UI. + `TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене. JWT-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 9130f65..2dbe1ca 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -7,7 +7,7 @@ | Роль | Enum | Возможности | |------|------|------------| | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | -| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | +| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, учебные периоды и календарные графики, временные слоты, формы обучения, аудитории и загруженность; read-only справочники специальностей и профилей. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. | | **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | @@ -27,9 +27,17 @@ Bearer-токен проверяется на backend. Frontend-скрытие - загрузка и комментарии дисциплин идут через `/api/department/*`; - общий `/api/subjects` доступен кафедре только на чтение; -- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя; +- привязки `/api/teacher-subjects` разрешены только если дисциплина принадлежит кафедре текущего пользователя, а у преподавателя на текущую дату действует основная или дополнительная связь с ней; - добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой; - заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки. +- все `/api/workload/*`, включая свободные аудитории, принудительно используют кафедру из `AuthContext`; запрос с чужим `departmentId` отклоняется с `403`. + +Глобальный workload и произвольный фильтр кафедры разрешены ролям `ADMIN`, +`EDUCATION_OFFICE` и `SCHEDULE_VIEWER`. + +Ролевая матрица учебного отдела согласована с видимыми экранами: календарный график может +читать специальности и профили, а раздел настроек форм обучения выполняет GET/POST/DELETE. +Изменение самих специальностей и профилей остаётся административной операцией. --- @@ -64,6 +72,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график - **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика - **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным. +- **Целостность назначения:** год, специальность, профиль и форма графика должны совпадать с группой, а вычисленный курс должен входить в `1..course_count`. После назначения несовместимое изменение группы или графика отклоняется целиком с `409 Conflict`; назначение не удаляется и не становится устаревшим. +- **Размерность графика:** уменьшение `course_count` запрещено, пока существуют строки сетки старших курсов или дисциплины семестров выше `course_count * 2`. Даты сетки всегда находятся внутри учебного года графика. ### Аудитории (Classrooms) @@ -81,9 +91,11 @@ Bearer-токен проверяется на backend. Frontend-скрытие ### Дисциплины (Subjects) -- **Поля:** Название (уникальное), код, кафедра, описание +- **Поля:** Название (глобально уникальное без учёта регистра), код, кафедра, описание - Привязка преподавателей через `teacher_subjects` (Many-to-Many) - Кафедра может добавлять комментарии к дисциплине и загружать список дисциплин через кабинет кафедры +- Повторный импорт собственной дисциплины обновляет ту же запись и восстанавливает её из архива +- Совпадение названия с дисциплиной другой кафедры даёт `409 Conflict` и никогда не меняет владельца ### Жизненный цикл справочников @@ -98,18 +110,24 @@ Bearer-токен проверяется на backend. Frontend-скрытие ### Кафедральные связи преподавателей -Основная кафедра преподавателя хранится в `users.department_id` для совместимости, а актуальные и исторические связи фиксируются в `teacher_department_assignments`. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся основной, дополнительные связи создаются как неосновные. +Актуальные и исторические связи преподавателя с кафедрами определяются только по +`teacher_department_assignments`. Поле `users.department_id` сохраняется как legacy-зеркало +основной кафедры на текущую дату и не используется для бизнес-решений или исторических +отчётов. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся +основной, дополнительные связи создаются как неосновные. Правила: -- у преподавателя должна быть одна открытая основная кафедра; +- периоды двух основных кафедр одного преподавателя не могут пересекаться; - преподаватель может иметь несколько открытых неосновных кафедр; - одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи; -- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`; +- при переводе старая запись закрывается днём перед `valid_from`, новая начинается с `valid_from`; +- будущий перевод не меняет текущую принадлежность и legacy-зеркало до даты вступления в силу; - кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры; - кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль; - администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`; -- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой. +- исторические списки включают архивного преподавателя, если он и назначение действовали на целевую дату; +- нагрузка определяется для каждой даты занятия отдельно и разделяется между кафедрами при переводе внутри отчётного периода. --- @@ -135,6 +153,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие | `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | | `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар | +Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы +считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не +могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного +года; сужение года, исключающее существующий семестр, отклоняется. Создание и изменение +семестра блокируют строку родительского года, а PostgreSQL exclusion constraints разрешают +глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений +поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк. + Полная замена `academic_calendar_days` выполняется через транзакционный `AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню, @@ -155,6 +181,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. 11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах. +Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения +календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки +звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск +семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только +по lookup-картам. `ScheduleQueryService` передаёт все группы в один +`buildScheduleForGroups()`, поэтому число запросов не растёт как +`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются. + Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп. В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра. @@ -167,6 +201,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие единый снимок изменений и затем фильтрует итогового преподавателя. При отсутствии таких замен полный список групп не загружается. +Отчёты workload и свободные аудитории не являются интерактивным поиском и используют +отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без +лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию +и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита +широкого `/api/schedule/search` остаётся прежней. + Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. @@ -175,8 +215,19 @@ Bearer-токен проверяется на backend. Frontend-скрытие Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. Ручное назначение выполняется из ячейки редактора календарного графика, потому что оно относится к конкретной учебной дате. +Создание и изменение слота выполняет транзакционный `TimeSlotService`. Время начала должно +быть раньше окончания, продолжительность вычисляется только backend, а внутри одной сетки +запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы. Поэтому соседние +интервалы, например `08:00–09:30` и `09:30–11:00`, допустимы. Операции блокируют строки +затронутых сеток в стабильном порядке; exclusion constraint PostgreSQL дополнительно +защищает инвариант между экземплярами backend и при прямых конкурентных записях. + Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание. +Правила расписания могут ссылаться только на слоты сетки `DEFAULT`. Используемый базовый +слот нельзя перенести в `WEEKDAY` или `MANUAL`, а сетку с используемыми слотами нельзя +сделать небазовой. Эти условия проверяются сервисом и транзакционными триггерами БД. + При миграции одинаковые стартовые слоты создаются для базовой и субботней сетки: | № | Время | diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 56421de..c841e6a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -2,10 +2,10 @@ ## Общая информация -- **СУБД:** PostgreSQL (локально `postgres:alpine3.23`, продакшн — managed PostgreSQL) +- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine3.20`, продакшн — managed PostgreSQL) - **Управление схемой:** Flyway (программный запуск) - **Hibernate DDL:** Отключён (`ddl-auto=none`) -- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей) +- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов) - **Мультитенантность:** Каждый тенант = отдельная БД --- @@ -59,6 +59,28 @@ erDiagram TIMESTAMP revoked_at VARCHAR rotated_to_token_hash } + + auth_login_rate_limits { + BIGSERIAL id PK + VARCHAR tenant UK + VARCHAR username_normalized UK + VARCHAR client_ip UK + INTEGER failure_count + TIMESTAMP window_started_at + TIMESTAMP last_failure_at + TIMESTAMP blocked_until + TIMESTAMP updated_at + } + + auth_login_attempt_audit { + BIGSERIAL id PK + VARCHAR tenant + VARCHAR username_normalized + VARCHAR client_ip + VARCHAR outcome + TIMESTAMP occurred_at + INTEGER retry_after_seconds + } education_forms { BIGSERIAL id PK @@ -432,7 +454,47 @@ erDiagram | `user_agent` | VARCHAR(512) | User-Agent клиента | | `ip_address` | VARCHAR(64) | IP-адрес клиента | -Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie. +Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый +refresh-токен отзывается, а клиент получает новый refresh-cookie. Фоновая tenant-aware +очистка удаляет только строки, чьи `expires_at` или `revoked_at` старше настраиваемого срока +audit retention (по умолчанию 30 дней). Индексы `idx_auth_refresh_tokens_cleanup_expires` и +`idx_auth_refresh_tokens_cleanup_revoked` обслуживают ограниченные batch-delete из V1. + +#### `auth_login_rate_limits` — Общие счётчики попыток входа + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID состояния rate limit | +| `tenant` | VARCHAR(100) | Тенант запроса | +| `username_normalized` | VARCHAR(100) | NFKC-нормализованное имя в нижнем регистре | +| `client_ip` | VARCHAR(64) | Проверенный IP клиента | +| `failure_count` | INTEGER | Число отказов в текущем окне, не меньше нуля | +| `window_started_at` | TIMESTAMP | Начало окна учёта попыток | +| `last_failure_at` | TIMESTAMP | Время последнего отказа | +| `blocked_until` | TIMESTAMP | Окончание временной блокировки либо `NULL` | +| `created_at` | TIMESTAMP | Время создания состояния | +| `updated_at` | TIMESTAMP | Последнее изменение состояния | + +Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля +backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её +`FOR UPDATE`; одна tenant-БД поэтому является общим атомарным хранилищем для всех pod. +Индексы по `blocked_until` и `updated_at` обслуживают проверку и очистку. + +#### `auth_login_attempt_audit` — Аудит неудачных входов + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID события | +| `tenant` | VARCHAR(100) | Тенант запроса | +| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса | +| `client_ip` | VARCHAR(64) | Проверенный IP клиента | +| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` | +| `occurred_at` | TIMESTAMP | Время события | +| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` | + +Таблица принципиально не содержит пароль, его хэш из запроса или признак существования +пользователя. Записи старше настраиваемого срока (по умолчанию 90 дней), а также неактивные +счётчики удаляются tenant-aware задачей ограниченными `SKIP LOCKED` пачками. ### Учебный процесс @@ -476,11 +538,15 @@ erDiagram | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `name` | VARCHAR(200) UNIQUE | Название | +| `name` | VARCHAR(200) NOT NULL | Название | | `code` | VARCHAR(20) | Код предмета | | `department_id` | BIGINT FK → departments | Кафедра | | `description` | TEXT | Описание | +Уникальный функциональный индекс `uq_subjects_name_ci` на `lower(name)` гарантирует +глобальную уникальность названия без учёта регистра и защищает владение дисциплиной при +конкурентном импорте разных кафедр. Индекс входит в единую baseline-миграцию V1. + ### Аудиторный фонд #### `classrooms` — Аудитории @@ -555,7 +621,13 @@ erDiagram | `created_at` | TIMESTAMP | Дата создания записи | | `created_by` | BIGINT FK → users | Кто оформил перевод | -Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной кафедрой, но позволяет преподавателю иметь несколько открытых неосновных кафедр. +Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной +кафедры у преподавателя. Ограничение +`ex_teacher_primary_department_no_overlap` запрещает пересечение любых закрытых или открытых +периодов основной кафедры одного преподавателя. Индекс +`uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной +кафедрой, но позволяет иметь несколько открытых неосновных кафедр. Все эти объекты входят в +единую baseline-миграцию `V1__init.sql`. #### `teacher_creation_requests` — Заявки кафедр на создание преподавателей | Колонка | Тип | Описание | @@ -609,7 +681,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `end_time` | TIME | Время окончания | | `duration_minutes` | INT | Длительность в минутах | -Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может быть только один слот с номером пары. +Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может +быть только один слот с номером пары. Базовая схема V1 дополнительно требует точного равенства +`duration_minutes` разнице `end_time - start_time` в полных минутах и запрещает +пересекающиеся интервалы одной сетки через GiST exclusion constraint +`ex_time_slots_scope_no_overlap`. Интервалы трактуются как полуоткрытые `[start, end)`, +поэтому соседние пары разрешены. + +Триггеры V1 сохраняют связь правил с базовой сеткой: `schedule_rule_slots` принимает только +слот области `DEFAULT`, используемый слот нельзя перенести в небазовую область, а область с +используемыми слотами нельзя сделать небазовой. Блокировки строк слота и области закрывают +гонку между созданием правила и изменением сетки. #### `time_slot_date_assignments` — Ручные назначения сеток времени | Колонка | Тип | Описание | @@ -626,6 +708,10 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `start_date` | DATE | Дата начала | | `end_date` | DATE | Дата окончания | +V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` для включительных +диапазонов дат. Поэтому два учебных года не могут содержать одну и ту же календарную дату; +следующий год может начаться на следующий день после окончания предыдущего. + #### `semesters` — Семестры | Колонка | Тип | Описание | |---------|-----|----------| @@ -635,6 +721,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `start_date` | DATE | Дата начала, от неё считается неделя 1 | | `end_date` | DATE | Дата окончания | +Пара `(academic_year_id, semester_type)` уникальна. V1 дополнительно запрещает пересечение +включительных диапазонов семестров одного года через `ex_semesters_year_no_overlap`. +Триггеры требуют полного вхождения семестра в границы родительского года и запрещают +сужать учебный год так, чтобы существующий семестр оказался снаружи. Блокировка строки года +в триггере сериализует эти взаимные проверки с конкурентными insert/update. + #### `academic_calendar_activity_types` — Коды активностей графика | Колонка | Тип | Описание | |---------|-----|----------| @@ -659,6 +751,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `created_at` | TIMESTAMP | Дата создания | | `updated_at` | TIMESTAMP | Дата обновления | +Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год, +специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная +группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке +остаются строки старших курсов или дисциплины старших семестров. + #### `academic_calendar_days` — Дневная сетка календарного графика | Колонка | Тип | Описание | |---------|-----|----------| @@ -670,6 +767,9 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `day_of_week` | INT CHECK(1–7) | День недели ISO | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | +Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал +`academic_calendars.course_count`, а дата находилась внутри учебного года графика. + #### `academic_calendar_subjects` — Дисциплины календарного графика | Колонка | Тип | Описание | |---------|-----|----------| @@ -679,7 +779,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `subject_id` | BIGINT FK → subjects | Дисциплина из справочника | | `created_at` | TIMESTAMP | Дата создания привязки | -Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend по `academic_calendars.course_count * 2`. +Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend и триггером `trg_calendar_subjects_dimensions` по `academic_calendars.course_count * 2`. #### `student_group_calendar_assignments` — Назначения графиков группам | Колонка | Тип | Описание | @@ -689,6 +789,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `academic_year_id` | BIGINT FK → academic_years (CASCADE) | Учебный год | | `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Назначенный график | +Назначение уникально для пары «группа + учебный год». Триггер +`trg_calendar_assignments_compatible` проверяет совпадение года, специальности, профиля и +формы обучения, а также попадание вычисленного курса группы в `1..course_count`. Обратные +триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки +ссылочных строк закрывают конкурентные записи между несколькими backend-pod. + #### `schedule_rules` — Правила расписания | Колонка | Тип | Описание | |---------|-----|----------| @@ -709,7 +815,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня `ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`. -Миграция V4 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум. +Базовая схема V1 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум. Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1; нули допустимы только как лимиты неиспользуемых типов занятия. @@ -738,7 +844,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `locked_at` | TIMESTAMP | Когда выполнено закрепление | | `lock_comment` | TEXT | Комментарий к закреплению | -V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить +V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить одинаковые день, чётность, базовый временной слот, преподавателя, аудиторию, тип и формат занятия. Более широкие ресурсные пересечения и семантика подгрупп проверяются сервисом, поскольку зависят от нескольких таблиц и фактических активных недель. @@ -767,7 +873,7 @@ V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр | `created_at` | TIMESTAMP | Дата создания | Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для -одной и той же пары. Миграция V3 добавляет структурные инварианты: +одной и той же пары. Базовая схема V1 добавляет структурные инварианты: - `CANCEL` не содержит новых ресурсов; - `MOVE` содержит новый временной слот или аудиторию; @@ -793,28 +899,14 @@ V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр | Файл | Описание | |------|----------| -| `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 слота правила | +| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | -### Накатывание на существующих тенантов +### Этап разработки -V2–V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1–V3. -Перед добавлением ограничений V3 считает нарушения `CANCEL`, `MOVE`, `REPLACE` и формата. -Если найдены legacy-строки, миграция полностью откатывается и сообщает только количества -нарушений. Оператор должен исправить бизнес-данные tenant-БД и повторить миграцию; -автоматическое удаление или переписывание overrides не выполняется. - -Перед ограничениями V4 отдельно считаются нечётные лимиты лекций, лабораторных и практик, -а также группы точных дублей слотов. Любое нарушение останавливает V4 с русским сообщением; -ограничения не остаются частично применёнными и legacy-данные автоматически не меняются. - -```bash -# После исправления legacy-данных перезапустите backend, -# чтобы TenantConfigWatcher повторил Flyway migrate для tenant-БД. -docker compose restart backend -``` +По прямому решению владельца проекта все миграции V2–V7 объединены в V1, поскольку +клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно +пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в +`flyway_schema_history` не поддерживается. ### Полный сброс БД (локально) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7477086..e615db1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -73,14 +73,16 @@ private static final Logger logger = LoggerFactory.getLogger(MyController.class) // Информационные сообщения logger.info("Запрос на получение всех занятий"); -// Ошибки с полным стектрейсом -logger.error("Ошибка при сохранении: {}", e.getMessage(), e); +// Технические детали остаются только в журнале +logger.error("Ошибка при сохранении записи", e); ``` #### Валидация - Для сложных правил — отдельные сервисы или приватные методы в контроллере с понятным сообщением об ошибке - Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()` +- Не формируйте HTTP-ответ из `Exception.getMessage()`: JDBC/SQL-исключения передаются в + `GlobalExceptionHandler`, который возвращает безопасный русский `400`/`409`/`500` #### Импорты @@ -126,8 +128,8 @@ import com.magistr.app.repository.*; #### Лучшие практики ```javascript -// ✅ Предпочитайте const -const token = localStorage.getItem('token'); +// ✅ Авторизация только через общий модуль; access JWT не хранится в Web Storage +const session = getSession(); // ✅ Async/await вместо .then() async function loadData() { @@ -229,6 +231,10 @@ public class AbsenceController { Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. +Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца +содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о +фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД. + ### Применение ```bash diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index bc43226..6e4c5f2 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -7,8 +7,8 @@ | **Фреймворк** | Нет (Vanilla JavaScript) | | **Модульная система** | ES6 Modules (`import`/`export`) | | **Стили** | CSS (модульный подход) | -| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания | -| **Веб-сервер** | Apache httpd:alpine | +| **Шрифт** | Системный стек без внешних font-CDN | +| **Веб-сервер** | Apache httpd на Alpine со строгим CSP | --- @@ -16,14 +16,24 @@ ``` frontend/ -├── package.json # Dependency-free проверки frontend через node:test +├── package.json # Exact build-зависимости и команды проверки +├── package-lock.json # Зафиксированное npm-дерево +├── auth-session.js # Access JWT и профиль сессии только в памяти вкладки +├── telemetry.js # Same-origin загрузчик собранного OTel bundle +├── security.conf # CSP и защитные HTTP-заголовки Apache +├── telemetry/ +│ └── otel-entry.js # Исходная точка сборки OpenTelemetry +├── scripts/ +│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild ├── tests/ -│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов +│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh +│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов +│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile ├── index.html # 🔐 Страница авторизации (общая) ├── script.js # Логика авторизации ├── style.css # Стили страницы авторизации ├── theme-toggle.js # Переключение светлой/тёмной темы -├── Dockerfile # httpd:alpine +├── Dockerfile # Multi-stage: npm bundle → Apache httpd │ ├── admin/ # 👨‍💼 Интерфейс администратора │ ├── index.html # SPA-оболочка с sidebar @@ -36,10 +46,10 @@ frontend/ │ │ └── departments-data.css # Стили создания кафедры/специальности │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация +│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям │ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── utils.js # Утилиты -│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) │ │ └── views/ # Модули представлений │ │ ├── dashboard.js # Дашборд │ │ ├── users.js # Управление пользователями @@ -81,14 +91,18 @@ frontend/ │ └── time-slots.html # Базовая, субботняя и ручные сетки времени │ ├── teacher/ # 👩‍🏫 Интерфейс преподавателя -│ └── index.html # Недельный просмотр динамического расписания преподавателя +│ ├── index.html # CSP-совместимая HTML-оболочка +│ ├── app.js # Недельный просмотр и общий auth-session +│ └── style.css # Стили кабинета без inline-блока ├── department/ # 🏛 Кабинет кафедры │ └── index.html # Redirect в `/admin/#department-workspace` ├── edu-office/ # 🗓 Кабинет учебного отдела │ └── index.html # Redirect в `/admin/#schedule-view` │ └── student/ # 🎓 Интерфейс студента - └── index.html # Недельный просмотр динамического расписания группы + ├── index.html # CSP-совместимая HTML-оболочка + ├── app.js # Недельный просмотр и общий auth-session + └── style.css # Стили кабинета без inline-блока ``` --- @@ -112,12 +126,13 @@ frontend/ 3. Подключает соответствующий JS-модуль из `js/views/{tab}.js` 4. Обновляет заголовок страницы (`#page-title`) -`main.js` также фильтрует вкладки по роли: +`main.js` и отдельный settings SPA получают разрешённые вкладки из единого +`admin/js/role-capabilities.js`; локальные дубли `ROLE_NAVIGATION`/`ROLE_TABS` удалены. | Роль | Доступные вкладки | |------|-------------------| | `ADMIN` | Все вкладки | -| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории, оборудование | +| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения | | `DEPARTMENT` | Кабинет кафедры, просмотр расписаний | | `SCHEDULE_VIEWER` | Только просмотр расписаний | @@ -139,14 +154,14 @@ frontend/ | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | | `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | -| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` | +| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | ### Особенности админских вкладок - Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`. -- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. +- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. @@ -168,47 +183,32 @@ frontend/ - Кнопка «Назад в панель» для возврата в `/admin/` - Текущие вкладки: - **Общие настройки** — заглушка (только `ADMIN`) - - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика. + - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Поле длительности доступно только для чтения и пересчитывается при изменении времени начала или окончания; в API отправляются только границы, а окончательное значение вычисляет backend. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика. --- ## API-клиент (`api.js`) -Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом: +Все защищённые HTTP-запросы проходят через `fetchWithAuth()` из `auth-session.js`. +Access JWT и `role`/`departmentId`/`userId` существуют только в памяти JavaScript-модуля: ```javascript -export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) { - const response = await fetch(endpoint, { - method, - headers: getHeaders(body ? 'application/json' : null), - credentials: 'same-origin', - body: body ? JSON.stringify(body) : undefined - }); - - if (response.status === 401 && retryOnUnauthorized) { - const refreshed = await refreshAccessToken(); - if (refreshed) return apiFetch(endpoint, method, body, false); - clearAuthState(); - window.location.href = '/'; +export async function fetchWithAuth(endpoint, options = {}, retry = true) { + const headers = new Headers(options.headers || {}); + if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`); + const response = await fetch(endpoint, { ...options, headers, credentials: 'same-origin' }); + if (response.status === 401 && retry && await refreshAccessToken()) { + return fetchWithAuth(endpoint, options, false); } - - if (!response.ok) { - throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); - } - - return await response.json(); + return response; } - -// Shortcut-методы -export const api = { - get: (url) => apiFetch(url, 'GET'), - post: (url, body) => apiFetch(url, 'POST', body), - put: (url, body) => apiFetch(url, 'PUT', body), - delete: (url, body) => apiFetch(url, 'DELETE', body) -}; ``` -При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа. +При загрузке защищённой страницы память восстанавливается через `POST /api/auth/refresh` и +`HttpOnly` cookie. При `401` клиент выполняет не более одной общей refresh-ротации для всех +параллельных запросов и один раз повторяет исходный запрос. Неуспешный refresh очищает память +и возвращает пользователя на страницу входа. Legacy auth-ключи только удаляются из +`localStorage`/`sessionStorage`; новые данные авторизации туда не записываются. После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы. @@ -216,13 +216,21 @@ export const api = { ## Frontend-тесты -Регрессионные проверки frontend используют встроенный `node:test` без сторонних npm-зависимостей (Node.js 18+). Тесты `dashboard-conflicts.test.mjs` покрывают локальные даты `Europe/Moscow` в интервале 00:00–03:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса. +Регрессионные проверки используют встроенный `node:test` (Node.js 18+). Помимо тестов +дашборда, `auth-session.test.mjs` покрывает login-state, reload через refresh-cookie, +single-flight ротацию, повтор после `401` и logout. `security-policy.test.mjs` запрещает +auth-данные в Web Storage, удалённые/inline-скрипты, динамические inline-стили и рассинхрон +SHA-256 style-хэшей с CSP. Там же закреплены безопасный DOM-рендеринг ошибок без +интерполяции `exception.message` в `innerHTML`, скрытые парольные поля с корректным +`autocomplete`, русские метки tenant-контура и отсутствие англоязычных/raw exception +сообщений в собственных frontend-логах. Команды выполняются из каталога `frontend/`: ```bash -npm test # unit-тесты дат, загрузки кафедр и UI-состояний -npm run check # синтаксис модулей дашборда + unit-тесты +npm run build # собрать локальный dist/vendor/otel.js +npm test # frontend unit/static tests +npm run check # синтаксис auth/UI-модулей + все frontend-тесты ``` --- @@ -233,13 +241,10 @@ npm run check # синтаксис модулей дашборда + unit-те 1. Пользователь вводит логин/пароль 2. `script.js` отправляет `POST /api/auth/login` -3. При успехе сохраняет в `localStorage`: - - `token` — access JWT - - `role` — роль пользователя - - `departmentId` — кафедра пользователя - - `userId` — ID пользователя для личного расписания преподавателя -4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript -5. Перенаправляет на соответствующий интерфейс: +3. При успехе кладёт access JWT и профиль пользователя только в память текущей страницы. +4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript. +5. После перехода новый документ восстанавливает память через `POST /api/auth/refresh`. +6. Пользователь перенаправляется на соответствующий интерфейс: - `ADMIN` → `/admin/` - `EDUCATION_OFFICE` → `/admin/#schedule-view` - `DEPARTMENT` → `/admin/#department-workspace` @@ -249,18 +254,19 @@ npm run check # синтаксис модулей дашборда + unit-те ### Проверка авторизации -На каждой странице проверяется наличие токена и роли: +Каждая защищённая страница сначала восстанавливает сессию и проверяет роль из памяти: ```javascript -export function isAuthenticatedAsAdmin() { - const role = localStorage.getItem('role'); - return getToken() && role === 'ADMIN'; +const session = await restoreSession(); +if (!session || !AUTHORIZED_ROLES.includes(session.role)) { + window.location.replace('/'); } ``` ### Выход -Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`. +Кнопка «Выйти» вызывает `POST /api/auth/logout`, очищает access JWT и профиль из памяти и +перенаправляет на `/`. Refresh-cookie очищает backend. --- @@ -292,7 +298,8 @@ export function isAuthenticatedAsAdmin() { ### Преподаватель (`/teacher/`) -Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`. +Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из +восстановленного в памяти профиля сессии. Основные элементы: - навигация по неделям: предыдущая, текущая, следующая; @@ -300,7 +307,7 @@ export function isAuthenticatedAsAdmin() { - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; - отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила. -Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. +Если refresh-cookie недействительна или роль не `TEACHER`, страница возвращает пользователя на вход. ### Студент (`/student/`) @@ -368,17 +375,18 @@ CSS-переменные позволяют поддерживать светл --- -## OpenTelemetry (`otel.js`) +## OpenTelemetry (локальный bundle) -Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`. +Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на +same-origin путь `/otel/v1/traces`. -- **На production** — загружается автоматически через динамический `import()` -- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh` +- npm-зависимости зафиксированы exact-версиями в `package-lock.json`; +- `scripts/build-vendor.mjs` собирает их через esbuild в `/vendor/otel.js`; +- браузер импортирует только same-origin bundle, CDN-код в runtime отсутствует; +- на `localhost` телеметрия не включается. ```javascript -if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { - import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); -} +telemetryPromise = import('/vendor/otel.js'); ``` --- diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 73e7948..ad87888 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -6,18 +6,20 @@ ```yaml services: - backend: # Spring Boot (Java 17), порт 8080 - frontend: # Apache httpd:alpine, порт 80 - db: # PostgreSQL alpine3.23, порт 5432 + backend: # Spring Boot (Java 17), внутренний порт 8080 + frontend: # Apache httpd, публикует HTTP_PORT (по умолчанию 80) + db: # PostgreSQL 16.3, внутренний порт 5432 ``` ### Сеть -Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском: +Compose сам создаёт изолированную bridge-сеть `magistr`. Внешняя сеть или отдельно +установленный reverse proxy для локального запуска не нужны. Apache раздаёт frontend и +проксирует same-origin пути `/api` и `/actuator/health` в backend; backend и PostgreSQL не +публикуют порты на хосте. -```bash -docker network create proxy -``` +Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный +`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс. ### Переменные окружения @@ -30,9 +32,29 @@ POSTGRES_DB=app_db JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes JWT_ACCESS_TOKEN_TTL=15m JWT_REFRESH_TOKEN_TTL=7d +JWT_REFRESH_CLEANUP_RETENTION=30d +LOGIN_RATE_MAX_FAILURES=5 +LOGIN_RATE_ATTEMPT_WINDOW=15m +LOGIN_RATE_BASE_BLOCK_DURATION=1m +LOGIN_RATE_MAX_BLOCK_DURATION=15m +LOGIN_AUDIT_RETENTION=90d +TRUSTED_PROXY_CIDRS=172.16.0.0/12 +OTEL_SDK_DISABLED=true +HTTP_PORT=80 ``` -`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы. +Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env` +и заполните пустые секреты. `POSTGRES_PASSWORD` и `JWT_SECRET` обязательны для +`docker compose up` и не хранятся в `compose.yaml`; JWT должен быть случайным секретом длиной +минимум 32 байта (`openssl rand -base64 48`). `POSTGRES_DB` одновременно задаёт создаваемую +БД и входит в `SPRING_DATASOURCE_URL`, поэтому произвольное локальное имя остаётся +согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются +backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического +reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует. +Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён; +при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint. + +В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы. Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение останавливает запуск; профиль `production` также требует Secure refresh-cookie. @@ -43,25 +65,41 @@ JWT_REFRESH_TOKEN_TTL=7d Backend собирается через multi-stage сборку Maven: 1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package` -2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar` +2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и + проверяется по закреплённому SHA-256 +3. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar` ### Dockerfile (Frontend) ```dockerfile -FROM httpd:alpine -COPY . /usr/local/apache2/htdocs/ -RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ +FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets +RUN npm ci +RUN npm run build:vendor + +FROM httpd:alpine3.23@sha256:4a15e9c... +COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/ +COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf +COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf ``` +Оба базовых образа зафиксированы tag и manifest digest. Первый этап собирает зафиксированный +OpenTelemetry bundle; второй раздаёт только runtime- +файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`, +`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно +маршрутизировался в tenant `default`. Сервер +возвращает CSP с `script-src 'self'`, `script-src-attr 'none'`, `style-src 'self'` и точными +SHA-256 для оставшихся статических style-атрибутов. `unsafe-inline` и `unsafe-eval` не +используются. Дополнительно выставляются `nosniff`, `DENY`, строгий referrer policy и +ограниченная Permissions Policy. + --- ## Kubernetes (продакшн) ### Расположение конфигурации -Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот -внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным -runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера. +Production-манифесты находятся в `../k8s/`. Каталог расположен вне Git-корня `magistr`, +поэтому его изменения проверяются и перечисляются отдельно от `git diff` репозитория. ### Ключевые ресурсы @@ -78,6 +116,37 @@ runbook для оператора, а не утверждением о прим - `JWT_ACCESS_TOKEN_TTL=15m` - `JWT_REFRESH_TOKEN_TTL=7d` - `JWT_REFRESH_COOKIE_SECURE=true` +- `JWT_REFRESH_CLEANUP_ENABLED=true` +- `JWT_REFRESH_CLEANUP_RETENTION=30d` +- `JWT_REFRESH_CLEANUP_BATCH_SIZE=500` +- `JWT_REFRESH_CLEANUP_MAX_BATCHES=20` +- `JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000` +- `JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000` + +Cleanup проходит отдельно по каждой активной tenant-БД. `RETENTION` задаёт срок хранения +свежих истёкших и отозванных audit-записей, `BATCH_SIZE` и `MAX_BATCHES` ограничивают объём +одного прохода, а interval/initial delay управляют безопасным расписанием. + +### Защита входа и доверенные proxy + +`app-config` задаёт общий PostgreSQL rate limit для всех backend-pod: + +- `LOGIN_RATE_MAX_FAILURES=5` +- `LOGIN_RATE_ATTEMPT_WINDOW=15m` +- `LOGIN_RATE_BASE_BLOCK_DURATION=1m` +- `LOGIN_RATE_MAX_BLOCK_DURATION=15m` +- `LOGIN_AUDIT_RETENTION=90d` +- `TRUSTED_PROXY_CIDRS=10.42.0.0/16` + +Последнее значение соответствует стандартной pod-сети K3s, из которой Traefik обращается +к backend. Если кластер запущен с другим `cluster-cidr`, перед rollout нужно указать +фактическую сеть ingress proxy. Не следует добавлять публичные сети клиентов: backend +доверяет `X-Forwarded-For` только от непосредственного источника из этого списка. + +Счётчики и audit-события находятся в каждой tenant-БД, поэтому два pod используют одно +атомарное состояние. Старая история очищается раз в сутки ограниченными `SKIP LOCKED` +пачками; параметры `LOGIN_AUDIT_CLEANUP_*` из `.env.example` позволяют изменить расписание +и максимальный объём одного прохода. `app-secret` задаёт `JWT_SECRET`. Этот Secret не создаётся файлами `../k8s/`: его заранее предоставляет внешний secret manager или оператор. Deployment явно включает профиль @@ -194,8 +263,7 @@ namespace `magistr`. Secret должен существовать заранее ### Liveness и readiness probes backend -TCP probe подтверждает только открытый порт и не отражает готовность обязательных tenant-БД. -В production Deployment необходимо использовать раздельные HTTP probes: +Production Deployment использует раздельные HTTP probes: ```yaml livenessProbe: @@ -214,29 +282,20 @@ Liveness зависит только от состояния процесса. R исключается из приёма трафика без перезапуска. Actuator не публикует components, домены, JDBC URL или credentials. -Параметры `initialDelaySeconds`, `periodSeconds`, `timeoutSeconds` и `failureThreshold` нужно -согласовать с текущим Deployment; замена механизма probe не подтверждает автоматически -корректность этих порогов. +`../k8s/backend.yaml` монтирует каталог `/config` без `subPath`, а `app-config` задаёт +`TENANTS_CONFIG_PATH=/config/tenants.json` и `TENANTS_CONFIG_REQUIRED=true`. Отсутствующий +или невалидный обязательный Secret поэтому снимает readiness, не включая H2 fallback. -### Внешние действия для production Kubernetes - -Каталог `../k8s/` не изменялся и не проверялся в рамках текущей рабочей копии. Оператору после -получения доступа к нему необходимо: - -1. обновить backend Deployment: directory mount без `subPath`, - `TENANTS_CONFIG_REQUIRED=true` и две HTTP probes; -2. добавить либо сузить Role до `get`/`update` одного `tenants-secret` и проверить RoleBinding - фактического ServiceAccount; -3. убедиться, что внешний `tenants-secret` существует, содержит непустой ключ `tenants.json` - и допускает обновление; -4. отрендерить манифесты и выполнить серверную проверку без применения: +Перед production rollout оператор должен убедиться, что внешний `tenants-secret` существует, +содержит непустой ключ `tenants.json`, допускает обновление, и выполнить проверки без +применения: ```bash kubectl kustomize ../k8s >/dev/null kubectl apply --dry-run=server -k ../k8s ``` -5. отдельно проверить полномочия фактического ServiceAccount: +Отдельно проверьте полномочия фактического ServiceAccount: ```bash BACKEND_SERVICE_ACCOUNT='укажите-фактический-service-account' @@ -246,15 +305,21 @@ kubectl auth can-i update secret/tenants-secret \ --as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr ``` -Применение манифестов, rollout и проверка реальных pod являются отдельными внешними -операциями и без разрешения автоматически не выполняются. +Production rollout и проверка реальных pod являются внешними операциями и без отдельного +разрешения из этой рабочей копии не выполнялись. -### Обновление backend +### Ручное обновление backend и frontend ```bash -kubectl rollout restart deployment backend -n magistr +export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<64 hex>' +export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<64 hex>' +bash ../k8s/deploy.sh apply ``` +Манифесты содержат нулевой digest-sentinel. `deploy.sh` требует два реальных digest, +локально подставляет их до `kubectl apply` и строго ждёт оба rollout; прямое применение +Deployment-файлов запрещено. + --- ## Caddy (реверс-прокси) @@ -275,10 +340,16 @@ kubectl rollout restart deployment backend -n magistr Расположение: `.gitea/workflows/docker-build.yaml` Основные шаги: -1. Checkout кода -2. Login в Docker Registry -3. Build + Push образов (`backend`, `frontend`) -4. Генерация меток через `docker/metadata-action` +1. Backend unit/component tests, frontend static/unit tests, Compose validation и shell-тест + immutable rollout/rollback. +2. Только после успешных gates — login, параллельная сборка и push backend/frontend с + SHA-tag и release tag без `latest`. +3. Build jobs публикуют digest обоих образов; deploy job устанавливает фиксированный + `kubectl v1.33.12` после SHA-256 проверки. +4. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие + ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба + предыдущих образа и повторно проверяет их готовность. +5. Workflow-wide concurrency lock не допускает одновременные production deployment. --- @@ -289,7 +360,7 @@ kubectl rollout restart deployment backend -n magistr ```mermaid graph LR Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"] - Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector + Frontend["JS (локальный OTel bundle)"] -->|OTLP HTTP| Collector Collector --> SigNoz["SigNoz"] Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"] @@ -308,10 +379,13 @@ Tenant ID добавляется в: ### Интеграция Frontend -Файл `admin/js/otel.js` — клиентская телеметрия: +Собранный `/vendor/otel.js` — клиентская телеметрия: - Метрики производительности страниц - Трейсы пользовательских действий +Исходные npm-пакеты имеют exact-версии и lockfile, собираются внутри frontend-образа и не +загружаются браузером со стороннего CDN. + PostgreSQL receivers collector получают endpoint и credentials только из `otel-postgres-secret`; ConfigMap collector содержит лишь `${env:...}` ссылки. diff --git a/docs/LOGGING.md b/docs/LOGGING.md index 757385a..12a67ba 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -106,12 +106,18 @@ public class MyClass { private static final Logger log = LoggerFactory.getLogger(MyClass.class); public void doSomething() { - log.info("Операция выполнена: param={}", value); - log.error("Ошибка: {}", e.getMessage(), e); // со стектрейсом + log.info("Операция выполнена: параметр={}", value); + log.error("Операция завершилась ошибкой: типОшибки={}", + e.getClass().getSimpleName()); + log.debug("Технические детали ошибки операции", e); } } ``` +Production-сообщения пишутся на русском языке и не включают `exception.getMessage()`, +JDBC URL, логины, пароли или содержимое Secret. Технический тип исключения допускается как +структурированное поле, а полный стек — в отдельной русскоязычной записи уровня `DEBUG`. + ### Рекомендации по уровням | Уровень | Когда использовать | diff --git a/docs/README.md b/docs/README.md index a2ee0d3..7fa4038 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,14 +36,18 @@ # 1. Клонировать репозиторий git clone magistr && cd magistr -# 2. Создать Docker-сеть (если ещё не создана) -docker network create proxy +# 2. Подготовить локальные переменные +cp .env.example .env +# Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET. +# JWT_SECRET можно сгенерировать командой: openssl rand -base64 48 # 3. Запустить все сервисы docker compose up -d --build ``` -После запуска приложение доступно по адресу: **http://localhost:80** +Compose сам создаёт внутреннюю сеть и именованный том PostgreSQL. После запуска приложение +доступно по адресу **http://localhost:80**; если в `.env` задан другой `HTTP_PORT`, используйте +его. Backend и PostgreSQL наружу не публикуются: `/api` проксируется frontend-контейнером. **Учётные данные по умолчанию:** @@ -60,7 +64,7 @@ docker compose logs -f backend # Полный сброс базы данных (удаление данных + повтор миграций) docker compose down -v -docker compose up -d +docker compose up -d --build # Остановка всех сервисов docker compose down diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 4a246ec..1df05e6 100755 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,2 +1,4 @@ Dockerfile .dockerignore +node_modules +dist diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c9203f0..52de24b 100755 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,27 @@ -FROM httpd:alpine -COPY . /usr/local/apache2/htdocs/ +FROM node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2120bcca92ce372e AS frontend-assets -# Set appropriate permissions for the web server to serve static files -RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ +WORKDIR /build +COPY package.json package-lock.json ./ +RUN npm ci +COPY telemetry/ ./telemetry/ +COPY scripts/build-vendor.mjs ./scripts/build-vendor.mjs +RUN npm run build:vendor + +FROM httpd:alpine3.23@sha256:4a15e9c73f25334bc03cfb3c692c9adfc103bb46ca89cee1f0b9a5fcbc7b21f6 + +COPY admin/ /usr/local/apache2/htdocs/admin/ +COPY department/ /usr/local/apache2/htdocs/department/ +COPY edu-office/ /usr/local/apache2/htdocs/edu-office/ +COPY student/ /usr/local/apache2/htdocs/student/ +COPY teacher/ /usr/local/apache2/htdocs/teacher/ +COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/ +COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/ +COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf +COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf + +# Сервер раздаёт same-origin ресурсы и проксирует API во внутреннюю сеть Compose. +RUN sed -i 's/^#LoadModule headers_module/LoadModule headers_module/' /usr/local/apache2/conf/httpd.conf \ + && sed -i 's/^#LoadModule proxy_module/LoadModule proxy_module/' /usr/local/apache2/conf/httpd.conf \ + && sed -i 's/^#LoadModule proxy_http_module/LoadModule proxy_http_module/' /usr/local/apache2/conf/httpd.conf \ + && printf '\nInclude conf/extra/magistr-security.conf\nInclude conf/extra/magistr-proxy.conf\n' >> /usr/local/apache2/conf/httpd.conf \ + && chown -R www-data:www-data /usr/local/apache2/htdocs/ diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index b1e51ba..08662d7 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -317,6 +317,48 @@ line-height: 1.4; } +.dashboard-conflict-card { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + border: 1px solid rgba(229, 231, 235, 0.1); + border-left: 4px solid var(--warning); + border-radius: var(--radius-sm); + background: rgba(245, 158, 11, 0.05); +} + +.dashboard-conflict-card--error { + border-left-color: var(--error); + background: rgba(239, 68, 68, 0.05); +} + +.dashboard-conflict-card__icon { + margin-top: 0.1rem; +} + +.dashboard-conflict-card__body { + flex: 1; +} + +.dashboard-conflict-card__body h4 { + margin: 0 0 0.25rem; + color: #d97706; + font-size: 0.95rem; + font-weight: 600; +} + +.dashboard-conflict-card--error h4 { + color: var(--error); +} + +.dashboard-conflict-card__body p { + margin: 0; + color: var(--text-main); + font-size: 0.85rem; + line-height: 1.4; +} + @media (max-width: 1200px) { .dashboard-metrics-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); @@ -927,6 +969,9 @@ border-radius: var(--radius-sm); background: rgba(10, 10, 15, 0.97); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .schedule-visual-context-menu[hidden] { @@ -2014,7 +2059,7 @@ } .calendar-week-col { - width: calc((100% - var(--calendar-day-head-width)) / var(--calendar-week-columns, 1)); + width: 22px; } .calendar-semester-table thead th { @@ -2122,6 +2167,8 @@ visibility: hidden; transform: translateY(4px) scale(0.98); transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease; + right: 12px; + bottom: 12px; } .calendar-day-tooltip.visible { @@ -2219,6 +2266,7 @@ } .calendar-total-chip { + --chip-color: var(--accent); display: inline-flex; align-items: center; gap: 0.35rem; @@ -3626,6 +3674,15 @@ tbody tr:hover { transition: transform 0.4s ease; } +.theme-toggle-icon-enter { + animation: theme-toggle-icon-enter 0.4s ease; +} + +@keyframes theme-toggle-icon-enter { + from { opacity: 0; transform: rotate(-90deg) scale(0.5); } + to { opacity: 1; transform: rotate(0deg) scale(1); } +} + .theme-toggle:hover { background: var(--button-secondary-bg-hover); border-color: var(--button-secondary-border); diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index 207a7d1..8bf7322 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -482,3 +482,16 @@ pointer-events: auto; } } +.sidebar.collapsed .sidebar-section-title { + display: none !important; +} + +.sidebar-section-divider { + height: 1px; + background: rgba(229, 231, 235, 0.1); + margin: 0.5rem 1.5rem; +} + +.sidebar.collapsed .sidebar-section-divider { + margin: 0.5rem 0.5rem; +} diff --git a/frontend/admin/css/main.css b/frontend/admin/css/main.css index cbfb555..79b5c1c 100755 --- a/frontend/admin/css/main.css +++ b/frontend/admin/css/main.css @@ -107,4 +107,14 @@ body { animation: admin-ripple 0.6s linear; background-color: rgba(255, 255, 255, 0.3); pointer-events: none; + left: 50%; + top: 50%; + width: 24px; + height: 24px; + margin: -12px 0 0 -12px; +} + +.ripple-host { + position: relative; + overflow: hidden; } diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 32d95df..000ab0b 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -5,10 +5,6 @@ Админ-панель - - - - @@ -16,19 +12,6 @@ - diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index ff34b4c..f936e44 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -1,23 +1,38 @@ -let refreshPromise = null; - -const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId']; +import { + clearAuthState, + fetchWithAuth, + getAccessToken, + getAuthState, + logoutSession, + refreshAccessToken, + restoreSession, + storeAuthState +} from '../../auth-session.js'; export function getToken() { - return localStorage.getItem('token'); + return getAccessToken(); +} + +export function getSession() { + return getAuthState(); +} + +export async function initializeSession() { + return restoreSession(); } export function isAuthenticatedAsAdmin() { - const role = localStorage.getItem('role'); + const role = getAuthState()?.role; return getToken() && role === 'ADMIN'; } export function isAuthenticatedAsRole(expectedRole) { - const role = localStorage.getItem('role'); + const role = getAuthState()?.role; return getToken() && role === expectedRole; } export function isAuthenticatedAsAny(roles) { - const role = localStorage.getItem('role'); + const role = getAuthState()?.role; return getToken() && roles.includes(role); } @@ -44,7 +59,7 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna options.body = JSON.stringify(body); } - const response = await fetch(endpoint, options); + const response = await fetchWithAuth(endpoint, options); let data; try { @@ -54,12 +69,8 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna } if (response.status === 401 && retryOnUnauthorized) { - const refreshed = await refreshAccessToken(); - if (refreshed) { - return apiFetch(endpoint, method, body, false); - } clearAuthState(); - window.location.href = '/'; + window.location.replace('/'); } if (!response.ok) { @@ -72,28 +83,7 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna return data; } -export async function refreshAccessToken() { - if (refreshPromise) { - return refreshPromise; - } - - refreshPromise = (async () => { - const response = await fetch('/api/auth/refresh', { - method: 'POST', - credentials: 'same-origin' - }); - const data = await response.json().catch(() => null); - if (!response.ok || !data?.token) { - return false; - } - storeAuthState(data); - return true; - })().finally(() => { - refreshPromise = null; - }); - - return refreshPromise; -} +export { clearAuthState, refreshAccessToken, storeAuthState }; const cache = new Map(); @@ -139,32 +129,8 @@ export function clearCache() { } export async function logout() { - try { - await fetch('/api/auth/logout', { - method: 'POST', - credentials: 'same-origin' - }); - } catch (e) { - console.warn('Не удалось завершить серверную сессию:', e.message); - } finally { - clearAuthState(); - clearCache(); - } -} - -export function storeAuthState(data) { - if (data.token) localStorage.setItem('token', data.token); - if (data.role) localStorage.setItem('role', data.role); - if (data.departmentId !== undefined && data.departmentId !== null) { - localStorage.setItem('departmentId', data.departmentId); - } - if (data.userId !== undefined && data.userId !== null) { - localStorage.setItem('userId', data.userId); - } -} - -export function clearAuthState() { - AUTH_KEYS.forEach(key => localStorage.removeItem(key)); + await logoutSession(); + clearCache(); } export const api = { diff --git a/frontend/admin/js/dropdown.js b/frontend/admin/js/dropdown.js index 02f3f18..f3a290f 100644 --- a/frontend/admin/js/dropdown.js +++ b/frontend/admin/js/dropdown.js @@ -15,7 +15,7 @@ export class CustomSelect { this.layerParent = this.originalSelect.closest('.settings-card, .card'); // Hide original but keep it accessible for form submissions and JS - this.originalSelect.style.display = 'none'; + this.originalSelect.hidden = true; // Bind methods this.handleTriggerClick = this.handleTriggerClick.bind(this); @@ -114,11 +114,7 @@ export class CustomSelect { items.forEach(item => { if (item.classList.contains('placeholder-item')) return; const text = item.textContent.toLowerCase(); - if (text.includes(query)) { - item.style.display = ''; - } else { - item.style.display = 'none'; - } + item.hidden = !text.includes(query); }); }); searchLi.appendChild(searchInput); @@ -208,7 +204,7 @@ export class CustomSelect { const items = this.menu.querySelectorAll('.custom-select-item'); items.forEach(item => { if (!item.classList.contains('placeholder-item')) { - item.style.display = ''; + item.hidden = false; } }); setTimeout(() => { diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index bff3cf5..f554495 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -1,18 +1,17 @@ -// OTel: загружаем только на продакшене (не на localhost) -if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { - import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); -} - -import { api, isAuthenticatedAsAny, logout } from './api.js'; -import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; +import { api, initializeSession, logout } from './api.js'; +import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage } from './utils.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; +import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js'; +import { initializeTelemetry } from '../../telemetry.js'; -const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER']; -const currentRole = localStorage.getItem('role'); - -if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) { - window.location.href = '/'; +async function bootstrap() { +const session = await initializeSession(); +if (!session || !ADMIN_APP_ROLES.includes(session.role)) { + window.location.replace('/'); + return; } +const currentRole = session.role; +void initializeTelemetry(); // Global initialization for Custom Selects document.addEventListener('DOMContentLoaded', () => { @@ -120,25 +119,6 @@ const ROUTES = { }, }; -const ROLE_NAVIGATION = { - ADMIN: { - defaultTab: 'dashboard', - tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'] - }, - EDUCATION_OFFICE: { - defaultTab: 'dashboard', - tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms'] - }, - DEPARTMENT: { - defaultTab: 'department-workspace', - tabs: ['department-workspace', 'schedule-view'] - }, - SCHEDULE_VIEWER: { - defaultTab: 'schedule-view', - tabs: ['schedule-view'] - } -}; - let currentTab = null; // DOM Elements @@ -153,8 +133,8 @@ const btnLogout = document.getElementById('btn-logout'); const main = document.querySelector('.main'); const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge'); const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge'); -const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER; -const allowedTabs = new Set(roleNavigation.tabs); +const roleCapabilities = capabilitiesForRole(currentRole); +const allowedTabs = new Set(roleCapabilities.adminTabs); // Setup Global Effects applyRippleEffect(); @@ -258,8 +238,8 @@ async function switchTab(tab) { window.location.hash = tab; } } catch (e) { - appContent.innerHTML = `
Ошибка загрузки вкладки: ${e.message}
`; - console.error(e); + renderStatusMessage(appContent, 'Не удалось загрузить выбранную вкладку'); + console.error('Не удалось загрузить вкладку'); } // Close mobile menu if open @@ -274,7 +254,7 @@ function configureRoleInterface() { const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]'); if (settingsLink) { - settingsLink.hidden = !['ADMIN', 'EDUCATION_OFFICE'].includes(currentRole); + settingsLink.hidden = !SETTINGS_ROLES.includes(currentRole); } const firstAllowedTitle = { @@ -297,7 +277,7 @@ async function refreshTeacherRequestBadge(countOverride = null) { } catch (error) { if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true; if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true; - console.warn('Не удалось загрузить счётчик заявок:', error.message); + console.warn('Не удалось загрузить счётчик заявок'); } } @@ -323,7 +303,7 @@ document.addEventListener('teacherRequestsChanged', (event) => { }); const hashTab = window.location.hash.replace('#', ''); -switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab); +switchTab(allowedTabs.has(hashTab) ? hashTab : roleCapabilities.defaultAdminTab); window.addEventListener('hashchange', () => { const hashTab = window.location.hash.replace('#', ''); @@ -331,3 +311,6 @@ window.addEventListener('hashchange', () => { switchTab(hashTab); } }); +} + +await bootstrap(); diff --git a/frontend/admin/js/otel.js b/frontend/admin/js/otel.js deleted file mode 100644 index 2de329d..0000000 --- a/frontend/admin/js/otel.js +++ /dev/null @@ -1,47 +0,0 @@ -import { WebTracerProvider } from 'https://esm.sh/@opentelemetry/sdk-trace-web@1.22.0'; -import { getWebAutoInstrumentations } from 'https://esm.sh/@opentelemetry/auto-instrumentations-web@0.37.0'; -import { OTLPTraceExporter } from 'https://esm.sh/@opentelemetry/exporter-trace-otlp-http@0.49.1'; -import { BatchSpanProcessor } from 'https://esm.sh/@opentelemetry/sdk-trace-base@1.22.0'; -import { registerInstrumentations } from 'https://esm.sh/@opentelemetry/instrumentation@0.49.1'; -import { ZoneContextManager } from 'https://esm.sh/@opentelemetry/context-zone@1.22.0'; -import { Resource } from 'https://esm.sh/@opentelemetry/resources@1.22.0'; -import { SemanticResourceAttributes } from 'https://esm.sh/@opentelemetry/semantic-conventions@1.22.0'; - -// Инициализация провайдера метрик и трейсов с именем сервиса -const provider = new WebTracerProvider({ - resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: 'magistr-frontend-admin', - }), -}); - -// Экспортер отправляет данные на относительный путь /otel/v1/traces. -// На проде Caddy перехватит этот запрос и проксирует в SigNoz OTLP Collector (порт 4318). -const traceExporter = new OTLPTraceExporter({ - url: window.location.origin + '/otel/v1/traces', -}); - -// Использование BatchSpanProcessor для буферизации трейсов перед отправкой -provider.addSpanProcessor(new BatchSpanProcessor(traceExporter)); - -// Использование ZoneContextManager для поддержки асинхронных операций (Promise, setTimeout, etc) -provider.register({ - contextManager: new ZoneContextManager(), -}); - -// Регистрация авто-инструментаций для бразуера (document-load, xml-http-request, fetch, history, etc) -registerInstrumentations({ - instrumentations: [ - getWebAutoInstrumentations({ - '@opentelemetry/instrumentation-fetch': { - propagateTraceHeaderCorsUrls: /.*/, - clearTimingResources: true, - }, - '@opentelemetry/instrumentation-xml-http-request': { - propagateTraceHeaderCorsUrls: /.*/, - clearTimingResources: true, - }, - }), - ], -}); - -console.log('OpenTelemetry Web SDK initialized successfully.'); diff --git a/frontend/admin/js/role-capabilities.js b/frontend/admin/js/role-capabilities.js new file mode 100644 index 0000000..bcc5630 --- /dev/null +++ b/frontend/admin/js/role-capabilities.js @@ -0,0 +1,46 @@ +const capabilities = { + ADMIN: { + defaultAdminTab: 'dashboard', + adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'], + defaultSettingsTab: 'general', + settingsTabs: ['general', 'time-slots', 'database', 'edu-forms'] + }, + EDUCATION_OFFICE: { + defaultAdminTab: 'dashboard', + adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms'], + defaultSettingsTab: 'time-slots', + settingsTabs: ['time-slots', 'edu-forms'] + }, + DEPARTMENT: { + defaultAdminTab: 'department-workspace', + adminTabs: ['department-workspace', 'schedule-view'], + defaultSettingsTab: null, + settingsTabs: [] + }, + SCHEDULE_VIEWER: { + defaultAdminTab: 'schedule-view', + adminTabs: ['schedule-view'], + defaultSettingsTab: null, + settingsTabs: [] + } +}; + +export const ROLE_CAPABILITIES = Object.freeze( + Object.fromEntries(Object.entries(capabilities).map(([role, value]) => [ + role, + Object.freeze({ + ...value, + adminTabs: Object.freeze([...value.adminTabs]), + settingsTabs: Object.freeze([...value.settingsTabs]) + }) + ])) +); + +export const ADMIN_APP_ROLES = Object.freeze(Object.keys(ROLE_CAPABILITIES)); +export const SETTINGS_ROLES = Object.freeze( + ADMIN_APP_ROLES.filter(role => ROLE_CAPABILITIES[role].settingsTabs.length > 0) +); + +export function capabilitiesForRole(role) { + return ROLE_CAPABILITIES[role] || null; +} diff --git a/frontend/admin/js/utils.js b/frontend/admin/js/utils.js index 4b4b6a7..bfe420f 100755 --- a/frontend/admin/js/utils.js +++ b/frontend/admin/js/utils.js @@ -25,24 +25,44 @@ export function hideAlert(elementId) { el.textContent = ''; } +/** Форматирует календарную дату по локальным компонентам без UTC-сдвига. */ +export function formatLocalDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new TypeError('Ожидалась корректная дата'); + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function renderStatusMessage(container, message, className = 'form-alert error') { + if (!container) return; + const element = document.createElement('div'); + element.className = className; + element.textContent = message; + container.replaceChildren(element); +} + +export function renderTableMessage(tbody, columnCount, message) { + if (!tbody) return; + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = columnCount; + cell.className = 'loading-row'; + cell.textContent = message; + row.appendChild(cell); + tbody.replaceChildren(row); +} + export function applyRippleEffect() { document.addEventListener('click', function (e) { const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout'); if (!btn) return; - const rect = btn.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const ripple = document.createElement('span'); ripple.classList.add('ripple'); - ripple.style.left = `${x}px`; - ripple.style.top = `${y}px`; - - if (getComputedStyle(btn).position === 'static') { - btn.style.position = 'relative'; - } - btn.style.overflow = 'hidden'; + btn.classList.add('ripple-host'); btn.appendChild(ripple); diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 2116d6c..6becd8a 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -801,7 +801,7 @@ export async function initAcademicCalendar() { const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); return `
- +
${weekSlots.map(() => '').join('')} @@ -842,7 +842,6 @@ export async function initAcademicCalendar() { const dayName = shortDayLabel(row.dayOfWeek); const activity = activityById(selected) || activityTypes[0] || {}; const code = activity.code || row.activityCode || 'Т'; - const color = activity.colorCode || '#64748b'; return ` `; + renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей'); updateCount(0); } } @@ -44,10 +44,10 @@ export async function initTeacherRequests() { `).join('')} - + - + `; + renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей'); } } diff --git a/frontend/admin/js/views/university-structure.js b/frontend/admin/js/views/university-structure.js index c2ae22d..605121c 100644 --- a/frontend/admin/js/views/university-structure.js +++ b/frontend/admin/js/views/university-structure.js @@ -335,7 +335,7 @@ export async function initUniversityStructure() { profileIdInput.value = ''; profileFormTitle.textContent = 'Создание профиля'; btnSaveProfile.textContent = 'Создать'; - btnCancelProfileEdit.style.display = 'none'; + btnCancelProfileEdit.hidden = true; hideAlert('manage-profile-alert'); } @@ -384,7 +384,7 @@ export async function initUniversityStructure() { profileDescInput.value = profile.description || ''; profileFormTitle.textContent = 'Редактирование профиля'; btnSaveProfile.textContent = 'Сохранить'; - btnCancelProfileEdit.style.display = 'inline-block'; + btnCancelProfileEdit.hidden = false; hideAlert('manage-profile-alert'); return; } @@ -451,7 +451,7 @@ export async function initUniversityStructure() { tabProfileSpecSelect.disabled = false; mainProfileFormTitle.textContent = 'Создание профиля'; btnTabSaveProfile.textContent = 'Создать'; - btnTabCancelProfileEdit.style.display = 'none'; + btnTabCancelProfileEdit.hidden = true; hideAlert('tab-profile-alert'); tabProfileSpecSelect.dispatchEvent(new Event('change')); } @@ -507,7 +507,7 @@ export async function initUniversityStructure() { mainProfileFormTitle.textContent = 'Редактирование профиля'; btnTabSaveProfile.textContent = 'Сохранить'; - btnTabCancelProfileEdit.style.display = 'inline-block'; + btnTabCancelProfileEdit.hidden = false; hideAlert('tab-profile-alert'); createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html index 7766864..e1aa02b 100644 --- a/frontend/admin/settings/index.html +++ b/frontend/admin/settings/index.html @@ -5,10 +5,6 @@ Настройки — Magistr - - - - diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index 9e5cf45..a3fe82a 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -1,13 +1,17 @@ // Основной модуль страницы настроек import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; +import { renderStatusMessage } from '../../js/utils.js'; +import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js'; -import { getToken } from '../../js/api.js'; +import { initializeSession } from '../../js/api.js'; -// Проверка авторизации -const role = localStorage.getItem('role'); -if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { - window.location.href = '/'; +async function bootstrap() { +const session = await initializeSession(); +if (!session || !SETTINGS_ROLES.includes(session.role)) { + window.location.replace('/'); + return; } +const role = session.role; // Глобальная инициализация кастомных списков document.addEventListener('DOMContentLoaded', () => { @@ -44,12 +48,9 @@ const ROUTES = { }, }; -const ROLE_TABS = { - ADMIN: ['general', 'time-slots', 'database', 'edu-forms'], - EDUCATION_OFFICE: ['time-slots', 'edu-forms'] -}; -const allowedTabs = new Set(ROLE_TABS[role] || []); -const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general'; +const roleCapabilities = capabilitiesForRole(role); +const allowedTabs = new Set(roleCapabilities.settingsTabs); +const defaultTab = roleCapabilities.defaultSettingsTab; let currentTab = null; @@ -129,8 +130,8 @@ async function switchTab(tab) { currentTab = tab; } catch (e) { - appContent.innerHTML = `
Ошибка загрузки: ${e.message}
`; - console.error(e); + renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек'); + console.error('Не удалось загрузить раздел настроек'); } // Закрываем мобильное меню после перехода @@ -140,3 +141,6 @@ async function switchTab(tab) { // Вкладка по умолчанию switchTab(defaultTab); +} + +await bootstrap(); diff --git a/frontend/admin/settings/js/views/database.js b/frontend/admin/settings/js/views/database.js index cbf0dfc..7ad3544 100644 --- a/frontend/admin/settings/js/views/database.js +++ b/frontend/admin/settings/js/views/database.js @@ -1,5 +1,5 @@ import { api } from '../../../js/api.js'; -import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js'; +import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js'; export async function initDatabase() { const tenantsTbody = document.getElementById('tenants-tbody'); @@ -12,8 +12,8 @@ export async function initDatabase() { try { const data = await api.get('/api/database/status'); const statusBadge = data.connected - ? 'Online' - : 'Offline'; + ? 'Доступно' + : 'Недоступно'; statusInfo.innerHTML = `
@@ -36,7 +36,7 @@ export async function initDatabase() {
`; } catch (e) { - statusInfo.innerHTML = `
Ошибка загрузки статуса: ${e.message}
`; + renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения'); } } @@ -46,7 +46,7 @@ export async function initDatabase() { const tenants = await api.get('/api/database/tenants'); renderTenantsTable(tenants); } catch (e) { - tenantsTbody.innerHTML = ``; + renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов'); } } @@ -58,8 +58,8 @@ export async function initDatabase() { tenantsTbody.innerHTML = tenants.map(t => { const statusBadge = t.connected - ? 'Online' - : 'Offline'; + ? 'Доступно' + : 'Недоступно'; return ` @@ -96,7 +96,7 @@ export async function initDatabase() { showAlert('add-tenant-alert', `✗ ${result.message}`, 'error'); } } catch (e) { - showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error'); + showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error'); } finally { btnTest.textContent = 'Тест'; btnTest.disabled = false; @@ -122,7 +122,7 @@ export async function initDatabase() { try { const result = await api.post('/api/database/tenants', { name, domain, url, username, password }); if (result.success) { - showAlert('add-tenant-alert', `Тенант "${escapeHtml(domain)}" добавлен!`, 'success'); + showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success'); addTenantForm.reset(); loadTenants(); loadStatus(); @@ -130,7 +130,7 @@ export async function initDatabase() { showAlert('add-tenant-alert', result.message, 'error'); } } catch (e) { - showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error'); + showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error'); } }); @@ -147,7 +147,7 @@ export async function initDatabase() { loadTenants(); loadStatus(); } catch (e) { - alert(`Ошибка: ${e.message}`); + alert('Не удалось удалить тенант'); } }); diff --git a/frontend/admin/settings/js/views/edu-forms.js b/frontend/admin/settings/js/views/edu-forms.js index 752fe39..e2f4024 100644 --- a/frontend/admin/settings/js/views/edu-forms.js +++ b/frontend/admin/settings/js/views/edu-forms.js @@ -8,7 +8,7 @@ export async function fetchEducationForms() { allEducationForms = await api.get('/api/education-forms'); return allEducationForms; } catch (e) { - console.error("Failed to fetch education forms", e); + console.error('Не удалось загрузить формы обучения'); return []; } } diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js index 3df9480..4bbe0e1 100644 --- a/frontend/admin/settings/js/views/time-slots.js +++ b/frontend/admin/settings/js/views/time-slots.js @@ -62,8 +62,8 @@ export async function initTimeSlotsSettings() { form?.addEventListener('submit', saveSlot); resetButton?.addEventListener('click', resetForm); refreshButton?.addEventListener('click', loadAll); - startInput?.addEventListener('change', fillDurationIfEmpty); - endInput?.addEventListener('change', fillDurationIfEmpty); + startInput?.addEventListener('change', calculateDuration); + endInput?.addEventListener('change', calculateDuration); tbody?.addEventListener('click', handleSlotTableClick); await loadAll(); @@ -206,8 +206,7 @@ export async function initTimeSlotsSettings() { orderNumber: Number(orderInput.value), scopeId: activeScopeId, startTime: startInput.value, - endTime: endInput.value, - durationMinutes: durationInput.value ? Number(durationInput.value) : null + endTime: endInput.value }; try { @@ -272,8 +271,9 @@ export async function initTimeSlotsSettings() { } } - function fillDurationIfEmpty() { - if (durationInput.value || !startInput.value || !endInput.value || startInput.value >= endInput.value) return; + function calculateDuration() { + durationInput.value = ''; + if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return; const [startHour, startMinute] = startInput.value.split(':').map(Number); const [endHour, endMinute] = endInput.value.split(':').map(Number); durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute)); diff --git a/frontend/admin/settings/views/database.html b/frontend/admin/settings/views/database.html index e7cca00..dab0aca 100644 --- a/frontend/admin/settings/views/database.html +++ b/frontend/admin/settings/views/database.html @@ -55,11 +55,11 @@
- +
- +
diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index a1f6fbc..9670504 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -5,11 +5,11 @@
- +
- +
diff --git a/frontend/auth-session.js b/frontend/auth-session.js new file mode 100644 index 0000000..1f33894 --- /dev/null +++ b/frontend/auth-session.js @@ -0,0 +1,119 @@ +const LEGACY_AUTH_KEYS = ['token', 'role', 'departmentId', 'userId']; + +let accessToken = null; +let authState = null; +let refreshPromise = null; + +purgeLegacyAuthStorage(); + +export function getAccessToken() { + return accessToken; +} + +export function getAuthState() { + return authState ? { ...authState } : null; +} + +export function storeAuthState(data) { + if (!data?.token || !data?.role) { + clearAuthState(); + return null; + } + + accessToken = data.token; + authState = { + role: data.role, + departmentId: normalizeOptionalId(data.departmentId), + userId: normalizeOptionalId(data.userId), + redirect: typeof data.redirect === 'string' ? data.redirect : '/' + }; + return getAuthState(); +} + +export function clearAuthState() { + accessToken = null; + authState = null; +} + +export async function restoreSession() { + if (accessToken && authState) { + return getAuthState(); + } + + const refreshed = await refreshAccessToken(); + return refreshed ? getAuthState() : null; +} + +export async function refreshAccessToken() { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + let response; + try { + response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'same-origin' + }); + } catch (error) { + clearAuthState(); + return false; + } + + const data = await response.json().catch(() => null); + if (!response.ok || !storeAuthState(data)) { + clearAuthState(); + return false; + } + return true; + })().finally(() => { + refreshPromise = null; + }); + + return refreshPromise; +} + +export async function fetchWithAuth(endpoint, options = {}, retryOnUnauthorized = true) { + const headers = new Headers(options.headers || {}); + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + + const response = await fetch(endpoint, { + ...options, + headers, + credentials: 'same-origin' + }); + + if (response.status === 401 && retryOnUnauthorized && await refreshAccessToken()) { + return fetchWithAuth(endpoint, options, false); + } + return response; +} + +export async function logoutSession() { + try { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin' + }); + } catch (error) { + console.warn('Не удалось завершить серверную сессию'); + } finally { + clearAuthState(); + } +} + +function purgeLegacyAuthStorage() { + try { + LEGACY_AUTH_KEYS.forEach(key => localStorage.removeItem(key)); + LEGACY_AUTH_KEYS.forEach(key => sessionStorage.removeItem(key)); + } catch (error) { + console.warn('Не удалось очистить устаревшие данные авторизации в браузере'); + } +} + +function normalizeOptionalId(value) { + return value === undefined || value === null || value === '' ? null : String(value); +} diff --git a/frontend/department/index.html b/frontend/department/index.html index 4c02630..06f6b50 100644 --- a/frontend/department/index.html +++ b/frontend/department/index.html @@ -7,8 +7,6 @@ - +

Перейти в кабинет кафедры

diff --git a/frontend/edu-office/index.html b/frontend/edu-office/index.html index 66e6d71..f09f648 100644 --- a/frontend/edu-office/index.html +++ b/frontend/edu-office/index.html @@ -7,8 +7,6 @@ - +

Перейти в кабинет учебного отдела

diff --git a/frontend/index.html b/frontend/index.html index 3799de7..3a9e3aa 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,9 +5,6 @@ Авторизация - - - @@ -82,6 +79,6 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3475db2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,949 @@ +{ + "name": "magistr-frontend-checks", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "magistr-frontend-checks", + "devDependencies": { + "@opentelemetry/api": "1.9.0", + "@opentelemetry/auto-instrumentations-web": "0.65.0", + "@opentelemetry/context-zone": "2.9.0", + "@opentelemetry/exporter-trace-otlp-http": "0.220.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0", + "@opentelemetry/sdk-trace-web": "2.9.0", + "@opentelemetry/semantic-conventions": "1.43.0", + "esbuild": "0.25.12", + "zone.js": "0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-web": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-web/-/auto-instrumentations-web-0.65.0.tgz", + "integrity": "sha512-POJmrp9P4xSw0w5HOZLxRQe/bS3TkuEUSJnPo/+S75FF9yrEoUhCkUahWYwqcjsMIZmer1tsbW1gjvkx2mZsgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/instrumentation-document-load": "^0.65.0", + "@opentelemetry/instrumentation-fetch": "^0.220.0", + "@opentelemetry/instrumentation-user-interaction": "^0.64.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0" + } + }, + "node_modules/@opentelemetry/context-zone": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone/-/context-zone-2.9.0.tgz", + "integrity": "sha512-dUH2j6/jh5hgsAFvEqx+9MIjdi8OzBK1u8K6qsb2HE2R1AiR+u00GWCxbDts30JggTX4rwA8YNLO2xF5LC6EFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-zone-peer-dep": "2.9.0", + "zone.js": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/context-zone-peer-dep": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone-peer-dep/-/context-zone-peer-dep-2.9.0.tgz", + "integrity": "sha512-xECINyl1UHLNbaF1my3K5AqJGyox1ysLidplInAb/XTfBxc2CLDRuS4rUxiJs8irW/PkEDujdc/IPUqkJFnrzg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0", + "zone.js": "^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.220.0.tgz", + "integrity": "sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-document-load": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-document-load/-/instrumentation-document-load-0.65.0.tgz", + "integrity": "sha512-xAhNXUIywjqmaSkpimsuUpRr0QYebMyTn0IJS9wFWYVLyH+2Q5IYWYys4IrT60foeyf2gyFmRBQyHupblNdaMA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.23.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.220.0.tgz", + "integrity": "sha512-9LW+zgvKK6kqojB0GVqbOCRGBUWiatHPmRTusjSVdwYCImIgJRrJTKu6WhjHdejJSjRS/mGsw/w4ETZBqav3kQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/sdk-trace-web": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-user-interaction": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-user-interaction/-/instrumentation-user-interaction-0.64.0.tgz", + "integrity": "sha512-y9P9CP8WzuJBZL/h4rSa0jhpDlHTFWiuGhe/xxfyvHyA1UuI4QW2ZcsdFdQDak29J20tGZzkAoi6EqZDRU7xVg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-web": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0", + "zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-xml-http-request/-/instrumentation-xml-http-request-0.220.0.tgz", + "integrity": "sha512-9FTY+kT3z5D8f7dC+Tubd4oJTu6tOGlQmws5J8e7DLMeIUwpWmLquRFV2BuUShiAPxTMGqqsZnuT3CVVbTu0JA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/sdk-trace-web": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.220.0.tgz", + "integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-transformer": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.220.0.tgz", + "integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.220.0.tgz", + "integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.9.0.tgz", + "integrity": "sha512-LS4XlzOK3e6YYdt84m15AmRR04121rKipmifi4XFZToH1h75f7F2bzHLbB1NMgIEYJ0jSKYm4VsK5mws/5kfTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/import-in-the-middle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", + "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/zone.js": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz", + "integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 9db5f87..a8d0e3d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,22 @@ "node": ">=18" }, "scripts": { + "build": "npm run build:vendor", + "build:vendor": "node scripts/build-vendor.mjs", "test": "node --test tests/*.test.mjs", - "check": "node --check admin/js/dashboard-conflicts.js && node --check admin/js/views/dashboard.js && node --test tests/*.test.mjs" + "check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs" + }, + "devDependencies": { + "@opentelemetry/api": "1.9.0", + "@opentelemetry/auto-instrumentations-web": "0.65.0", + "@opentelemetry/context-zone": "2.9.0", + "@opentelemetry/exporter-trace-otlp-http": "0.220.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0", + "@opentelemetry/sdk-trace-web": "2.9.0", + "@opentelemetry/semantic-conventions": "1.43.0", + "esbuild": "0.25.12", + "zone.js": "0.16.0" } } diff --git a/frontend/proxy.conf b/frontend/proxy.conf new file mode 100644 index 0000000..c64c464 --- /dev/null +++ b/frontend/proxy.conf @@ -0,0 +1,10 @@ +ProxyRequests Off +ProxyPreserveHost On + +# Same-origin API для локального Docker Compose. +ProxyPass "/api" "http://backend:8080/api" connectiontimeout=5 timeout=60 retry=0 +ProxyPassReverse "/api" "http://backend:8080/api" + +# Локальная диагностика без публикации backend-порта на хосте. +ProxyPass "/actuator/health" "http://backend:8080/actuator/health" connectiontimeout=5 timeout=10 retry=0 +ProxyPassReverse "/actuator/health" "http://backend:8080/actuator/health" diff --git a/frontend/script.js b/frontend/script.js index c264a8c..125a6bf 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,34 +1,11 @@ +import { clearAuthState, storeAuthState } from './auth-session.js'; +import { initializeTelemetry } from './telemetry.js'; + +void initializeTelemetry(); + (() => { 'use strict'; - // --- OpenTelemetry Frontend Instrumentation --- - // Загружаем OTel только на продакшене (не на localhost) - if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { - import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => { - const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http'); - const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web'); - const { registerInstrumentations } = await import('https://esm.sh/@opentelemetry/instrumentation'); - const { Resource } = await import('https://esm.sh/@opentelemetry/resources'); - - const exporter = new OTLPTraceExporter({ - url: window.location.origin + '/otel/v1/traces' - }); - - const provider = new WebTracerProvider({ - resource: new Resource({ 'service.name': 'magistr-frontend' }), - }); - - provider.addSpanProcessor(new BatchSpanProcessor(exporter)); - provider.register(); - - registerInstrumentations({ - instrumentations: [getWebAutoInstrumentations()] - }); - console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде."); - }).catch(e => console.error("Ошибка загрузки OTel:", e)); - } - // ---------------------------------------------- - const form = document.getElementById('login-form'); const usernameInput = document.getElementById('username'); const passwordInput = document.getElementById('password'); @@ -42,14 +19,8 @@ // Ripple effect btnSubmit.addEventListener('click', function(e) { - const rect = this.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const ripple = document.createElement('span'); ripple.classList.add('ripple'); - ripple.style.left = `${x}px`; - ripple.style.top = `${y}px`; this.appendChild(ripple); @@ -141,15 +112,8 @@ if (response.ok) { showAlert('Вход выполнен успешно!', 'success'); - - localStorage.removeItem('token'); - localStorage.removeItem('role'); - localStorage.removeItem('departmentId'); - localStorage.removeItem('userId'); - if (data.token) localStorage.setItem('token', data.token); - if (data.role) localStorage.setItem('role', data.role); - if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); - if (data.userId) localStorage.setItem('userId', data.userId); + clearAuthState(); + storeAuthState(data); const redirect = data.redirect || '/'; setTimeout(() => { window.location.href = redirect; }, 400); diff --git a/frontend/scripts/build-vendor.mjs b/frontend/scripts/build-vendor.mjs new file mode 100644 index 0000000..21eeac9 --- /dev/null +++ b/frontend/scripts/build-vendor.mjs @@ -0,0 +1,10 @@ +import { build } from 'esbuild'; + +await build({ + entryPoints: ['telemetry/otel-entry.js'], + bundle: true, + format: 'esm', + platform: 'browser', + target: ['es2022'], + outfile: 'dist/vendor/otel.js' +}); diff --git a/frontend/security.conf b/frontend/security.conf new file mode 100644 index 0000000..f946835 --- /dev/null +++ b/frontend/security.conf @@ -0,0 +1,5 @@ +Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-Ap89pw6Ru+bI6/fvSZrLRErTB0Vlrkjhr+GBUApiIWk=' 'sha256-SIXM20kwgT/LFCXoUieNCZ2zVEtDEJLBMFkzuqTDiEs=' 'sha256-Gg5P79IEFo2naj0u+NnV/XRNhPpmG+qszzSTXOPUj1A=' 'sha256-TnG5YZ63fruUpLo7bQSZmwEy7iaTHxpbBhwz5vu9uSg=' 'sha256-XzNZtBQ5RVU4zPLtfvXv2K6GCBeoCM2HUIIQSlEaULA=' 'sha256-nULJId45lkSYkp9G7rs5Ucrzkl/RvOYcmolE2LAWGy0=' 'sha256-zxhlsg7XWHprQXI4sW8mCF1bkpJgB7ybVJwWOPVLGRE=' 'sha256-K0Xrmwt6pAGUrGVS8MwRZJExIhykcVQ8SI1byCheVwc=' 'sha256-4YmfPYnVNL8IzZiGb/0iwhc6yUgCF19Y33KOfCPVmCw=' 'sha256-iNZSE5e3vOhMyMQkK5rpl8610z7E4ZtyqgP2Svv49PU=' 'sha256-MhqAPfMbUlw1OEgW5/QfCxo+BaJmIHx/8235W2X3bVI=' 'sha256-foNl8DYrtM07r6llOCuEQkuDC3P+Z9AhhM6r+IJDuvE=' 'sha256-TZ/59DFnpMwCdrSuMVhcjfKS2+Uktb1ikcGdbop+LrY=' 'sha256-bouNTXMWL7OeoflvcP2VmNuyQ00CJIpU091rlJcNh0o=' 'sha256-SWPOf/xgJrjLW+pINXE1lz0iy53MqwWqXeVJ6eC/feI=' 'sha256-1N/gWxc/W4gFlj8zf/p3KcADNes7ROePFaTOWiqwF4E=' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog=' 'sha256-/dAGmjflKS5r+hQF1hmagGz5QABPHf+YFadZo1ne2rc=' 'sha256-Xrwk3bvnw8h9hvxxkkKe/tGHcDa4FiyIkZgQh7zZ5OM=' 'sha256-yg9xEhg50yeViXuIpHVfY4ORsctQahuBNAF5elcPo2o=' 'sha256-wJR14aiwE2jYijrg7Fjqg/N0F1aAZ5CIcUtwN05LcGg=' 'sha256-aYnI2FBeHDuscjjfM9Mw136ryNwuYO8SA40Zevg1KoY=' 'sha256-y6K4epLEZv6QKwcoJSp/0xGIN8fOl7DSHSPNd5QT6Xo=' 'sha256-tdUysXRHnX2Z4LXERihNGxflbZuvaer3/ZEbjN2vQYE=' 'sha256-scdHk/Xhx12Q7ITYKMEP661eR9qxmkAEEIEGSoSvhnk=' 'sha256-SQjLChNmhyZMwxbpXjOF6LYFFUEHnV6PvzvTEmWb7eA=' 'sha256-50MALp7OIkg8n1p4VILXcnKO8ypKpLNL/QYLOgWPOCk=' 'sha256-clWb9y4Y/RHEr3K6egWYXbH1r5Hi2fgpzDHlBdskEeo=' 'sha256-chNIGV8ioVlRP9Ypn/XhEA2EhWFAeWCuUGJL5S+vzz0=' 'sha256-W998Pje85WadRdREdYtdQtKylzNM5QOtfq5HJWoaeTc=' 'sha256-s6IpehL1Dcfvbn/QY1MA4otfjj+O7fzMbbfi7Qc6pmA=' 'sha256-AC3CbEeLPJf2W6daVbkfUpWh5e6gLviBovIHzqhb7I4=' 'sha256-+j7xWNheSULp9DWeiEHZrhTxGekhyQ9qIjUgAiPhJ50=' 'sha256-moRhvd2U0JZLbFHJNyPJuevxlkfGXw5XWMByVNv9Vig=' 'sha256-203TZuzrYa786NHRsoV1tXZ8L/9lEl+/UL46E0VAiGI=' 'sha256-032ZxPG0oQ3+/U6NXKrgm5LnBreb9401xJrjrsb+p+U=' 'sha256-JamKOmpIVQSPv+gDrYtzbxnVclnrvhrOMLmA+WaGPIE=' 'sha256-mnRyqew64Lcv5/Qgb/4HP8LfJi5MjjQCGLPt+/cJZis=' 'sha256-Fx6gwPzP46ZjBgLjBZ6QLksJxr+0rXElPelXtSQMnxE=' 'sha256-fug/5+Ut6nV0WD+m4YV23l7GiD/oMKUnUvZdMiDCZmw=' 'sha256-AkGc/9SiOd74zk72UnCdLs+k10sM4iy2uKmgoXkaHe0=' 'sha256-Ar6JjQB5XpqI8eiPMCdTp/UuHaqgHiv6I+MV5V0AqD0=' 'sha256-q3nqK4VzeI/SowYCYQ/tCfo056B/JMutuSpzbJbHczk=' 'sha256-FHr5kWe1pqEckj58767LzvY6a0Jefvryb6NDWnX+9iA=' 'sha256-3bpbcQ42x+1MqmADGJ3zikAZX1tMXcOSJO5HaQgBLd0=' 'sha256-JZXmJpz7JH1a0DyiOr/syUiirSXF8caWHSEs55ZeuvM=' 'sha256-5z4wfnbsEag4kdsuEHpgdTrXYzlAY7n+RR18FQrPaEk=' 'sha256-97KgMmG+SNbCjjLJMC4FFguhozddOvNKLVC6hCmCMsg=' 'sha256-K1k356n2KePxr1iToCyZJNdGCm9eq1GSLPiovYYcGJw=' 'sha256-Wbi+cQ7sBtwXXZqWfFgLJRH4d6W8PogiptWNj9KIw8c=' 'sha256-ZGygktZXqOYjv8SF3rIcpWd19YFA1qMbC3LhD9PPSIk=' 'sha256-C1diVwUHC99dr4LcsUyLWfgQa8cu0K/7SBK+bd24TWg=' 'sha256-gKWvnkygeYkz1lyPSLpgVWSsmFaaDT2lY+hqijL4Gd8=' 'sha256-XhqU3am1TiOnFUKLIcPS5JCePjRUcUoGqQvjnfL92ik=' 'sha256-COR0sjBXRz2BkYmsZyQELjCUGLEbVii9YMmpBGwfFhM=' 'sha256-N90MKmRow2DpYEVeqcc3uc8pOUsS4Rg4sNmkau1k0xQ=' 'sha256-qcFcRsF+ljnThVJj80IfggPdbDjDPk/ZEeeiFCw/f4s=' 'sha256-niVdTxYNBhpZEFDz7d87VgN56BaVHxkE1mKzrSiTEa8=' 'sha256-N6tSydZ64AHCaOWfwKbUhxXx2fRFDxHOaL3e3CO7GPI=' 'sha256-V3Yf08rYNHRd1lYyQK1mcEb3duonX88OA6nfz8qDvuY=' 'sha256-7VXlcg/uSZugHSa6UtIG2/44ju460LiO4M0CyQfraX8='; upgrade-insecure-requests" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set X-Content-Type-Options "nosniff" +Header always set X-Frame-Options "DENY" +Header always set Permissions-Policy "camera=(), geolocation=(), microphone=()" diff --git a/frontend/student/app.js b/frontend/student/app.js new file mode 100644 index 0000000..c2e0fa2 --- /dev/null +++ b/frontend/student/app.js @@ -0,0 +1,222 @@ +import { + clearAuthState, + fetchWithAuth, + logoutSession, + restoreSession +} from '../auth-session.js'; + +(async () => { + const session = await restoreSession(); + if (!session || session.role !== 'STUDENT') { + window.location.replace('/'); + return; + } + const groupSelect = document.getElementById('group-select'); + const grid = document.getElementById('schedule-grid'); + const statusLine = document.getElementById('status-line'); + const weekLabel = document.getElementById('week-label'); + const datePicker = document.getElementById('date-picker'); + let weekStart = startOfWeek(new Date()); + + document.getElementById('logout-link').addEventListener('click', async (event) => { + event.preventDefault(); + await logoutSession(); + window.location.replace('/'); + }); + + document.getElementById('theme-toggle-local').addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + localStorage.setItem('theme', next); + }); + document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light'; + + document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1)); + document.getElementById('today-week').addEventListener('click', () => { + weekStart = startOfWeek(new Date()); + loadSchedule(); + }); + document.getElementById('next-week').addEventListener('click', () => moveWeek(1)); + datePicker.addEventListener('change', () => { + if (datePicker.value) { + weekStart = startOfWeek(new Date(datePicker.value)); + loadSchedule(); + } + }); + groupSelect.addEventListener('change', () => { + localStorage.setItem('studentGroupId', groupSelect.value); + loadSchedule(); + }); + + init(); + + async function init() { + await loadGroups(); + await loadSchedule(); + } + + async function loadGroups() { + try { + const groups = await fetchJson('/api/groups'); + if (!groups.length) { + groupSelect.innerHTML = ''; + return; + } + const savedGroupId = localStorage.getItem('studentGroupId'); + groupSelect.innerHTML = groups.map(group => ( + `` + )).join(''); + groupSelect.value = savedGroupId && groups.some(group => String(group.id) === savedGroupId) + ? savedGroupId + : String(groups[0].id); + } catch (error) { + groupSelect.innerHTML = ''; + showStatus(error.message, true); + } + } + + async function loadSchedule() { + const groupId = groupSelect.value; + const startDate = toIsoDate(weekStart); + const endDate = toIsoDate(addDays(weekStart, 6)); + datePicker.value = startDate; + weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`; + + renderEmptyWeek('Загрузка...'); + if (!groupId) { + showStatus('Выберите группу', false); + renderEmptyWeek('Нет выбранной группы'); + return; + } + + try { + const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`); + renderWeek(lessons); + showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false); + } catch (error) { + renderEmptyWeek('Ошибка загрузки'); + showStatus(error.message, true); + } + } + + function renderWeek(lessons) { + const lessonsByDate = new Map(); + lessons.forEach(lesson => { + if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []); + lessonsByDate.get(lesson.date).push(lesson); + }); + + grid.innerHTML = daysOfWeek().map(date => { + const key = toIsoDate(date); + const dayLessons = lessonsByDate.get(key) || []; + return renderDay(date, dayLessons); + }).join(''); + } + + function renderEmptyWeek(message) { + grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join(''); + } + + function renderDay(date, lessons, emptyMessage = 'Занятий нет') { + return ` +
+
+
${escapeHtml(dayName(date))}
+
${escapeHtml(formatDate(date))}
+
+
+ ${lessons.length ? lessons.map(renderLesson).join('') : `
${escapeHtml(emptyMessage)}
`} +
+
+ `; + } + + function renderLesson(lesson) { + const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : ''; + const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length + ? lesson.subgroupNames.join(', ') + : lesson.subgroupName; + return ` +
+
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}
+
${escapeHtml(lesson.subjectName)}
+
+ ${escapeHtml(lesson.lessonTypeName)} + ${subgroups ? `${escapeHtml(subgroups)}` : ''} + ${escapeHtml(lesson.lessonFormat)} + ${escapeHtml(lesson.teacherName)} + ${escapeHtml(lesson.classroomName)} + ${groups ? `${escapeHtml(groups)}` : ''} +
+
+ `; + } + + function moveWeek(delta) { + weekStart = addDays(weekStart, delta * 7); + loadSchedule(); + } + + function daysOfWeek() { + return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)); + } + + async function fetchJson(url) { + const response = await fetchWithAuth(url); + const data = await response.json().catch(() => null); + if (response.status === 401) { + clearAuthState(); + window.location.replace('/'); + throw new Error('Требуется повторный вход в систему'); + } + if (!response.ok) { + throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); + } + return data; + } + + function showStatus(message, isError) { + statusLine.textContent = message; + statusLine.classList.toggle('error', isError); + } + + function startOfWeek(date) { + const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const day = copy.getDay() || 7; + copy.setDate(copy.getDate() - day + 1); + return copy; + } + + function addDays(date, days) { + const copy = new Date(date); + copy.setDate(copy.getDate() + days); + return copy; + } + + function toIsoDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + function formatDate(date) { + return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } + + function dayName(date) { + const value = date.toLocaleDateString('ru-RU', { weekday: 'long' }); + return value.charAt(0).toUpperCase() + value.slice(1); + } + + function trimTime(value) { + return String(value || '').slice(0, 5); + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } +})(); + diff --git a/frontend/student/index.html b/frontend/student/index.html index b81f5c0..44f253e 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -4,304 +4,7 @@ Расписание студента - +
@@ -344,275 +47,6 @@
- + diff --git a/frontend/student/style.css b/frontend/student/style.css new file mode 100644 index 0000000..12952fd --- /dev/null +++ b/frontend/student/style.css @@ -0,0 +1,297 @@ +* { + box-sizing: border-box; +} + +:root { + --bg: #f4f7fb; + --panel: #ffffff; + --panel-border: #dbe3ef; + --text: #162033; + --muted: #66758f; + --accent: #4F46E5; + --accent-strong: #4338CA; + --blue: #2563eb; + --warning: #b45309; + --shadow: 0 12px 34px rgba(22, 32, 51, 0.08); +} + +[data-theme="dark"] { + --bg: #0e131d; + --panel: #151d2b; + --panel-border: #263449; + --text: #edf3fb; + --muted: #9cadc5; + --accent: #8B5CF6; + --accent-strong: #7C3AED; + --blue: #60a5fa; + --warning: #f59e0b; + --shadow: 0 18px 44px rgba(0, 0, 0, 0.28); +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; +} + +.shell { + max-width: 1240px; + margin: 0 auto; + padding: 24px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.title h1 { + margin: 0 0 4px; + font-size: 28px; + font-weight: 700; + letter-spacing: 0; +} + +.title span { + color: var(--muted); + font-size: 14px; +} + +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +.btn, +.select, +.date-input { + min-height: 40px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + color: var(--text); + font: inherit; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + padding: 0 14px; + cursor: pointer; + text-decoration: none; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent-strong); +} + +.btn-primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.btn-primary:hover { + background: var(--accent-strong); + color: #fff; +} + +.btn-secondary { + background: var(--panel); + border-color: var(--panel-border); + color: var(--text); +} + +.btn-secondary:hover { + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); +} + +.btn-danger-subtle { + background: #FEF2F2; + border-color: #FECACA; + color: #B91C1C; +} + +[data-theme="dark"] .btn-danger-subtle { + background: rgba(239, 68, 68, 0.13); + border-color: rgba(239, 68, 68, 0.25); + color: #FECACA; +} + +.btn-icon-md { + width: 40px; + height: 40px; + padding: 0; + border-radius: 8px; +} + +.btn-icon-md svg { + width: 20px; + height: 20px; +} + +.btn-md { + min-height: 40px; + padding: 0 16px; + font-size: 14px; + font-weight: 600; +} + +.toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + gap: 12px; + margin-bottom: 16px; + padding: 14px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.select, +.date-input { + width: 100%; + padding: 0 12px; +} + +.week-nav { + display: flex; + gap: 8px; +} + +.status { + min-height: 22px; + margin-bottom: 12px; + color: var(--muted); + font-size: 14px; +} + +.schedule { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 10px; +} + +.day { + min-height: 260px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + overflow: hidden; +} + +.day-head { + padding: 12px; + border-bottom: 1px solid var(--panel-border); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.day-name { + font-weight: 700; +} + +.day-date { + margin-top: 2px; + color: var(--muted); + font-size: 13px; +} + +.lesson-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; +} + +.lesson { + border-left: 4px solid var(--blue); + border-radius: 7px; + background: color-mix(in srgb, var(--blue) 9%, transparent); + padding: 10px; +} + +.lesson-time { + color: var(--muted); + font-size: 13px; + font-weight: 600; +} + +.lesson-title { + margin-top: 5px; + font-weight: 700; + line-height: 1.25; +} + +.lesson-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.chip { + border: 1px solid var(--panel-border); + border-radius: 999px; + padding: 3px 8px; + color: var(--muted); + font-size: 12px; + background: var(--panel); +} + +.empty { + padding: 16px 10px; + color: var(--muted); + font-size: 13px; +} + +.error { + color: var(--warning); +} + +@media (max-width: 980px) { + .schedule { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .shell { + padding: 16px; + } + + .topbar, + .actions, + .toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .topbar, + .actions { + display: grid; + } + + .week-nav { + justify-content: stretch; + } + + .week-nav .btn { + flex: 1; + } + + .schedule { + grid-template-columns: 1fr; + } +} + diff --git a/frontend/style.css b/frontend/style.css index 4e7736f..f4147be 100755 --- a/frontend/style.css +++ b/frontend/style.css @@ -447,6 +447,11 @@ body { animation: ripple 0.6s linear; background-color: rgba(255, 255, 255, 0.3); pointer-events: none; + left: 50%; + top: 50%; + width: 24px; + height: 24px; + margin: -12px 0 0 -12px; } @keyframes ripple { @@ -498,6 +503,15 @@ body { transition: transform 0.4s ease; } +.theme-toggle-icon-enter { + animation: theme-toggle-icon-enter 0.4s ease; +} + +@keyframes theme-toggle-icon-enter { + from { opacity: 0; transform: rotate(-90deg) scale(0.5); } + to { opacity: 1; transform: rotate(0deg) scale(1); } +} + .theme-toggle:hover { background: var(--button-secondary-bg-hover); transform: translateY(-1px); diff --git a/frontend/teacher/app.js b/frontend/teacher/app.js new file mode 100644 index 0000000..e0c9ddb --- /dev/null +++ b/frontend/teacher/app.js @@ -0,0 +1,246 @@ +import { + clearAuthState, + fetchWithAuth, + logoutSession, + restoreSession +} from '../auth-session.js'; + +(async () => { + const session = await restoreSession(); + if (!session || session.role !== 'TEACHER') { + window.location.replace('/'); + return; + } + const teacherId = session.userId; + const grid = document.getElementById('schedule-grid'); + const statusLine = document.getElementById('status-line'); + const weekLabel = document.getElementById('week-label'); + const datePicker = document.getElementById('date-picker'); + let weekStart = startOfWeek(new Date()); + + document.getElementById('logout-link').addEventListener('click', async (event) => { + event.preventDefault(); + await logoutSession(); + window.location.replace('/'); + }); + + document.getElementById('theme-toggle-local').addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + localStorage.setItem('theme', next); + }); + document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light'; + + document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1)); + document.getElementById('today-week').addEventListener('click', () => { + weekStart = startOfWeek(new Date()); + loadSchedule(); + }); + document.getElementById('next-week').addEventListener('click', () => moveWeek(1)); + datePicker.addEventListener('change', () => { + if (datePicker.value) { + weekStart = startOfWeek(new Date(datePicker.value)); + loadSchedule(); + } + }); + + loadSchedule(); + + async function loadSchedule() { + const startDate = toIsoDate(weekStart); + const endDate = toIsoDate(addDays(weekStart, 6)); + datePicker.value = startDate; + weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`; + + if (!teacherId) { + renderEmptyWeek('Не удалось определить преподавателя'); + showStatus('Выполните вход заново, чтобы получить идентификатор пользователя', true); + return; + } + + renderEmptyWeek('Загрузка...'); + try { + const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`); + renderWeek(lessons); + showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false); + } catch (error) { + renderEmptyWeek('Ошибка загрузки'); + showStatus(error.message, true); + } + } + + function renderWeek(lessons) { + if (window.matchMedia('(max-width: 980px)').matches) { + renderMobileWeek(lessons); + return; + } + + const slots = collectSlots(lessons); + const lessonsByKey = new Map(); + lessons.forEach(lesson => { + lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson); + }); + + grid.innerHTML = [ + '
', + ...daysOfWeek().map(date => renderHead(date)), + ...slots.flatMap(slot => [ + `
${escapeHtml(slot.label)}
`, + ...daysOfWeek().map(date => renderCell(lessonsByKey.get(`${toIsoDate(date)}:${slot.order}`))) + ]) + ].join(''); + } + + function renderMobileWeek(lessons) { + const lessonsByDate = new Map(); + lessons.forEach(lesson => { + if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []); + lessonsByDate.get(lesson.date).push(lesson); + }); + grid.innerHTML = daysOfWeek().map(date => { + const key = toIsoDate(date); + const dayLessons = lessonsByDate.get(key) || []; + return ` +
+ ${renderHead(date)} +
+ ${dayLessons.length ? dayLessons.map(renderLesson).join('') : '
Занятий нет
'} +
+
+ `; + }).join(''); + } + + function renderEmptyWeek(message) { + if (window.matchMedia('(max-width: 980px)').matches) { + grid.innerHTML = daysOfWeek().map(date => ` +
+ ${renderHead(date)} +
${escapeHtml(message)}
+
+ `).join(''); + return; + } + + grid.innerHTML = [ + '
', + ...daysOfWeek().map(date => renderHead(date)), + `
Неделя
`, + ...daysOfWeek().map(() => `
${escapeHtml(message)}
`) + ].join(''); + } + + function renderHead(date) { + return ` +
+ ${escapeHtml(dayName(date))} + ${escapeHtml(formatDate(date))} +
+ `; + } + + function renderCell(lesson) { + return `
${lesson ? renderLesson(lesson) : '
Нет занятия
'}
`; + } + + function renderLesson(lesson) { + const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : ''; + const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length + ? lesson.subgroupNames.join(', ') + : lesson.subgroupName; + return ` +
+
${escapeHtml(lesson.subjectName)}
+
+ ${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))} + ${escapeHtml(lesson.lessonTypeName)} + ${subgroups ? `${escapeHtml(subgroups)}` : ''} + ${escapeHtml(lesson.classroomName)} + ${groups ? `${escapeHtml(groups)}` : ''} +
+
+ `; + } + + function collectSlots(lessons) { + const slots = new Map(); + lessons.forEach(lesson => { + slots.set(lesson.timeSlotOrder, { + order: lesson.timeSlotOrder, + label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}` + }); + }); + if (!slots.size) { + slots.set(1, { order: 1, label: 'Расписание' }); + } + return [...slots.values()].sort((a, b) => a.order - b.order); + } + + function moveWeek(delta) { + weekStart = addDays(weekStart, delta * 7); + loadSchedule(); + } + + function daysOfWeek() { + return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)); + } + + async function fetchJson(url) { + const response = await fetchWithAuth(url); + const data = await response.json().catch(() => null); + if (response.status === 401) { + clearAuthState(); + window.location.replace('/'); + throw new Error('Требуется повторный вход в систему'); + } + if (!response.ok) { + throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); + } + return data; + } + + function showStatus(message, isError) { + statusLine.textContent = message; + statusLine.classList.toggle('error', isError); + } + + function startOfWeek(date) { + const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const day = copy.getDay() || 7; + copy.setDate(copy.getDate() - day + 1); + return copy; + } + + function addDays(date, days) { + const copy = new Date(date); + copy.setDate(copy.getDate() + days); + return copy; + } + + function toIsoDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + function formatDate(date) { + return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } + + function dayName(date) { + const value = date.toLocaleDateString('ru-RU', { weekday: 'long' }); + return value.charAt(0).toUpperCase() + value.slice(1); + } + + function trimTime(value) { + return String(value || '').slice(0, 5); + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } +})(); + diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index 846f4c1..9e7a1cd 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -4,310 +4,7 @@ Расписание преподавателя - +
@@ -347,299 +44,6 @@
- + diff --git a/frontend/teacher/style.css b/frontend/teacher/style.css new file mode 100644 index 0000000..78f8577 --- /dev/null +++ b/frontend/teacher/style.css @@ -0,0 +1,303 @@ +* { + box-sizing: border-box; +} + +:root { + --bg: #f5f6f8; + --panel: #ffffff; + --panel-border: #d8dee8; + --text: #172033; + --muted: #67758d; + --accent: #4F46E5; + --accent-strong: #4338CA; + --green: #047857; + --orange: #b45309; + --shadow: 0 12px 30px rgba(23, 32, 51, 0.08); +} + +[data-theme="dark"] { + --bg: #10151f; + --panel: #171f2e; + --panel-border: #2a374b; + --text: #eef4fb; + --muted: #a2b0c5; + --accent: #8B5CF6; + --accent-strong: #7C3AED; + --green: #34d399; + --orange: #f59e0b; + --shadow: 0 18px 42px rgba(0, 0, 0, 0.28); +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; +} + +.shell { + max-width: 1240px; + margin: 0 auto; + padding: 24px; +} + +.topbar, +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.topbar { + margin-bottom: 18px; +} + +.title h1 { + margin: 0 0 4px; + font-size: 28px; + font-weight: 700; + letter-spacing: 0; +} + +.title span { + color: var(--muted); + font-size: 14px; +} + +.toolbar { + margin-bottom: 16px; + padding: 14px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.actions, +.week-nav { + display: flex; + align-items: center; + gap: 8px; +} + +.btn, +.date-input { + min-height: 40px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + color: var(--text); + font: inherit; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + padding: 0 14px; + cursor: pointer; + text-decoration: none; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent-strong); +} + +.btn-primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.btn-primary:hover { + background: var(--accent-strong); + color: #fff; +} + +.btn-secondary { + background: var(--panel); + border-color: var(--panel-border); + color: var(--text); +} + +.btn-secondary:hover { + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); +} + +.btn-danger-subtle { + background: #FEF2F2; + border-color: #FECACA; + color: #B91C1C; +} + +[data-theme="dark"] .btn-danger-subtle { + background: rgba(239, 68, 68, 0.13); + border-color: rgba(239, 68, 68, 0.25); + color: #FECACA; +} + +.btn-icon-md { + width: 40px; + height: 40px; + padding: 0; + border-radius: 8px; +} + +.btn-icon-md svg { + width: 20px; + height: 20px; +} + +.btn-md { + min-height: 40px; + padding: 0 16px; + font-size: 14px; + font-weight: 600; +} + +.date-input { + padding: 0 12px; +} + +.status { + min-height: 22px; + margin-bottom: 12px; + color: var(--muted); + font-size: 14px; +} + +.schedule { + display: grid; + grid-template-columns: 96px repeat(7, minmax(0, 1fr)); + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + overflow: hidden; +} + +.corner, +.day-head, +.time-cell, +.day-cell { + border-right: 1px solid var(--panel-border); + border-bottom: 1px solid var(--panel-border); +} + +.corner, +.day-head { + min-height: 58px; + padding: 10px; + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.day-head strong { + display: block; +} + +.day-head span { + color: var(--muted); + font-size: 13px; +} + +.time-cell { + padding: 10px; + color: var(--muted); + font-size: 13px; + background: color-mix(in srgb, var(--panel-border) 26%, transparent); +} + +.day-cell { + min-height: 118px; + padding: 8px; +} + +.lesson { + height: 100%; + min-height: 96px; + border-left: 4px solid var(--green); + border-radius: 7px; + background: color-mix(in srgb, var(--green) 10%, transparent); + padding: 9px; +} + +.lesson-title { + font-weight: 700; + line-height: 1.25; +} + +.lesson-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.chip { + border: 1px solid var(--panel-border); + border-radius: 999px; + padding: 3px 8px; + color: var(--muted); + font-size: 12px; + background: var(--panel); +} + +.empty { + color: var(--muted); + font-size: 13px; +} + +.error { + color: var(--orange); +} + +@media (max-width: 980px) { + .schedule { + display: block; + border: 0; + background: transparent; + box-shadow: none; + } + + .mobile-day { + margin-bottom: 10px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--panel); + overflow: hidden; + } + + .mobile-day .day-head { + border-right: 0; + } + + .mobile-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + } +} + +@media (max-width: 640px) { + .shell { + padding: 16px; + } + + .topbar, + .toolbar, + .actions { + display: grid; + align-items: stretch; + } + + .week-nav .btn { + flex: 1; + } +} + diff --git a/frontend/telemetry.js b/frontend/telemetry.js new file mode 100644 index 0000000..0dc59f1 --- /dev/null +++ b/frontend/telemetry.js @@ -0,0 +1,13 @@ +let telemetryPromise = null; + +export function initializeTelemetry() { + if (['localhost', '127.0.0.1', '::1'].includes(window.location.hostname)) { + return Promise.resolve(); + } + if (!telemetryPromise) { + telemetryPromise = import('/vendor/otel.js').catch(error => { + console.warn('Не удалось инициализировать браузерную телеметрию'); + }); + } + return telemetryPromise; +} diff --git a/frontend/telemetry/otel-entry.js b/frontend/telemetry/otel-entry.js new file mode 100644 index 0000000..ae5e719 --- /dev/null +++ b/frontend/telemetry/otel-entry.js @@ -0,0 +1,40 @@ +import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +const exporter = new OTLPTraceExporter({ + url: `${window.location.origin}/otel/v1/traces` +}); + +const provider = new WebTracerProvider({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'magistr-frontend' + }), + spanProcessors: [new BatchSpanProcessor(exporter)] +}); + +provider.register({ + contextManager: new ZoneContextManager() +}); + +registerInstrumentations({ + instrumentations: [ + getWebAutoInstrumentations({ + '@opentelemetry/instrumentation-fetch': { + propagateTraceHeaderCorsUrls: [window.location.origin], + clearTimingResources: true + }, + '@opentelemetry/instrumentation-xml-http-request': { + propagateTraceHeaderCorsUrls: [window.location.origin], + clearTimingResources: true + } + }) + ] +}); + +console.info('Браузерная телеметрия OpenTelemetry инициализирована'); diff --git a/frontend/tests/auth-session.test.mjs b/frontend/tests/auth-session.test.mjs new file mode 100644 index 0000000..8924bd3 --- /dev/null +++ b/frontend/tests/auth-session.test.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +let importSequence = 0; + +class StorageMock { + constructor(initial = {}) { + this.values = new Map(Object.entries(initial)); + this.removed = []; + } + + getItem(key) { + return this.values.has(key) ? this.values.get(key) : null; + } + + setItem(key, value) { + this.values.set(key, String(value)); + } + + removeItem(key) { + this.removed.push(key); + this.values.delete(key); + } +} + +async function loadAuthModule() { + importSequence += 1; + return import(`../auth-session.js?test=${importSequence}`); +} + +function installBrowserMocks() { + globalThis.localStorage = new StorageMock({ + token: 'устаревший-access-token', + role: 'ADMIN', + departmentId: '7', + userId: '11', + theme: 'dark' + }); + globalThis.sessionStorage = new StorageMock({ token: 'устаревший-session-token' }); +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +test('login сохраняет access JWT только в памяти и очищает legacy Web Storage', async () => { + installBrowserMocks(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return jsonResponse({ ok: true }); + }; + + const auth = await loadAuthModule(); + const state = auth.storeAuthState({ + token: 'access-login', + role: 'ADMIN', + departmentId: 7, + userId: 11, + redirect: '/admin/' + }); + + assert.equal(auth.getAccessToken(), 'access-login'); + assert.deepEqual(state, { + role: 'ADMIN', + departmentId: '7', + userId: '11', + redirect: '/admin/' + }); + assert.equal(localStorage.getItem('token'), null); + assert.equal(localStorage.getItem('role'), null); + assert.equal(localStorage.getItem('theme'), 'dark'); + + await auth.fetchWithAuth('/api/test'); + assert.equal(new Headers(calls[0].options.headers).get('Authorization'), 'Bearer access-login'); +}); + +test('reload восстанавливает память через HttpOnly refresh-cookie', async () => { + installBrowserMocks(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return jsonResponse({ + token: 'access-after-reload', + role: 'DEPARTMENT', + departmentId: 4, + userId: 15, + redirect: '/admin/#department-workspace' + }); + }; + + const auth = await loadAuthModule(); + const state = await auth.restoreSession(); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, '/api/auth/refresh'); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.credentials, 'same-origin'); + assert.equal(auth.getAccessToken(), 'access-after-reload'); + assert.equal(state.role, 'DEPARTMENT'); +}); + +test('401 запускает одну ротацию refresh и повторяет запрос с новым access JWT', async () => { + installBrowserMocks(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + if (calls.length === 1) return jsonResponse({ message: 'Истёк токен' }, 401); + if (url === '/api/auth/refresh') { + return jsonResponse({ token: 'access-rotated', role: 'ADMIN' }); + } + return jsonResponse({ success: true }); + }; + + const auth = await loadAuthModule(); + auth.storeAuthState({ token: 'access-old', role: 'ADMIN' }); + const response = await auth.fetchWithAuth('/api/protected'); + + assert.equal(response.status, 200); + assert.deepEqual(calls.map(call => call.url), [ + '/api/protected', + '/api/auth/refresh', + '/api/protected' + ]); + assert.equal(new Headers(calls[0].options.headers).get('Authorization'), 'Bearer access-old'); + assert.equal(new Headers(calls[2].options.headers).get('Authorization'), 'Bearer access-rotated'); +}); + +test('параллельные запросы используют одну refresh-ротацию', async () => { + installBrowserMocks(); + let refreshCalls = 0; + let releaseRefresh; + const refreshGate = new Promise(resolve => { + releaseRefresh = resolve; + }); + globalThis.fetch = async url => { + if (url === '/api/auth/refresh') { + refreshCalls += 1; + await refreshGate; + return jsonResponse({ token: 'access-shared', role: 'ADMIN' }); + } + return jsonResponse({ success: true }); + }; + + const auth = await loadAuthModule(); + const first = auth.refreshAccessToken(); + const second = auth.refreshAccessToken(); + releaseRefresh(); + + assert.equal(await first, true); + assert.equal(await second, true); + assert.equal(refreshCalls, 1); +}); + +test('logout отзывает cookie-сессию и очищает access JWT из памяти', async () => { + installBrowserMocks(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return jsonResponse({ success: true }); + }; + + const auth = await loadAuthModule(); + auth.storeAuthState({ token: 'access-before-logout', role: 'STUDENT', userId: 22 }); + await auth.logoutSession(); + + assert.equal(auth.getAccessToken(), null); + assert.equal(auth.getAuthState(), null); + assert.equal(calls[0].url, '/api/auth/logout'); + assert.equal(calls[0].options.credentials, 'same-origin'); +}); diff --git a/frontend/tests/local-date-format.test.mjs b/frontend/tests/local-date-format.test.mjs new file mode 100644 index 0000000..684a658 --- /dev/null +++ b/frontend/tests/local-date-format.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.TZ = 'Europe/Moscow'; + +const { formatLocalDate } = await import('../admin/js/utils.js'); + +test('date-only форматируется по московским компонентам, а не через UTC', () => { + assert.equal(formatLocalDate(new Date('2026-01-01T21:15:00Z')), '2026-01-02'); +}); + +test('date-only сохраняет локальный день до московской полуночи', () => { + assert.equal(formatLocalDate(new Date('2026-01-01T20:59:59Z')), '2026-01-01'); +}); + +test('некорректная дата отклоняется явной русской ошибкой', () => { + assert.throws(() => formatLocalDate(new Date('invalid')), /Ожидалась корректная дата/); +}); diff --git a/frontend/tests/security-policy.test.mjs b/frontend/tests/security-policy.test.mjs new file mode 100644 index 0000000..b369f9c --- /dev/null +++ b/frontend/tests/security-policy.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const frontendRoot = fileURLToPath(new URL('..', import.meta.url)); +const sourceExtensions = new Set(['.html', '.js', '.mjs', '.css']); + +async function sourceFiles(directory = frontendRoot) { + const result = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'tests') continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + result.push(...await sourceFiles(path)); + } else if (sourceExtensions.has(extname(entry.name))) { + result.push(path); + } + } + return result; +} + +async function sourceCorpus() { + const files = await sourceFiles(); + return Promise.all(files.map(async path => ({ path, text: await readFile(path, 'utf8') }))); +} + +test('frontend не исполняет удалённые или inline-скрипты', async () => { + const corpus = await sourceCorpus(); + for (const { path, text } of corpus) { + assert.doesNotMatch(text, /(?:from\s+|import\s*\()\s*['"]https?:\/\//, path); + if (extname(path) === '.html') { + assert.doesNotMatch(text, /]*\bsrc=)[^>]*>/i, path); + assert.doesNotMatch(text, / { + const corpus = await sourceCorpus(); + const authStorage = /(?:localStorage|sessionStorage)\.(?:getItem|setItem)\(\s*['"](?:token|role|departmentId|userId)['"]/; + for (const { path, text } of corpus) { + assert.doesNotMatch(text, authStorage, path); + } +}); + +test('CSP запрещает произвольные inline-ресурсы и покрывает style-атрибуты хэшами', async () => { + const corpus = await sourceCorpus(); + const config = await readFile(join(frontendRoot, 'security.conf'), 'utf8'); + + assert.match(config, /script-src 'self'/); + assert.match(config, /script-src-attr 'none'/); + assert.match(config, /style-src 'self'/); + assert.match(config, /style-src-attr 'unsafe-hashes'/); + assert.match(config, /connect-src 'self'/); + assert.doesNotMatch(config, /'unsafe-inline'|'unsafe-eval'/); + + const expectedHashes = new Set(); + for (const { path, text } of corpus) { + assert.doesNotMatch(text, /\.style\.(?:cssText|[A-Za-z-]+)\s*=|\.style\.setProperty|setAttribute\(\s*['"]style/i, path); + for (const match of text.matchAll(/style="([^"]*)"/g)) { + assert.doesNotMatch(match[1], /\$\{/, `Динамический style-атрибут в ${path}`); + expectedHashes.add(`sha256-${createHash('sha256').update(match[1]).digest('base64')}`); + } + } + + const configuredHashes = new Set( + Array.from(config.matchAll(/'((?:sha256)-[^']+)'/g), match => match[1]) + ); + assert.deepEqual(configuredHashes, expectedHashes); +}); + +test('Docker-образ включает локальный telemetry bundle и security headers', async () => { + const dockerfile = await readFile(join(frontendRoot, 'Dockerfile'), 'utf8'); + const telemetry = await readFile(join(frontendRoot, 'telemetry.js'), 'utf8'); + assert.match(dockerfile, /npm ci/); + assert.match(dockerfile, /npm run build:vendor/); + assert.match(dockerfile, /scripts\/build-vendor\.mjs/); + assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//); + assert.match(dockerfile, /magistr-security\.conf/); + assert.match(telemetry, /import\('\/vendor\/otel\.js'\)/); + assert.doesNotMatch(telemetry, /https?:\/\//); +}); + +test('сообщения об ошибках не вставляют текст исключения через innerHTML', async () => { + const files = [ + 'admin/js/main.js', + 'admin/settings/js/main.js', + 'admin/settings/js/views/database.js', + 'admin/js/views/teacher-requests.js' + ]; + + for (const relativePath of files) { + const source = await readFile(join(frontendRoot, relativePath), 'utf8'); + assert.doesNotMatch(source, /\$\{[^}\n]*(?:e|error|err)\.message[^}\n]*\}/, relativePath); + } + + const utils = await readFile(join(frontendRoot, 'admin/js/utils.js'), 'utf8'); + assert.match(utils, /element\.textContent = message/); + assert.match(utils, /container\.replaceChildren\(element\)/); + assert.match(utils, /cell\.textContent = message/); + assert.match(utils, /tbody\.replaceChildren\(row\)/); +}); + +test('пароли скрыты и помечены для менеджера паролей', async () => { + const users = await readFile(join(frontendRoot, 'admin/views/users.html'), 'utf8'); + const database = await readFile(join(frontendRoot, 'admin/settings/views/database.html'), 'utf8'); + const teacherRequests = await readFile(join(frontendRoot, 'admin/js/views/teacher-requests.js'), 'utf8'); + + assert.match(users, /id="new-password"[^>]*type="password"|type="password"[^>]*id="new-password"/); + assert.match(users, /id="new-password"[^>]*autocomplete="new-password"/); + assert.match(database, /id="tenant-password"[^>]*type="password"|type="password"[^>]*id="tenant-password"/); + assert.match(database, /id="tenant-password"[^>]*autocomplete="new-password"/); + assert.match(teacherRequests, /type="password"[^>]*class="teacher-request-password"[^>]*autocomplete="new-password"/); +}); + +test('tenant UI и production-логи соблюдают русский языковой регламент', async () => { + const database = await readFile(join(frontendRoot, 'admin/settings/js/views/database.js'), 'utf8'); + assert.doesNotMatch(database, /\bOnline\b|\bOffline\b/); + assert.match(database, /Доступно/); + assert.match(database, /Недоступно/); + + const backendRoot = join(frontendRoot, '..', 'backend', 'src', 'main', 'java', 'com', 'magistr', 'app', 'config', 'tenant'); + const logSources = await Promise.all([ + 'TenantRoutingDataSource.java', + 'TenantDataSourceConfig.java', + 'TenantInterceptor.java' + ].map(name => readFile(join(backendRoot, name), 'utf8'))); + const tenantSources = logSources.flatMap(source => + Array.from(source.matchAll(/log\.(?:debug|info|warn|error)\("([^"]*)"/g), match => match[1]) + ).join('\n'); + assert.doesNotMatch( + tenantSources, + /Database API request|Unknown tenant|Resolved tenant|default DataSource|H2 in-memory|errorType|Tenant-БД|tenant-БД|startup lifecycle|startup Hikari pool/ + ); + + const frontendSources = (await sourceCorpus()) + .filter(({ path }) => extname(path) === '.js') + .map(({ text }) => text) + .join('\n'); + assert.doesNotMatch(frontendSources, /console\.(?:log|info|warn|error)\([^\n]*(?:Failed|Error)/); + assert.doesNotMatch(frontendSources, /console\.(?:warn|error)\([^\n]*\.message/); +}); + +test('единая матрица ролей согласует admin и settings для учебного отдела', async () => { + const { + ADMIN_APP_ROLES, + SETTINGS_ROLES, + capabilitiesForRole + } = await import('../admin/js/role-capabilities.js'); + const educationOffice = capabilitiesForRole('EDUCATION_OFFICE'); + + assert.ok(ADMIN_APP_ROLES.includes('EDUCATION_OFFICE')); + assert.ok(SETTINGS_ROLES.includes('EDUCATION_OFFICE')); + assert.ok(educationOffice.adminTabs.includes('academic-calendar')); + assert.deepEqual(educationOffice.settingsTabs, ['time-slots', 'edu-forms']); + assert.equal(educationOffice.defaultSettingsTab, 'time-slots'); + assert.equal(capabilitiesForRole('DEPARTMENT').settingsTabs.length, 0); + + const adminMain = await readFile(join(frontendRoot, 'admin/js/main.js'), 'utf8'); + const settingsMain = await readFile(join(frontendRoot, 'admin/settings/js/main.js'), 'utf8'); + assert.match(adminMain, /from '\.\/role-capabilities\.js'/); + assert.match(settingsMain, /from '\.\.\/\.\.\/js\/role-capabilities\.js'/); + assert.doesNotMatch(adminMain, /const ROLE_NAVIGATION/); + assert.doesNotMatch(settingsMain, /const ROLE_TABS/); +}); diff --git a/frontend/theme-toggle.js b/frontend/theme-toggle.js index 4a389d4..30866c9 100755 --- a/frontend/theme-toggle.js +++ b/frontend/theme-toggle.js @@ -35,14 +35,9 @@ /* Swap icon immediately and trigger a gentle rotate via CSS */ btn.innerHTML = goLight ? sunSVG : moonSVG; const svg = btn.querySelector('svg'); - svg.style.transition = 'none'; - svg.style.transform = 'rotate(-90deg) scale(0.5)'; - svg.style.opacity = '0'; - requestAnimationFrame(() => { - svg.style.transition = 'transform 0.4s ease, opacity 0.3s ease'; - svg.style.transform = 'rotate(0deg) scale(1)'; - svg.style.opacity = '1'; - }); + svg.classList.remove('theme-toggle-icon-enter'); + void svg.offsetWidth; + svg.classList.add('theme-toggle-icon-enter'); }); /* Where to place the button */ diff --git a/scripts/deploy-images.sh b/scripts/deploy-images.sh new file mode 100644 index 0000000..1bef22c --- /dev/null +++ b/scripts/deploy-images.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NAMESPACE=${KUBE_NAMESPACE:-magistr} +BACKEND_REF=${1:-} +FRONTEND_REF=${2:-} + +log() { + printf '[CI/CD] %s\n' "$1" +} + +fail() { + printf '[CI/CD] Ошибка: %s\n' "$1" >&2 + exit 1 +} + +require_digest() { + local image_ref=$1 + local component=$2 + if [[ ! "$image_ref" =~ ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ ]]; then + fail "для ${component} требуется полный immutable image digest" + fi +} + +require_digest "$BACKEND_REF" "backend" +require_digest "$FRONTEND_REF" "frontend" + +previous_backend=$(kubectl get deployment backend -n "$NAMESPACE" \ + -o 'jsonpath={.spec.template.spec.containers[?(@.name=="backend")].image}') +previous_frontend=$(kubectl get deployment frontend -n "$NAMESPACE" \ + -o 'jsonpath={.spec.template.spec.containers[?(@.name=="frontend")].image}') + +[[ -n "$previous_backend" ]] || fail "не удалось определить текущий образ backend" +[[ -n "$previous_frontend" ]] || fail "не удалось определить текущий образ frontend" + +rollback() { + local failed=0 + log "Rollout завершился ошибкой, возвращаем предыдущие образы" + kubectl set image deployment/backend backend="$previous_backend" -n "$NAMESPACE" || failed=1 + kubectl set image deployment/frontend frontend="$previous_frontend" -n "$NAMESPACE" || failed=1 + kubectl rollout status deployment/frontend -n "$NAMESPACE" --timeout=120s || failed=1 + kubectl rollout status deployment/backend -n "$NAMESPACE" --timeout=300s || failed=1 + if [[ "$failed" -ne 0 ]]; then + printf '[CI/CD] Ошибка: автоматический rollback требует вмешательства оператора\n' >&2 + return 1 + fi + log "Предыдущие образы восстановлены" +} + +log "Применяем проверенные image digests" +if ! kubectl set image deployment/backend backend="$BACKEND_REF" -n "$NAMESPACE" \ + || ! kubectl set image deployment/frontend frontend="$FRONTEND_REF" -n "$NAMESPACE"; then + rollback || true + fail "не удалось обновить Deployment" +fi + +if ! kubectl rollout status deployment/frontend -n "$NAMESPACE" --timeout=120s \ + || ! kubectl rollout status deployment/backend -n "$NAMESPACE" --timeout=300s; then + rollback || true + fail "новая ревизия не достигла состояния Ready" +fi + +log "Immutable rollout успешно завершён" diff --git a/scripts/test-deploy-images.sh b/scripts/test-deploy-images.sh new file mode 100644 index 0000000..f62ea11 --- /dev/null +++ b/scripts/test-deploy-images.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +DEPLOY_SCRIPT="$ROOT_DIR/scripts/deploy-images.sh" +CALL_LOG=$(mktemp) +trap 'rm -f "$CALL_LOG"' EXIT + +OLD_BACKEND='registry.local/magistr-backend@sha256:1111111111111111111111111111111111111111111111111111111111111111' +OLD_FRONTEND='registry.local/magistr-frontend@sha256:2222222222222222222222222222222222222222222222222222222222222222' +NEW_BACKEND='registry.local/magistr-backend@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +NEW_FRONTEND='registry.local/magistr-frontend@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + +kubectl() { + printf '%s\n' "$*" >> "$CALL_LOG" + if [[ "$1" == "get" && "$3" == "backend" ]]; then + printf '%s' "$OLD_BACKEND" + return 0 + fi + if [[ "$1" == "get" && "$3" == "frontend" ]]; then + printf '%s' "$OLD_FRONTEND" + return 0 + fi + if [[ "${DEPLOY_TEST_MODE:-success}" == "failure" \ + && "$1" == "rollout" \ + && "$3" == "deployment/backend" \ + && "${DEPLOY_TEST_FAILURE_EMITTED:-false}" == "false" ]]; then + DEPLOY_TEST_FAILURE_EMITTED=true + return 1 + fi + return 0 +} +export -f kubectl +export CALL_LOG OLD_BACKEND OLD_FRONTEND DEPLOY_TEST_MODE DEPLOY_TEST_FAILURE_EMITTED + +if bash "$DEPLOY_SCRIPT" 'registry.local/backend:main' "$NEW_FRONTEND" >/dev/null 2>&1; then + printf 'Ошибка теста: mutable backend tag не был отклонён\n' >&2 + exit 1 +fi + +: > "$CALL_LOG" +DEPLOY_TEST_MODE=success bash "$DEPLOY_SCRIPT" "$NEW_BACKEND" "$NEW_FRONTEND" +grep --fixed-strings "set image deployment/backend backend=$NEW_BACKEND" "$CALL_LOG" >/dev/null +grep --fixed-strings "set image deployment/frontend frontend=$NEW_FRONTEND" "$CALL_LOG" >/dev/null + +: > "$CALL_LOG" +if DEPLOY_TEST_MODE=failure DEPLOY_TEST_FAILURE_EMITTED=false \ + bash "$DEPLOY_SCRIPT" "$NEW_BACKEND" "$NEW_FRONTEND"; then + printf 'Ошибка теста: неуспешный rollout завершился кодом 0\n' >&2 + exit 1 +fi +grep --fixed-strings "set image deployment/backend backend=$OLD_BACKEND" "$CALL_LOG" >/dev/null +grep --fixed-strings "set image deployment/frontend frontend=$OLD_FRONTEND" "$CALL_LOG" >/dev/null + +printf 'Проверка immutable rollout и rollback завершена успешно\n'
+ aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}"> ${escapeHtml(code)} ${timeAssignmentForDate(row.date) ? 'вр.' : ''} @@ -921,7 +919,7 @@ export async function initAcademicCalendar() { return; } calendarDayDialogActivitySummary.innerHTML = ` - ${escapeHtml(activity.code)} + ${escapeHtml(activity.code)}
${escapeHtml(activity.name)}

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

@@ -938,7 +936,7 @@ export async function initAcademicCalendar() { const activity = activityById(commonCalendarDayActivityValue(days)); if (!activity) { calendarDayDialogActivitySummary.innerHTML = ` - ... + ...
Выбрано ячеек: ${days.length}

Выберите код активности, чтобы применить его ко всему выделению

@@ -948,7 +946,7 @@ export async function initAcademicCalendar() { } calendarDayDialogActivitySummary.innerHTML = ` - ${escapeHtml(activity.code)} + ${escapeHtml(activity.code)}
${escapeHtml(activity.name)} · ${days.length} ячеек

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

@@ -1041,8 +1039,6 @@ export async function initAcademicCalendar() { const select = day.querySelector('.calendar-day-activity'); const activity = activityById(select.value); const code = activity?.code || '-'; - const color = activity?.colorCode || '#64748b'; - day.style.setProperty('--activity-color', color); day.querySelector('.calendar-day-code').textContent = code; day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); } @@ -1053,11 +1049,8 @@ export async function initAcademicCalendar() { return; } const tooltip = ensureCalendarDayTooltip(); - const activity = activityById(day.querySelector('.calendar-day-activity')?.value); - tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b'); tooltip.innerHTML = renderCalendarDayTooltip(day); tooltip.classList.add('visible'); - positionCalendarDayTooltip(tooltip, day, event); } function hideCalendarDayTooltip() { @@ -1076,14 +1069,13 @@ export async function initAcademicCalendar() { function renderCalendarDayTooltip(day) { const activity = activityById(day.querySelector('.calendar-day-activity')?.value); const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-'; - const color = activity?.colorCode || '#64748b'; const timeAssignment = timeAssignmentForDate(day.dataset.date); const timeLabel = timeAssignment ? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка') : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); return `
- ${escapeHtml(code)} + ${escapeHtml(code)}
${escapeHtml(formatLongDate(day.dataset.date))} ${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя @@ -1106,24 +1098,6 @@ export async function initAcademicCalendar() { `; } - function positionCalendarDayTooltip(tooltip, day, event) { - const offset = 14; - const rect = day.getBoundingClientRect(); - const x = event?.clientX ?? rect.left + rect.width / 2; - const y = event?.clientY ?? rect.top; - const tooltipRect = tooltip.getBoundingClientRect(); - let left = x + offset; - let top = y + offset; - if (left + tooltipRect.width > window.innerWidth - 12) { - left = x - tooltipRect.width - offset; - } - if (top + tooltipRect.height > window.innerHeight - 12) { - top = y - tooltipRect.height - offset; - } - tooltip.style.left = `${Math.max(12, left)}px`; - tooltip.style.top = `${Math.max(12, top)}px`; - } - function startCalendarDayDragSelection(event, day) { if (event.button !== 0) return; hideCalendarDayTooltip(); @@ -1246,10 +1220,9 @@ export async function initAcademicCalendar() { calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru')) .map(([code, count]) => { - const activity = activityTypes.find(item => item.code === code); const selectedClass = selectedCode === code ? ' is-selected' : ''; const suffix = selectedCode === code ? 'выбрано' : ''; - return `${escapeHtml(code)}: ${count}${suffix}`; + return `${escapeHtml(code)}: ${count}${suffix}`; }).join(''); } diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js index e426580..8d5090a 100755 --- a/frontend/admin/js/views/classrooms.js +++ b/frontend/admin/js/views/classrooms.js @@ -5,7 +5,7 @@ async function fetchEquipments() { try { return await api.get('/api/equipments'); } catch (e) { - console.error("Failed to fetch equipments", e); + console.error('Не удалось загрузить список оборудования'); return []; } } diff --git a/frontend/admin/js/views/dashboard.js b/frontend/admin/js/views/dashboard.js index d594266..bf45dc0 100644 --- a/frontend/admin/js/views/dashboard.js +++ b/frontend/admin/js/views/dashboard.js @@ -325,18 +325,16 @@ export async function initDashboard() { const conflictCards = conflicts.map(c => { const isError = c.severity === 'error'; - const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b'; - const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)'; const badgeIcon = isError ? `` : ``; return ` -
-
${badgeIcon}
-
-

${escapeHtml(c.title)}

-

${c.description}

+
+
${badgeIcon}
+
+

${escapeHtml(c.title)}

+

${escapeHtml(c.description)}

`; diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js index 1614365..b34a134 100644 --- a/frontend/admin/js/views/department-workspace.js +++ b/frontend/admin/js/views/department-workspace.js @@ -1,11 +1,12 @@ -import { api } from '../api.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +import { api, getSession } from '../api.js'; +import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; export async function initDepartmentWorkspace() { - const role = localStorage.getItem('role'); - const userDepartmentId = localStorage.getItem('departmentId'); + const session = getSession(); + const role = session?.role; + const userDepartmentId = session?.departmentId; const departmentField = document.getElementById('department-workspace-department-field'); const departmentSelect = document.getElementById('department-workspace-department'); const startInput = document.getElementById('department-workspace-start'); @@ -493,14 +494,10 @@ function fillSelect(id, items, valueFn, labelFn, emptyLabel) { function setDefaultDates(startInput, endInput) { const today = new Date(); - const nextWeek = new Date(); + const nextWeek = new Date(today); nextWeek.setDate(today.getDate() + 7); - startInput.value = toIsoDate(today); - endInput.value = toIsoDate(nextWeek); -} - -function toIsoDate(date) { - return date.toISOString().slice(0, 10); + startInput.value = formatLocalDate(today); + endInput.value = formatLocalDate(nextWeek); } function formatDateTime(value) { diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index c5048b5..754525e 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -5,7 +5,7 @@ async function fetchEducationForms() { try { return await api.get('/api/education-forms'); } catch (e) { - console.error("Failed to fetch education forms", e); + console.error('Не удалось загрузить формы обучения'); return []; } } @@ -182,7 +182,7 @@ export async function initGroups() { try { subgroups = await api.get('/api/subgroups'); } catch (error) { - console.error("Failed to load subgroups", error); + console.error('Не удалось загрузить подгруппы'); } } @@ -560,7 +560,7 @@ export async function initGroups() { renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity)); subgroupFormTitle.textContent = 'Настройка подгрупп'; btnSaveSubgroup.textContent = 'Применить'; - btnCancelSubgroupEdit.style.display = 'none'; + btnCancelSubgroupEdit.hidden = true; hideAlert('manage-subgroup-alert'); } diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index bbc42a4..6ed0708 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -1,4 +1,4 @@ -import { api } from '../api.js'; +import { api, getSession } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; @@ -38,8 +38,9 @@ const MOBILE_QUERY = '(max-width: 760px)'; const DEFAULT_SEMESTER_WEEKS = 22; export async function initScheduleView() { - const role = localStorage.getItem('role'); - const userDepartmentId = normalizeId(localStorage.getItem('departmentId')); + const session = getSession(); + const role = session?.role; + const userDepartmentId = normalizeId(session?.departmentId); const dateInput = document.getElementById('schedule-view-date'); const targetSelect = document.getElementById('schedule-view-target'); const loadButton = document.getElementById('schedule-view-load'); diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index e57faab..fb274b8 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -658,13 +658,7 @@ export async function initSchedule() { slotIndex: button.dataset.slotIndex || '' }; if (!visualContextMenu) return; - const rect = button.getBoundingClientRect(); visualContextMenu.hidden = false; - const menuRect = visualContextMenu.getBoundingClientRect(); - const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12); - const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12); - visualContextMenu.style.top = `${Math.max(12, top)}px`; - visualContextMenu.style.left = `${Math.max(12, left)}px`; visualContextMenu.querySelector('button')?.focus(); } @@ -672,8 +666,6 @@ export async function initSchedule() { visualContextTarget = null; if (!visualContextMenu) return; visualContextMenu.hidden = true; - visualContextMenu.style.top = ''; - visualContextMenu.style.left = ''; } async function handleVisualContextMenuClick(event) { diff --git a/frontend/admin/js/views/teacher-requests.js b/frontend/admin/js/views/teacher-requests.js index 787b0db..1a6b931 100644 --- a/frontend/admin/js/views/teacher-requests.js +++ b/frontend/admin/js/views/teacher-requests.js @@ -1,5 +1,5 @@ import { api } from '../api.js'; -import { escapeHtml, hideAlert, showAlert } from '../utils.js'; +import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js'; export async function initTeacherRequests() { const requestsTbody = document.getElementById('teacher-requests-tbody'); @@ -23,7 +23,7 @@ export async function initTeacherRequests() { detail: { count: normalizedRequests.length } })); } catch (error) { - requestsTbody.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки заявок')}
${escapeHtml(request.comment || '-')}
@@ -131,6 +131,6 @@ export async function initTeacherRequests() { await loadDepartments(); await loadRequests(); } catch (error) { - requestsTbody.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки')}
Ошибка загрузки: ${e.message}