From ee876f1acdffda7f347a0d87be97e9062043a35a Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 19 Jul 2026 20:16:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B0=D0=B3-=D1=84=D0=B8=D0=BA=D1=81=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D1=91=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +- .gitea/workflows/docker-build.yaml | 68 +++++-- BUG_FIX_PROGRESS.md | 162 ++++++++++++++-- DEVOPS.md | 26 +-- STARTUP_GUIDE.md | 118 ++++++++++++ backend/Dockerfile | 4 +- .../DatabaseConstraintViolationMapper.java | 4 + .../app/controller/GroupController.java | 91 +-------- .../app/controller/SubgroupController.java | 15 +- .../java/com/magistr/app/model/Subgroup.java | 2 +- .../app/service/AcademicStructureService.java | 59 ++++-- .../app/service/ScheduleGeneratorService.java | 3 +- .../app/service/ScheduleQueryService.java | 3 +- .../main/resources/db/migration/V1__init.sql | 79 +++++++- ...inRateLimitConcurrencyIntegrationTest.java | 3 +- ...okenServiceConcurrencyIntegrationTest.java | 3 +- ...riodInvariantMigrationIntegrationTest.java | 3 +- ...emicStructureInvariantIntegrationTest.java | 177 +++++++++++++++++- ...eduleOverrideMigrationIntegrationTest.java | 3 +- ...duleRuleHoursMigrationIntegrationTest.java | 3 +- ...jectOwnershipInvariantIntegrationTest.java | 3 +- ...herDepartmentInvariantIntegrationTest.java | 3 +- ...SlotInvariantMigrationIntegrationTest.java | 3 +- ...micCalendarGridServiceIntegrationTest.java | 3 +- .../service/AcademicStructureServiceTest.java | 55 ++++++ ...PostgreSqlAdvisoryLockIntegrationTest.java | 3 +- .../ScheduleGenerationQueryCountTest.java | 24 +++ .../app/service/ScheduleQueryServiceTest.java | 31 +++ ...RuleServiceConcurrencyIntegrationTest.java | 3 +- ...antLifecyclePostgreSqlIntegrationTest.java | 3 +- .../app/testing/TestContainerImages.java | 16 ++ compose.yaml | 8 +- docs/API.md | 30 ++- docs/ARCHITECTURE.md | 25 +++ docs/BUSINESS_LOGIC.md | 8 +- docs/DATABASE.md | 111 ++++++----- docs/DEVELOPMENT.md | 27 +++ docs/FRONTEND.md | 4 + docs/INFRASTRUCTURE.md | 62 ++++-- docs/README.md | 17 +- frontend/admin/js/views/academic-calendar.js | 30 ++- scripts/check-artifact-pinning.sh | 152 +++++++++++++++ scripts/test-artifact-pinning.sh | 32 ++++ 43 files changed, 1228 insertions(+), 258 deletions(-) create mode 100644 STARTUP_GUIDE.md create mode 100644 backend/src/test/java/com/magistr/app/testing/TestContainerImages.java create mode 100755 scripts/check-artifact-pinning.sh create mode 100755 scripts/test-artifact-pinning.sh diff --git a/.env.example b/.env.example index ae07dab..24fdebd 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ # Скопируйте файл в .env и замените пустые значения локальными секретами. POSTGRES_USER=myuser -POSTGRES_PASSWORD= +POSTGRES_PASSWORD=supersecretpassword#<---- POSTGRES_DB=app_db # Сгенерировать: openssl rand -base64 48 -JWT_SECRET= +JWT_SECRET=#<---- JWT_ACCESS_TOKEN_TTL=15m JWT_REFRESH_TOKEN_TTL=7d JWT_REFRESH_COOKIE_NAME=magistr_refresh @@ -35,6 +35,3 @@ 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 index 917fa20..1923ce4 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -15,6 +15,9 @@ env: REGISTRY: gitea.zuev.company BACKEND_IMAGE: zuev/magistr-backend FRONTEND_IMAGE: zuev/magistr-frontend + MAVEN_IMAGE: maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc + NODE_IMAGE: node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2120bcca92ce372e + TRIVY_IMAGE: aquasec/trivy:0.63.0@sha256:6fb0646988fcd2fdf7bf123f7174945ebc2c9c72d1fa1567c8d7daeeb70f8037 jobs: checks: @@ -23,14 +26,14 @@ jobs: timeout-minutes: 20 steps: - name: Получить исходный код - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Backend unit и component tests run: >- docker run --rm -v "${{ github.workspace }}/backend:/workspace" -w /workspace - maven:3.9.9-eclipse-temurin-17 + "${{ env.MAVEN_IMAGE }}" mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test - name: Frontend static и unit tests @@ -38,7 +41,7 @@ jobs: docker run --rm -v "${{ github.workspace }}/frontend:/workspace" -w /workspace - node:22-alpine3.23 + "${{ env.NODE_IMAGE }}" sh -c "npm ci && npm run check" - name: Проверить Docker Compose @@ -50,6 +53,9 @@ jobs: - name: Проверить сценарий immutable rollout и rollback run: bash scripts/test-deploy-images.sh + - name: Проверить закрепление артефактов и supply-chain gates + run: bash scripts/test-artifact-pinning.sh + build-and-push-backend: name: Собрать backend image needs: checks @@ -59,10 +65,10 @@ jobs: digest: ${{ steps.build.outputs.digest }} steps: - name: Получить исходный код - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Войти в Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -70,7 +76,7 @@ jobs: - name: Сформировать immutable tags и labels id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }} flavor: latest=false @@ -80,10 +86,12 @@ jobs: - name: Собрать и опубликовать backend image id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ./backend push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: | ${{ steps.meta.outputs.labels }} @@ -99,10 +107,10 @@ jobs: digest: ${{ steps.build.outputs.digest }} steps: - name: Получить исходный код - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Войти в Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -110,7 +118,7 @@ jobs: - name: Сформировать immutable tags и labels id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }} flavor: latest=false @@ -120,19 +128,53 @@ jobs: - name: Собрать и опубликовать frontend image id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ./frontend push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: | ${{ steps.meta.outputs.labels }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} + security-scan: + name: Проверить опубликованные образы + needs: [build-and-push-backend, build-and-push-frontend] + runs-on: ubuntu-latest + timeout-minutes: 20 + 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 }} + steps: + - name: Войти в Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.ZUEV_TOKEN }} + + - name: Проверить backend на HIGH и CRITICAL уязвимости + run: >- + docker run --rm + -v "$HOME/.docker:/root/.docker:ro" + "${{ env.TRIVY_IMAGE }}" + image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed + "$BACKEND_REF" + + - name: Проверить frontend на HIGH и CRITICAL уязвимости + run: >- + docker run --rm + -v "$HOME/.docker:/root/.docker:ro" + "${{ env.TRIVY_IMAGE }}" + image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed + "$FRONTEND_REF" + deploy-to-k8s: name: Развернуть проверенные digests - needs: [build-and-push-backend, build-and-push-frontend] + needs: [build-and-push-backend, build-and-push-frontend, security-scan] runs-on: ubuntu-latest timeout-minutes: 15 environment: production @@ -143,7 +185,7 @@ jobs: KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e steps: - name: Получить deploy-скрипт - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Создать kubeconfig env: diff --git a/BUG_FIX_PROGRESS.md b/BUG_FIX_PROGRESS.md index f32f010..bc00ea1 100644 --- a/BUG_FIX_PROGRESS.md +++ b/BUG_FIX_PROGRESS.md @@ -21,7 +21,7 @@ | `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` | Текущая единая baseline-миграция: `V1__init.sql`, SHA-256 -`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`. +`fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`. Упоминания V2–V7 в исторических записях ниже описывают последовательность разработки до консолидации и не означают наличие этих файлов в текущем дереве. @@ -62,7 +62,7 @@ | 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` отсутствует. | +| 10 | исправлено и проверено | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Kubernetes содержит RBAC `get/update`, directory mount без `subPath`, required tenant-config и HTTP probes. | 64 целевых теста; полный backend 277/0/0/0; Kustomize, secret-scan и синтаксис deploy-скрипта прошли. | | 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 | исправлено и проверено | Четыре 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. | @@ -81,10 +81,10 @@ | 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. | +| 29 | исправлено и проверено | Business dates зависели от timezone процесса, абсолютные моменты смешивались с `LocalDateTime`, frontend date-only местами строился через UTC. | `BusinessTimeService` с `Clock`/`Europe/Moscow`; абсолютные моменты переведены на `Instant`/`TIMESTAMPTZ`/UTC; frontend использует локальные компоненты; Compose и Kubernetes фиксируют бизнес-зону и timezone JVM. | Boundary tests московской полуночи, frontend date-only, Kustomize и полный backend PostgreSQL/Testcontainers-прогон 277/0/0/0 прошли. | +| 30 | исправлено и проверено | Образы/скачиваемые инструменты использовали mutable refs либо не имели общего контроля digest/SBOM/scan. | Docker/Compose/Testcontainers, OTel Collector и CI images закреплены tag+digest, Actions — commit SHA; checksum, provenance, SBOM и Trivy gate проверяются скриптом. | Shell-регрессии, Workflow/Compose YAML, repo и полный `K8S_DIR=../k8s` artifact-scan прошли. | +| 31 | исправлено и проверено | Group create/update допускал неположительные значения и уменьшение ниже активных подгрупп; параллельные изменения могли вместе превысить размер группы. | Общий validator create/update, блокировка родительской группы, положительные CHECK и перекрёстные триггеры непосредственно в V1. | 11 service tests и 4 PostgreSQL migration/concurrency tests; полный backend 277/0/0/0. | +| 32 | исправлено и проверено | `between(start,end) > 120` разрешал 121 включительную дату. | Оба сервиса считают включительные даты как `between + 1` и разрешают не больше 120. | Границы 120/121 в query/generator tests; полный backend 277/0/0/0. | | 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 — успешно. | @@ -333,10 +333,10 @@ - AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и `docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials существующего домена было оставлено проблеме № 13 и устранено следующим этапом. -- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись - и Kustomize не запускался. Для production обязательны directory mount без `subPath`, +- В текущем внешнем `../k8s` подтверждены directory mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness / - readiness probes; применение и rollout остаются внешними действиями оператора. + readiness probes. Манифесты успешно собираются Kustomize; production rollout остаётся + отдельным действием оператора. Фактические проверки этапа: @@ -348,7 +348,7 @@ | Контейнерный полный `mvn test` | 169 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 40 suites. | | `git diff --check` | Успешно. | | Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. | -| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. | +| `kubectl kustomize ../k8s`, production secret-scan и `bash -n ../k8s/deploy.sh` | Успешно в финальном аудите. | ### № 13 — полная и повторяемая синхронизация tenant-конфигурации @@ -926,14 +926,142 @@ | Compose, Kustomize, secret-scan, `git diff --check` | Успешно. | | Изменённые внешние файлы | `../k8s/config.yaml`, `../k8s/README.md`; каталог вне Git-корня и проверен отдельно. | +### № 29 — единая временная зона бизнес-дат + +- Добавлен `BusinessTimeService`: production использует UTC `Clock`, а календарную дату + вычисляет в настраиваемой зоне `BUSINESS_TIME_ZONE` с безопасным default + `Europe/Moscow`. Контроллеры пользователей, групп, кабинета кафедры, заявок и дисциплин, + генератор расписания и история кафедр получают текущую бизнес-дату через этот сервис. +- Абсолютные моменты refresh/login-аудита, lifecycle, overrides, заявок, комментариев и + назначений переведены с `LocalDateTime` на `Instant`. Hibernate и Jackson настроены на + UTC; все соответствующие колонки единственной `V1__init.sql` используют `TIMESTAMPTZ`. +- `formatLocalDate()` и календарная арифметика frontend используют локальные компоненты + `Date`; `toISOString().slice(0, 10)` для date-only в frontend больше не используется. +- Compose передаёт `BUSINESS_TIME_ZONE`, `TZ` и + `JAVA_TOOL_OPTIONS=-Duser.timezone=...`; `.env.example` содержит явные значения. + Kubernetes `app-config` содержит те же production-поля, поэтому backend pod не зависит от + timezone node или базового образа. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md`, + `docs/DATABASE.md`, `docs/DEVELOPMENT.md`, `docs/FRONTEND.md` и + `docs/INFRASTRUCTURE.md`. +- Новых миграционных файлов не создано; каталог Flyway содержит только `V1__init.sql`, + текущий SHA-256 после завершения № 31: + `fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `BusinessTimeServiceTest` | В первом полном прогоне обе границы московской полуночи прошли. | +| `npm test`, `node --check` | Все 4 frontend test-suite и изменённые JS-файлы прошли. | +| Static time scan | В production Java нет `LocalDate.now()`/`LocalDateTime.now()` без явной зоны; во frontend нет date-only через `toISOString`; в V1 и документации нет `TIMESTAMP` без timezone. | +| Compose и diff | `docker compose config --quiet` с обязательными секретами и `git diff --check` успешны. | +| Kubernetes timezone | Kustomize-рендер содержит `BUSINESS_TIME_ZONE=Europe/Moscow`, `TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow`. | +| Первый полный `mvn test` | 269 тестов: 263 успешны. После прогона исправлены новое lifecycle-ожидание, JDBC binding `Instant` и три ранее нестабильных ожидания PostgreSQL `40P01`; также усилена блокировка календарного assignment. | +| Повторный Maven/Testcontainers | Полный прогон после № 31–32: 277 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | + +### № 30 — воспроизводимые и проверяемые артефакты + +- Backend build/runtime, PostgreSQL Compose и все PostgreSQL Testcontainers закреплены + одновременно точной версией и manifest digest. Ранее закреплённые frontend base images + сохранены. +- Все сторонние Actions в Gitea workflow закреплены полными 40-символьными commit SHA; + Maven, Node.js и Trivy запускаются только по `tag@sha256:...`. +- OpenTelemetry Java Agent и `kubectl` имеют точные версии и обязательную SHA-256 проверку до + использования. +- Оба build job публикуют BuildKit provenance `mode=max` и SBOM-attestation. Отдельный + `security-scan` проверяет backend/frontend digests закреплённым Trivy и блокирует deploy при + исправимых `HIGH`/`CRITICAL` уязвимостях. +- Добавлены `scripts/check-artifact-pinning.sh` и его регрессионный shell-тест. Проверка + контролирует Dockerfile, Compose, Testcontainers, Actions, checksum, SBOM/provenance, scan + gate и зависимость deploy от scan. Опциональный `K8S_DIR` расширяет проверку на внешние + production-манифесты. +- AutoUpdateDocs синхронизировал `docs/DATABASE.md`, `docs/DEVELOPMENT.md`, + `docs/INFRASTRUCTURE.md` и `DEVOPS.md`. +- Внешний `../k8s/otel-collector.yaml` использует официальный Collector Contrib + `0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`. + Версия подтверждена официальным релизом OpenTelemetry, manifest digest получен напрямую + из Docker Hub Registry API. +- Новых Flyway-файлов не создано; в каталоге миграций остаётся только `V1__init.sql` с + SHA-256 `fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `bash scripts/check-artifact-pinning.sh` | Успешно: repo image refs, Actions, checksum, SBOM и scan gates закреплены. | +| `bash scripts/test-artifact-pinning.sh` | Успешно: незакреплённый base image и удалённая SBOM гарантированно отклоняются. | +| `bash scripts/test-deploy-images.sh` | Успешно: immutable refs, rollout и парный rollback. | +| Workflow YAML, Compose config, `git diff --check` | Успешно. | +| `K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh` | Успешно: все production-образы имеют полный digest. | +| `kubectl kustomize ../k8s` | Успешно; рендер содержит закреплённый OTel Collector. | + +### № 31 — инварианты численности групп и подгрупп + +- Создание и изменение групп теперь проходят через общий validator + `AcademicStructureService`: численность, год начала обучения и все идентификаторы должны + быть положительными, обязательные поля не могут отсутствовать. +- При обновлении сервис блокирует группу и запрещает уменьшать `groupSize` ниже суммы + численностей активных подгрупп. Создание, обновление и архивация подгрупп также блокируют + строку родительской группы до завершения транзакции. +- `subgroups.student_capacity` стал обязательным. В V1 добавлены CHECK-ограничения + положительной численности группы, года начала обучения и численности подгруппы. +- Два триггера V1 защищают перекрёстный инвариант при прямых и конкурентных записях: + сумма активных подгрупп не может превышать размер группы. Ошибка сопоставляется с + безопасным русским ответом через `DatabaseConstraintViolationMapper`. +- Новых миграций не создано: вся итоговая схема находится в `V1__init.sql`, а V2–V4 + отсутствуют. +- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md`, + `docs/BUSINESS_LOGIC.md` и `docs/DATABASE.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `AcademicStructureServiceTest` | 11 тестов: общий create/update validator, положительные значения и запрет уменьшения ниже подгрупп прошли. | +| `AcademicStructureInvariantIntegrationTest` | 4 PostgreSQL-теста: CHECK, уменьшение группы и гонка уменьшения с добавлением подгруппы прошли; инвариант сохранён. | +| Полный контейнерный `mvn test` | 277 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| Каталог Flyway | Ровно один `V1__init.sql`, SHA-256 `fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`. | + +### № 32 — точный включительный лимит диапазона расписания + +- `ScheduleQueryService` и `ScheduleGeneratorService` одинаково вычисляют количество + календарных дат как `ChronoUnit.DAYS.between(startDate, endDate) + 1`. +- Диапазон из 120 дат разрешён, а из 121 даты возвращает русскую ошибку валидации. +- AutoUpdateDocs уточнил включительную семантику лимита в `docs/API.md`, + `docs/ARCHITECTURE.md` и `docs/BUSINESS_LOGIC.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `ScheduleQueryServiceTest.rangeLimitCountsBothBoundaryDates` | Граница 120 разрешена, 121 отклонена. | +| `ScheduleGenerationQueryCountTest` | Существующая генерация 120 дат сохранена, запрос 121 даты отклонён до генерации. | +| Полный контейнерный `mvn test` | 277 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | + +## Финальный аудит + +- Все 33 проблемы из области (`1` и `3–34`) имеют реализованную репозиторную часть и + воспроизводимое подтверждение. № 2 не изменялась, `BUG_REPORT.md` сохранён как исходный + реестр. +- Полностью закрыты и проверены 32 пункта. Для № 1 остаются только операторские ротация, + provisioning, rollout и очистка истории, которые исходный prompt прямо запрещает + выполнять без отдельных production-полномочий. +- Полный backend PostgreSQL/Testcontainers-прогон: 277 тестов, 0 failures, 0 errors, + 0 skipped. Четыре frontend test-suite, `npm run check`, Compose validation, + Workflow YAML, artifact/deploy shell-тесты и `git diff --check` прошли. +- `kubectl kustomize ../k8s`, production secret-scan, синтаксис deploy-скрипта и расширенный + artifact-scan прошли. В production-манифестах отсутствуют mutable `latest` и `:main`. +- Каталог Flyway содержит ровно `V1__init.sql`; V2–V4 и другие дополнительные миграции + отсутствуют. Финальный SHA-256 V1: + `fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`. + ## Точка продолжения -Текущий этап: **№ 29 — единая временная зона бизнес-дат**. +Текущий этап: **реализация и проверка всех разрешённых изменений завершены**. -Следующая операция: +Оставшееся операторское действие владельца: -1. построить graphify-контекст всех `LocalDate.now()`/`LocalDateTime.now()` и frontend - `toISOString()` в бизнес-операциях; -2. внедрить единый `Clock`/`ZoneId` Europe/Moscow без изменения абсолютных timestamp; -3. зафиксировать `TZ`/`user.timezone` в Compose и Kubernetes; -4. проверить границы московской полуночи и date-only форматирование без UTC-сдвига. +1. при готовности production-полномочий выполнить `docs/SECURITY_RUNBOOK.md`: ротацию, + provisioning, rollout и очистку истории для № 1. Эти действия не выполнялись + автоматически и не требуются для завершения репозиторной реализации. diff --git a/DEVOPS.md b/DEVOPS.md index 4ce8624..05a37cf 100644 --- a/DEVOPS.md +++ b/DEVOPS.md @@ -88,12 +88,12 @@ graph TD * **Frontend (`frontend.yaml`)**: * Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update. - * В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd (`httpd:alpine`). + * В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd; версия и manifest digest закреплены в Dockerfile. * Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80. * **Backend (`backend.yaml`)**: * `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже). - * Для сборки образов используется multi-stage сборка Maven (JDK 17) и запуск под управлением `eclipse-temurin:17-jre-alpine`. + * Для сборки образов используется multi-stage сборка Maven (JDK 17); build/runtime-образы одновременно закреплены точным tag и manifest digest. * Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов. * Для связи с Ingress настроен ClusterIP-сервис на порту 8080. @@ -194,22 +194,26 @@ graph TD ### 5.1 Автоматизация сборки (CI) В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер: -1. **Checkout**: Загрузка актуального исходного кода проекта на ранер. -2. **Setup Buildx**: Инициализация Docker Buildx для оптимизации кэширования слоев. -3. **Login to Registry**: Аутентификация во встроенном реестре контейнеров Gitea Container Registry (`git.zuev.company`) с использованием сервисного токена `ZUEV_TOKEN` (права `write:package`). -4. **Build & Push**: Параллельная сборка Docker-образов для бэкенда и фронтенда с тегом `latest` и отправка их в приватный реестр. +1. **Checks**: Backend/frontend-тесты, Compose validation, тест immutable rollback и статическая проверка закрепления артефактов. +2. **Build & Push**: Параллельная сборка и публикация backend/frontend с SHA-tag или release tag; mutable `latest` не создаётся. +3. **Attestations**: BuildKit добавляет к обоим образам SBOM и максимальную provenance-attestation. +4. **Security scan**: Закреплённый digest Trivy блокирует доставку при исправимых уязвимостях `HIGH`/`CRITICAL`. +5. **Deploy**: Только прошедшие gates registry digests передаются в production rollout. Все сторонние Actions закреплены полными commit SHA. ### 5.2 Доставка в кластер (CD) Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет `gitea-registry` типа `docker-registry` в пространстве имен `magistr`. Этот секрет ассоциирован со спецификациями деплоев через директиву `imagePullSecrets`. -Непосредственно Gitea Actions Runner работает как демон `act_runner` внутри изолированного LXC контейнера (CTID 107). В пайплайне шаг развертывания (`deploy-to-k8s`) динамически генерирует `kubeconfig` из секрета, устанавливает утилиту `kubectl` и выполняет императивное обновление релизов без использования тяжеловесных GitOps операторов: +Непосредственно Gitea Actions Runner работает как демон `act_runner` внутри изолированного LXC контейнера (CTID 107). В пайплайне шаг развертывания (`deploy-to-k8s`) динамически генерирует `kubeconfig` из секрета, проверяет checksum закреплённой версии `kubectl` и выполняет атомарное обновление обоих образов без использования тяжеловесных GitOps операторов: ```bash -kubectl rollout restart deployment backend frontend -n magistr -kubectl rollout status deployment/frontend -n magistr --timeout=120s -kubectl rollout status deployment/backend -n magistr --timeout=300s +bash scripts/deploy-images.sh \ + gitea.zuev.company/zuev/magistr-backend@sha256: \ + gitea.zuev.company/zuev/magistr-frontend@sha256: ``` -Поскольку манифесты используют `imagePullPolicy: Always` и тег `:main`, поды автоматически скачивают свежие слои собранных образов. + +Скрипт принимает только полные `image@sha256:...`, ожидает готовность обоих Deployment и при +ошибке возвращает предыдущую пару digest. Поэтому содержимое релиза не зависит от повторного +разрешения mutable tag. --- diff --git a/STARTUP_GUIDE.md b/STARTUP_GUIDE.md new file mode 100644 index 0000000..ff28b33 --- /dev/null +++ b/STARTUP_GUIDE.md @@ -0,0 +1,118 @@ +# Инструкция запуска Magistr + +Ниже — минимальный порядок действий. Подробности по инфраструктуре и секретам находятся в +[`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) и +[`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md). + +## 1. Локальный запуск + +Нужны Git, Docker Engine, Docker Compose и запущенный Caddy из `../сaddy-proxy/`. + +1. В корне проекта создайте локальный файл настроек: + + ```bash + cp .env.example .env + openssl rand -base64 36 # значение POSTGRES_PASSWORD + openssl rand -base64 48 # значение JWT_SECRET + ``` + + Вставьте полученные значения в `.env`. Файл `.env` не коммитьте. + +2. Один раз создайте общую Docker-сеть, затем запустите Caddy и Magistr: + + ```bash + docker network inspect proxy >/dev/null 2>&1 || docker network create proxy + docker compose -f ../сaddy-proxy/compose.yaml up -d + docker compose config --quiet + docker compose up -d --build + docker compose ps + ``` + +3. Откройте `https://localhost` (`http://localhost` перенаправит на HTTPS). Проверьте backend + через Caddy: + + ```bash + curl -kfsS https://localhost/actuator/health/readiness + ``` + + Если браузер не доверяет локальному сертификату Caddy, на CachyOS/Arch выполните: + + ```bash + docker cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-local-root.crt + sudo trust anchor --store /tmp/caddy-local-root.crt + ``` + + До установки проверьте SHA-256 отпечаток командой + `openssl x509 -in /tmp/caddy-local-root.crt -noout -fingerprint -sha256`, затем полностью + перезапустите браузер. + + Для первого локального входа: `admin` / `admin`. Эти данные предназначены только для + разработки. + +4. Если запуск не удался: + + ```bash + docker compose logs --tail=200 backend db frontend + ``` + + Полный локальный сброс БД выполняется командой `docker compose down -v`, но она удалит + все локальные данные. Обычная остановка без удаления данных: `docker compose down`. + +## 2. Первый запуск в production + +1. Подготовьте K3s/Kubernetes-кластер и убедитесь, что `kubectl get nodes` работает. + PostgreSQL в текущие манифесты не входит: заранее создайте доступную из кластера БД для + каждого университета. Пользователь БД должен иметь права на создание и изменение схемы — + Flyway применит `V1__init.sql` автоматически. + +2. Настройте DNS для `magistr.zuev.company` и остальных tenant-доменов на публичный + reverse proxy/Ingress. В текущем `../k8s/ingress.yaml` нет секции TLS: для HTTPS нужно + настроить внешний Caddy с сертификатом либо добавить cert-manager/TLS в кластер. + +3. Создайте namespace и четыре обязательных Secret по + [`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md): + + - `app-secret`: `JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD`; + - `tenants-secret`: файл `tenants.json`; + - `otel-postgres-secret`: подключения Collector к БД; + - `gitea-registry`: доступ к приватным Docker-образам. + + Для `magistr.zuev.company` запись в `tenants.json` должна иметь `"domain": "magistr"` + и реальный JDBC URL. Не сохраняйте значения Secret в Git или логах. + +4. В Gitea Actions добавьте секреты `ZUEV_TOKEN` (доступ к registry) и + `KUBECONFIG_DATA` (kubeconfig в Base64). Push в `main` соберёт, проверит и опубликует + образы. При самом первом запуске автоматический deploy может ещё не найти Deployment — + возьмите два опубликованных digest из CI/registry и выполните bootstrap вручную: + + ```bash + export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:' + export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:' + K8S_DIR=../k8s bash scripts/check-production-secrets.sh + K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh + kubectl kustomize ../k8s >/dev/null + bash ../k8s/deploy.sh apply + ``` + +5. Проверьте результат: + + ```bash + bash ../k8s/deploy.sh status + kubectl rollout status deployment/backend -n magistr --timeout=300s + kubectl logs -n magistr -l app=backend --tail=200 + curl -fsS https://magistr.zuev.company/ + ``` + + После первого входа немедленно смените/отключите все тестовые пароли из `V1__init.sql`. + Следующие push в `main` CI/CD сможет развёртывать автоматически. + +## 3. Что могу выполнить я + +По вашей команде я могу подготовить `.env`, запустить и диагностировать локальный Compose, +проверить/исправить код и манифесты, прогнать тесты и dry-run Kubernetes, а при доступном +`kubectl` — проверить состояние кластера. + +Без вас я не могу получить реальные пароли и токены, настроить DNS/сертификаты у внешнего +провайдера или войти в Gitea/Kubernetes, если доступы не предоставлены. Production deploy, +ротацию Secret и удаление БД я не выполняю без отдельного явного разрешения: это меняет +внешнюю систему и может прервать работу либо уничтожить данные. diff --git a/backend/Dockerfile b/backend/Dockerfile index 995c9a6..234cf97 100755 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM maven:3.9-eclipse-temurin-17 AS build +FROM maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B @@ -14,7 +14,7 @@ RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \ -B \ && echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c - -FROM eclipse-temurin:17-jre-alpine +FROM eclipse-temurin:17-jre-alpine@sha256:02320dd4ce20e243dfb915c686089cf9315c763084fafbb12d5c9993aee18b57 # Best practice: run as a non-root user RUN addgroup -S spring && adduser -S spring -G spring diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java index 360f0b4..eb0c3ca 100644 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java @@ -38,6 +38,10 @@ final class DatabaseConstraintViolationMapper { badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"), badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"), badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"), + badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"), + badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"), + badRequest("chk_subgroups_student_capacity_positive", "Численность подгруппы должна быть больше нуля"), + badRequest("chk_student_group_subgroup_capacity", "Сумма численностей активных подгрупп не может превышать численность группы"), badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"), badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"), badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"), 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 ec4d97d..85eb0f5 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -6,7 +6,6 @@ import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupResponse; import com.magistr.app.model.AcademicCalendarSubject; -import com.magistr.app.model.EducationForm; import com.magistr.app.model.Role; import com.magistr.app.model.Speciality; import com.magistr.app.model.SpecialtyProfile; @@ -31,7 +30,6 @@ import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -43,9 +41,6 @@ public class GroupController { private static final Logger logger = LoggerFactory.getLogger(GroupController.class); private final GroupRepository groupRepository; - private final EducationFormRepository educationFormRepository; - private final SpecialtiesRepository specialtiesRepository; - private final SpecialtyProfileRepository specialtyProfileRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final ScheduleGeneratorService scheduleGeneratorService; @@ -54,25 +49,18 @@ public class GroupController { private final BusinessTimeService businessTime; public GroupController(GroupRepository groupRepository, - EducationFormRepository educationFormRepository, - SpecialtiesRepository specialtiesRepository, - SpecialtyProfileRepository specialtyProfileRepository, AcademicCalendarSubjectRepository calendarSubjectRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, ScheduleGeneratorService scheduleGeneratorService, StudentGroupLifecycleService groupLifecycleService, AcademicStructureService academicStructureService) { - this(groupRepository, educationFormRepository, specialtiesRepository, - specialtyProfileRepository, calendarSubjectRepository, assignmentRepository, + this(groupRepository, calendarSubjectRepository, assignmentRepository, scheduleGeneratorService, groupLifecycleService, academicStructureService, BusinessTimeService.systemDefault()); } @Autowired public GroupController(GroupRepository groupRepository, - EducationFormRepository educationFormRepository, - SpecialtiesRepository specialtiesRepository, - SpecialtyProfileRepository specialtyProfileRepository, AcademicCalendarSubjectRepository calendarSubjectRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, ScheduleGeneratorService scheduleGeneratorService, @@ -80,9 +68,6 @@ public class GroupController { AcademicStructureService academicStructureService, BusinessTimeService businessTime) { this.groupRepository = groupRepository; - this.educationFormRepository = educationFormRepository; - this.specialtiesRepository = specialtiesRepository; - this.specialtyProfileRepository = specialtyProfileRepository; this.calendarSubjectRepository = calendarSubjectRepository; this.assignmentRepository = assignmentRepository; this.scheduleGeneratorService = scheduleGeneratorService; @@ -144,46 +129,10 @@ public class GroupController { @PostMapping @RequireRoles({Role.ADMIN}) public ResponseEntity createGroup(@RequestBody CreateGroupRequest request) { - logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}", - request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy()); - try { - 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 = new StudentGroup(); - 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 - {}", group.getId()); - - return ResponseEntity.ok(mapToResponse(group)); - } catch (Exception e) { - logger.error("Ошибка при создании группы", e); - throw e; - } + logger.info("Получен запрос на создание новой группы"); + StudentGroup group = academicStructureService.createGroup(request); + logger.info("Группа успешно создана с ID - {}", group.getId()); + return ResponseEntity.ok(mapToResponse(group)); } @PutMapping("/{id}") @@ -222,36 +171,6 @@ public class GroupController { return ResponseEntity.ok(mapToResponse(group)); } - private ResponseEntity validateGroupRequest(CreateGroupRequest request) { - if (request.getName() == null || request.getName().isBlank()) { - return validationError("Название группы обязательно"); - } - if (request.getGroupSize() == null) { - return validationError("Численность группы обязательна"); - } - if (request.getEducationFormId() == null) { - return validationError("Форма обучения обязательна"); - } - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - return validationError("ID кафедры обязателен"); - } - if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { - return validationError("Год начала обучения обязателен"); - } - if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) { - return validationError("Код специальности обязателен"); - } - if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) { - return validationError("Профиль обучения обязателен"); - } - return null; - } - - private ResponseEntity validationError(String errorMessage) { - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - private GroupResponse mapToResponse(StudentGroup group) { LocalDate today = businessTime.today(); int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today); 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 ec99f95..58abb1f 100644 --- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -10,6 +10,7 @@ import com.magistr.app.repository.ScheduleRuleSlotRepository; import com.magistr.app.repository.SubgroupRepository; import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -53,8 +54,9 @@ public class SubgroupController { @PostMapping("/api/groups/{groupId}/subgroups") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + @Transactional public ResponseEntity create(@PathVariable Long groupId, @RequestBody SubgroupDto request) { - StudentGroup group = groupRepository.findById(groupId).orElse(null); + StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null); if (group == null) { return ResponseEntity.notFound().build(); } @@ -81,9 +83,14 @@ public class SubgroupController { @PutMapping("/api/groups/{groupId}/subgroups/{id}") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + @Transactional public ResponseEntity update(@PathVariable Long groupId, @PathVariable Long id, @RequestBody SubgroupDto request) { + StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); if (subgroup == null) { return ResponseEntity.notFound().build(); @@ -98,7 +105,7 @@ public class SubgroupController { if (duplicateName) { return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); } - String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity()); + String capacityError = validateGroupCapacity(group, id, request.studentCapacity()); if (capacityError != null) { return ResponseEntity.badRequest().body(Map.of("message", capacityError)); } @@ -112,7 +119,11 @@ public class SubgroupController { @DeleteMapping("/api/groups/{groupId}/subgroups/{id}") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + @Transactional public ResponseEntity delete(@PathVariable Long groupId, @PathVariable Long id) { + if (groupRepository.findByIdForUpdate(groupId).isEmpty()) { + return ResponseEntity.notFound().build(); + } Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); if (subgroup == null) { return ResponseEntity.notFound().build(); diff --git a/backend/src/main/java/com/magistr/app/model/Subgroup.java b/backend/src/main/java/com/magistr/app/model/Subgroup.java index e2e5ca0..c17ce6f 100644 --- a/backend/src/main/java/com/magistr/app/model/Subgroup.java +++ b/backend/src/main/java/com/magistr/app/model/Subgroup.java @@ -17,7 +17,7 @@ public class Subgroup extends LifecycleEntity { @Column(nullable = false, length = 100) private String name; - @Column(name = "student_capacity") + @Column(name = "student_capacity", nullable = false) private Integer studentCapacity; public Long getId() { diff --git a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java index c8a48ae..24e3232 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java @@ -19,6 +19,7 @@ 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.repository.SubgroupRepository; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -41,6 +42,7 @@ public class AcademicStructureService { private final EducationFormRepository educationFormRepository; private final GroupRepository groupRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository; + private final SubgroupRepository subgroupRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicStructureService(AcademicCalendarRepository calendarRepository, @@ -52,6 +54,7 @@ public class AcademicStructureService { EducationFormRepository educationFormRepository, GroupRepository groupRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, + SubgroupRepository subgroupRepository, ScheduleGeneratorService scheduleGeneratorService) { this.calendarRepository = calendarRepository; this.calendarDayRepository = calendarDayRepository; @@ -62,9 +65,22 @@ public class AcademicStructureService { this.educationFormRepository = educationFormRepository; this.groupRepository = groupRepository; this.assignmentRepository = assignmentRepository; + this.subgroupRepository = subgroupRepository; this.scheduleGeneratorService = scheduleGeneratorService; } + @Transactional + public StudentGroup createGroup(CreateGroupRequest request) { + validateGroupRequest(request); + GroupDimensions candidate = resolveGroupDimensions(request); + + StudentGroup group = new StudentGroup(); + applyGroupDimensions(group, request, candidate); + StudentGroup saved = saveGroup(group); + clearScheduleCacheAfterCommit(); + return saved; + } + @Transactional public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) { validateCalendarRequest(request); @@ -122,6 +138,13 @@ public class AcademicStructureService { validateGroupRequest(request); StudentGroup group = groupRepository.findByIdForUpdate(id) .orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена")); + long activeSubgroupCapacity = subgroupRepository.sumActiveStudentCapacityByGroupId(id); + if (request.getGroupSize() < activeSubgroupCapacity) { + throw new IllegalArgumentException( + "Численность группы не может быть меньше суммы численностей активных подгрупп (" + + activeSubgroupCapacity + " чел.)" + ); + } GroupDimensions candidate = resolveGroupDimensions(request); boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream() @@ -139,13 +162,7 @@ public class AcademicStructureService { ); } - 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()); + applyGroupDimensions(group, request, candidate); StudentGroup saved = saveGroup(group); clearScheduleCacheAfterCommit(); return saved; @@ -217,26 +234,38 @@ public class AcademicStructureService { if (request.getName() == null || request.getName().isBlank()) { throw new IllegalArgumentException("Название группы обязательно"); } - if (request.getGroupSize() == null) { - throw new IllegalArgumentException("Численность группы обязательна"); + if (request.getGroupSize() == null || request.getGroupSize() <= 0) { + throw new IllegalArgumentException("Численность группы должна быть больше нуля"); } - if (request.getEducationFormId() == null) { + if (request.getEducationFormId() == null || request.getEducationFormId() <= 0) { throw new IllegalArgumentException("Форма обучения обязательна"); } - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + if (request.getDepartmentId() == null || request.getDepartmentId() <= 0) { throw new IllegalArgumentException("ID кафедры обязателен"); } - if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { - throw new IllegalArgumentException("Год начала обучения обязателен"); + if (request.getYearStartStudy() == null || request.getYearStartStudy() <= 0) { + throw new IllegalArgumentException("Год начала обучения должен быть больше нуля"); } - if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) { + if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() <= 0) { throw new IllegalArgumentException("Код специальности обязателен"); } - if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) { + if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() <= 0) { throw new IllegalArgumentException("Профиль обучения обязателен"); } } + private void applyGroupDimensions(StudentGroup group, + CreateGroupRequest request, + GroupDimensions candidate) { + 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()); + } + private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) { AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()) .orElseThrow(() -> new IllegalArgumentException("Учебный год не найден")); 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 37bf881..8f12ec7 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -284,7 +284,8 @@ public class ScheduleGeneratorService { if (endDate.isBefore(startDate)) { throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); } - if (java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) { + long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1; + if (inclusiveDays > MAX_RANGE_DAYS) { throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); } } 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 eb2f021..c70689e 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -298,7 +298,8 @@ public class ScheduleQueryService { if (endDate.isBefore(startDate)) { throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); } - if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) { + long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1; + if (inclusiveDays > MAX_RANGE_DAYS) { throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); } } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 734d875..175b09a 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -254,12 +254,14 @@ ON CONFLICT (name) DO NOTHING; CREATE TABLE IF NOT EXISTS student_groups ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, - group_size BIGINT NOT NULL, + group_size BIGINT NOT NULL + CONSTRAINT chk_student_groups_group_size_positive CHECK (group_size > 0), education_form_id BIGINT NOT NULL REFERENCES education_forms(id), department_id BIGINT NOT NULL REFERENCES departments(id), specialty_id BIGINT NOT NULL REFERENCES specialties(id), specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), - year_start_study BIGINT NOT NULL, + year_start_study BIGINT NOT NULL + CONSTRAINT chk_student_groups_year_start_positive CHECK (year_start_study > 0), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, @@ -289,7 +291,8 @@ CREATE TABLE IF NOT EXISTS subgroups ( id BIGSERIAL PRIMARY KEY, group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, - student_capacity INT, + student_capacity INT NOT NULL + CONSTRAINT chk_subgroups_student_capacity_positive CHECK (student_capacity > 0), status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', active_from DATE, active_to DATE, @@ -299,6 +302,76 @@ CREATE TABLE IF NOT EXISTS subgroups ( UNIQUE(group_id, name) ); +CREATE OR REPLACE FUNCTION validate_student_group_subgroup_capacity() +RETURNS TRIGGER AS $$ +DECLARE + active_capacity BIGINT; +BEGIN + SELECT COALESCE(SUM(student_capacity), 0) + INTO active_capacity + FROM subgroups + WHERE group_id = NEW.id + AND status <> 'ARCHIVED'; + + IF active_capacity > NEW.group_size THEN + RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы' + USING ERRCODE = '23514', + CONSTRAINT = 'chk_student_group_subgroup_capacity'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER validate_student_group_subgroup_capacity + BEFORE UPDATE OF group_size ON student_groups + FOR EACH ROW + EXECUTE FUNCTION validate_student_group_subgroup_capacity(); + +CREATE OR REPLACE FUNCTION validate_subgroup_student_capacity() +RETURNS TRIGGER AS $$ +DECLARE + group_capacity BIGINT; + allocated_capacity BIGINT; +BEGIN + SELECT group_size + INTO group_capacity + FROM student_groups + WHERE id = NEW.group_id + FOR UPDATE; + + IF group_capacity IS NULL OR NEW.status = 'ARCHIVED' THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' THEN + SELECT COALESCE(SUM(student_capacity), 0) + INTO allocated_capacity + FROM subgroups + WHERE group_id = NEW.group_id + AND status <> 'ARCHIVED'; + ELSE + SELECT COALESCE(SUM(student_capacity), 0) + INTO allocated_capacity + FROM subgroups + WHERE group_id = NEW.group_id + AND status <> 'ARCHIVED' + AND id <> OLD.id; + END IF; + + IF allocated_capacity + NEW.student_capacity > group_capacity THEN + RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы' + USING ERRCODE = '23514', + CONSTRAINT = 'chk_student_group_subgroup_capacity'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER validate_subgroup_student_capacity + BEFORE INSERT OR UPDATE OF group_id, student_capacity, status ON subgroups + FOR EACH ROW + EXECUTE FUNCTION validate_subgroup_student_capacity(); + INSERT INTO subgroups (group_id, name, student_capacity) SELECT sg.id, 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 index 62227ee..62ed96f 100644 --- a/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/config/auth/LoginRateLimitConcurrencyIntegrationTest.java @@ -46,7 +46,8 @@ 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"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { 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 8f197b1..bbeef02 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 @@ -51,7 +51,8 @@ class RefreshTokenServiceConcurrencyIntegrationTest { private static final String TENANT = "test-tenant"; @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java index c4fd3a1..e796481 100644 --- a/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/AcademicPeriodInvariantMigrationIntegrationTest.java @@ -32,7 +32,8 @@ class AcademicPeriodInvariantMigrationIntegrationTest { private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap"; @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год") diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java index bc07564..4edeefa 100644 --- a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java @@ -27,7 +27,8 @@ import static org.assertj.core.api.Assertions.catchThrowable; class AcademicStructureInvariantIntegrationTest { @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы") @@ -205,6 +206,123 @@ class AcademicStructureInvariantIntegrationTest { } } + @Test + @DisplayName("V1 отклоняет неположительную численность и уменьшение группы ниже активных подгрупп") + void baselineProtectsGroupAndSubgroupCapacityConstraints() throws SQLException { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + try (Connection connection = POSTGRES.createConnection("")) { + long groupId = queryLong( + connection, + "SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'" + ); + + assertSqlState( + () -> insertCapacityGroup(connection, "НУЛЕВАЯ-ГРУППА", 0, 2025), + "23514", + "chk_student_groups_group_size_positive" + ); + assertSqlState( + () -> insertCapacityGroup(connection, "НУЛЕВОЙ-ГОД", 10, 0), + "23514", + "chk_student_groups_year_start_positive" + ); + assertSqlState( + () -> insertSubgroup(connection, groupId, "Нулевая подгруппа", 0), + "23514", + "chk_subgroups_student_capacity_positive" + ); + assertSqlState( + () -> updateLong( + connection, + "UPDATE student_groups SET group_size = ? WHERE id = ?", + 24, + groupId + ), + "23514", + "Сумма численностей активных подгрупп" + ); + + assertThat(queryLong( + connection, + "SELECT group_size FROM student_groups WHERE id = ?", + groupId + )).isEqualTo(25); + } + } + + @Test + @DisplayName("V1 сериализует конкурентное уменьшение группы и добавление подгруппы") + void baselineProtectsConcurrentGroupAndSubgroupCapacityWrites() throws Exception { + Flyway flyway = baselineFlyway(); + flyway.clean(); + flyway.migrate(); + + long groupId; + try (Connection connection = POSTGRES.createConnection("")) { + groupId = insertCapacityGroup(connection, "ГРУППА-ГОНКА-ВМЕСТИМОСТИ", 30, 2025); + insertSubgroup(connection, groupId, "Исходная подгруппа", 20); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + long finalGroupId = groupId; + List> futures = List.of( + executor.submit(() -> concurrentWrite( + "UPDATE student_groups SET group_size = ? WHERE id = ?", + List.of(20L, finalGroupId), + ready, + start + )), + executor.submit(() -> concurrentWrite( + "INSERT INTO subgroups (group_id, name, student_capacity) VALUES (?, 'Новая подгруппа', 10)", + List.of(finalGroupId), + 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()).contains("Сумма численностей активных подгрупп"); + }); + + try (Connection connection = POSTGRES.createConnection("")) { + long groupSize = queryLong( + connection, + "SELECT group_size FROM student_groups WHERE id = ?", + groupId + ); + long subgroupCapacity = queryLong( + connection, + """ + SELECT COALESCE(SUM(student_capacity), 0) + FROM subgroups + WHERE group_id = ? AND status <> 'ARCHIVED' + """, + groupId + ); + assertThat(subgroupCapacity).isLessThanOrEqualTo(groupSize); + } + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + private WriteOutcome concurrentGroupUpdate(long groupId, long educationFormId, CountDownLatch ready, @@ -304,6 +422,63 @@ class AcademicStructureInvariantIntegrationTest { } } + private long insertCapacityGroup(Connection connection, + String name, + long groupSize, + int yearStartStudy) 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 + ?, + ?, + study_form.id, + department.id, + specialty.id, + profile.id, + ? + 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 + """)) { + statement.setString(1, name); + statement.setLong(2, groupSize); + statement.setInt(3, yearStartStudy); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } + } + + private void insertSubgroup(Connection connection, + long groupId, + String name, + int studentCapacity) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO subgroups (group_id, name, student_capacity) + VALUES (?, ?, ?) + """)) { + statement.setLong(1, groupId); + statement.setString(2, name); + statement.setInt(3, studentCapacity); + statement.executeUpdate(); + } + } + private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException { try (PreparedStatement statement = connection.prepareStatement(""" INSERT INTO academic_calendar_days ( 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 32e5456..a6b305a 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java @@ -23,7 +23,8 @@ import static org.assertj.core.api.Assertions.catchThrowable; class ScheduleOverrideMigrationIntegrationTest { @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные") 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 b81a1ee..7270392 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java @@ -24,7 +24,8 @@ class ScheduleRuleHoursMigrationIntegrationTest { private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload"; @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий") diff --git a/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java index 71110f4..578d2b4 100644 --- a/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java @@ -28,7 +28,8 @@ class SubjectOwnershipInvariantIntegrationTest { private static final String UNIQUE_INDEX = "uq_subjects_name_ci"; @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра") diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java index f888261..1506912 100644 --- a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java @@ -31,7 +31,8 @@ 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"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр") diff --git a/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java index 829313b..7f462e4 100644 --- a/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java @@ -30,7 +30,8 @@ class TimeSlotInvariantMigrationIntegrationTest { private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap"; @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @Test @DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты") diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java index 1fe0748..d87b827 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java @@ -43,7 +43,8 @@ import static org.mockito.Mockito.verify; class AcademicCalendarGridServiceIntegrationTest { @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { diff --git a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java index cb5242e..9b8af15 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java @@ -19,6 +19,7 @@ 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.repository.SubgroupRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -69,6 +70,8 @@ class AcademicStructureServiceTest { @Mock private StudentGroupCalendarAssignmentRepository assignmentRepository; @Mock + private SubgroupRepository subgroupRepository; + @Mock private ScheduleGeneratorService scheduleGeneratorService; private AcademicStructureService service; @@ -92,6 +95,7 @@ class AcademicStructureServiceTest { educationFormRepository, groupRepository, assignmentRepository, + subgroupRepository, scheduleGeneratorService ); academicYear = academicYear(); @@ -111,6 +115,7 @@ class AcademicStructureServiceTest { 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(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L); when(calendarRepository.saveAndFlush(any(AcademicCalendar.class))) .thenAnswer(invocation -> invocation.getArgument(0)); when(groupRepository.saveAndFlush(any(StudentGroup.class))) @@ -192,6 +197,56 @@ class AcademicStructureServiceTest { verify(scheduleGeneratorService).clearCache(); } + @Test + void createsGroupThroughSharedValidationAndClearsCache() { + StudentGroup saved = service.createGroup(groupRequest(2025, FORM_ID)); + + assertThat(saved.getName()).isEqualTo("ИВТ-26"); + assertThat(saved.getGroupSize()).isEqualTo(25L); + assertThat(saved.getEducationForm()).isSameAs(studyForm); + verify(groupRepository).saveAndFlush(saved); + verify(scheduleGeneratorService).clearCache(); + } + + @Test + void rejectsNonPositiveGroupSizeOnCreateAndUpdate() { + CreateGroupRequest createRequest = groupRequest(2025, FORM_ID); + createRequest.setGroupSize(0L); + CreateGroupRequest updateRequest = groupRequest(2025, FORM_ID); + updateRequest.setGroupSize(-1L); + + assertThatThrownBy(() -> service.createGroup(createRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Численность группы должна быть больше нуля"); + assertThatThrownBy(() -> service.updateGroup(GROUP_ID, updateRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Численность группы должна быть больше нуля"); + + verify(groupRepository, never()).saveAndFlush(any()); + } + + @Test + void rejectsGroupSizeBelowActiveSubgroups() { + when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(24L); + CreateGroupRequest request = groupRequest(2025, FORM_ID); + request.setGroupSize(23L); + + assertThatThrownBy(() -> service.updateGroup(GROUP_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Численность группы не может быть меньше суммы численностей активных подгрупп (24 чел.)"); + + verify(groupRepository, never()).saveAndFlush(any()); + } + + @Test + void rejectsNonPositiveStudyStartYear() { + CreateGroupRequest request = groupRequest(0, FORM_ID); + + assertThatThrownBy(() -> service.createGroup(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Год начала обучения должен быть больше нуля"); + } + @Test void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() { group.setYearStartStudy(2020); diff --git a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java index 60df341..6040310 100644 --- a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java @@ -41,7 +41,8 @@ class PostgreSqlAdvisoryLockIntegrationTest { private static final LocalDate TEST_DATE = LocalDate.of(2026, 9, 1); @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java index 9bb02cb..41dd467 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java @@ -13,6 +13,7 @@ import java.util.stream.IntStream; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -134,6 +135,29 @@ class ScheduleGenerationQueryCountTest { assertThat(repositoryCalls).isEqualTo(7); } + @Test + @DisplayName("Генератор отклоняет 121 включительную дату") + void rejects121InclusiveDates() { + ScheduleGeneratorService generator = new ScheduleGeneratorService( + mock(ScheduleRuleRepository.class), + mock(GroupRepository.class), + mock(UserRepository.class), + mock(AcademicDateService.class), + mock(TimeSlotRepository.class), + mock(TimeSlotScopeRepository.class), + mock(TimeSlotDateAssignmentRepository.class) + ); + LocalDate startDate = LocalDate.of(2026, 1, 1); + + assertThatThrownBy(() -> generator.buildScheduleForGroups( + List.of(), + startDate, + startDate.plusDays(120) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Диапазон расписания не может превышать 120 дней"); + } + private AcademicYear academicYear() { AcademicYear academicYear = new AcademicYear(); academicYear.setId(1L); 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 9873600..39d71d1 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java @@ -73,6 +73,37 @@ class ScheduleQueryServiceTest { verify(groupRepository, never()).findAll(); } + @Test + void rangeLimitCountsBothBoundaryDates() { + ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class); + GroupRepository groupRepository = mock(GroupRepository.class); + ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class); + StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class); + ScheduleQueryService service = new ScheduleQueryService( + generatorService, + groupRepository, + overrideRepository, + groupLifecycleService + ); + LocalDate startDate = LocalDate.of(2026, 1, 1); + LocalDate allowedEndDate = startDate.plusDays(119); + LocalDate rejectedEndDate = startDate.plusDays(120); + when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate)) + .thenReturn(List.of()); + when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate)) + .thenReturn(List.of()); + + assertThat(service.search(null, 10L, null, null, + null, null, null, null, + startDate, allowedEndDate)).isEmpty(); + + assertThatThrownBy(() -> service.search(null, 10L, null, null, + null, null, null, null, + startDate, rejectedEndDate)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Диапазон расписания не может превышать 120 дней"); + } + @ParameterizedTest @ValueSource(ints = {51, 100}) void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) { diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java index f164708..806550a 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java @@ -53,7 +53,8 @@ import static org.mockito.Mockito.verify; class ScheduleRuleServiceConcurrencyIntegrationTest { @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { 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 3c7a4f0..cfb9e4a 100644 --- a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java @@ -42,7 +42,8 @@ class TenantLifecyclePostgreSqlIntegrationTest { private static final String VALID_DATABASE = "tenant_valid"; @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine"); + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES); private TenantRoutingDataSource routingDataSource; private TenantDatabaseMigrationService migrationService; diff --git a/backend/src/test/java/com/magistr/app/testing/TestContainerImages.java b/backend/src/test/java/com/magistr/app/testing/TestContainerImages.java new file mode 100644 index 0000000..78a0d4a --- /dev/null +++ b/backend/src/test/java/com/magistr/app/testing/TestContainerImages.java @@ -0,0 +1,16 @@ +package com.magistr.app.testing; + +import org.testcontainers.utility.DockerImageName; + +/** + * Единые воспроизводимые образы интеграционных тестов. + */ +public final class TestContainerImages { + + public static final DockerImageName POSTGRES = DockerImageName.parse( + "postgres:16.3-alpine@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41" + ).asCompatibleSubstituteFor("postgres"); + + private TestContainerImages() { + } +} diff --git a/compose.yaml b/compose.yaml index 8f263ce..e243f6b 100755 --- a/compose.yaml +++ b/compose.yaml @@ -45,22 +45,22 @@ services: start_period: 20s networks: - magistr + - proxy frontend: restart: unless-stopped build: context: ./frontend dockerfile: Dockerfile - ports: - - "${HTTP_PORT:-80}:80" networks: - magistr + - proxy depends_on: backend: condition: service_healthy db: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-myuser} @@ -79,6 +79,8 @@ services: networks: magistr: driver: bridge + proxy: + external: true volumes: postgres_data: diff --git a/docs/API.md b/docs/API.md index d19efdd..edbb1f1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -8,7 +8,7 @@ ```json { - "timestamp": "2026-05-27T19:47:54", + "timestamp": "2026-05-27T16:47:54Z", "status": 400, "error": "Некорректный запрос", "message": "Некорректные параметры запроса", @@ -18,6 +18,11 @@ Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`. +Все поля момента времени (`createdAt`, `updatedAt`, `reviewedAt`, `archivedAt` и +аналогичные) сериализуются как ISO-8601 UTC с суффиксом `Z`. Поля календарной даты +(`date`, `validFrom`, `validTo`, `activeFrom`, `activeTo`) остаются строками `YYYY-MM-DD` +без часового пояса и вычисляются по бизнес-зоне `Europe/Moscow`. + Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не @@ -360,7 +365,13 @@ Refresh-токен ротируется при каждом успешном о | `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | | `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | -Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`. +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 +календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля +невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и +отклоняется. Если у группы нет назначения календарного графика на учебный год даты, +расписание для неё возвращается пустым списком. Время пары берётся из базового слота +правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из +`/api/admin/time-slots`. **Пример:** ```http @@ -973,7 +984,11 @@ payload обрабатываются один раз, используется } ``` -`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось. +`groupSize`, `yearStartStudy` и все связанные идентификаторы должны быть положительными; +`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как +legacy-alias для старых клиентов и исторически содержит ID записи из +`/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже +`0`, если обучение ещё не началось. Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`. @@ -1000,6 +1015,10 @@ payload обрабатываются один раз, используется Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без изменений. +Если у группы есть активные подгруппы, `groupSize` нельзя уменьшить ниже суммы их +`studentCapacity`. Такой запрос отклоняется без изменения группы. Параллельные изменения +группы и подгрупп сериализуются на backend и проверяются ограничениями PostgreSQL. + ### `DELETE /api/groups/{id}` Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой. @@ -1012,7 +1031,10 @@ payload обрабатываются один раз, используется Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. -Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы. +`studentCapacity` обязателен и должен быть больше нуля. Для одной учебной группы сумма +численностей активных подгрупп не может превышать численность самой группы. Frontend на +вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или +три подгруппы. Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`. Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8443647..3b37e94 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -273,6 +273,12 @@ GiST exclusion constraints и триггеры PostgreSQL, связывающи ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт устаревшее назначение. +Создание и обновление группы используют один validator положительных размерностей. +Уменьшение численности дополнительно сверяется с суммой активных подгрупп. CRUD подгрупп и +изменение группы захватывают строку родительской группы `FOR UPDATE`; V1 повторяет проверку +триггерами обеих таблиц. Поэтому конкурентно могут завершиться только совместимые +изменения, а сумма активных `student_capacity` никогда не превышает `group_size`. + `TeacherDepartmentService` является единым источником датированных решений о кафедре преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты нагрузки читают `teacher_department_assignments` на целевую дату, не используют @@ -329,6 +335,8 @@ rollback. Инвалидация кэша зарегистрирована че lost update или проверке устаревшего снимка. Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов. +Обе границы диапазона включительны, поэтому общий лимит 120 дат вычисляется как +`endDate - startDate + 1` одинаково в query- и generator-слоях. `ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`. `AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона. @@ -345,6 +353,23 @@ Teacher-only чтение строит базовое расписание на --- +## Временная модель + +`BusinessTimeService` является единым источником текущей календарной даты и абсолютного +момента. Бизнес-дата вычисляется из инъецируемого `Clock` в зоне +`BUSINESS_TIME_ZONE` (по умолчанию `Europe/Moscow`), поэтому lifecycle, доступность +расписания, назначения кафедр и создание периодов не зависят от timezone JVM или хоста. + +Абсолютные моменты представлены `Instant`, хранятся в PostgreSQL как `TIMESTAMPTZ` и +сериализуются в UTC. Hibernate принудительно использует `hibernate.jdbc.time_zone=UTC`, +Jackson — UTC. Отдельные календарные даты представлены `LocalDate`/SQL `DATE`; для них +смещение и время суток не передаются. + +В production-конструкторах используется системный UTC clock. Тесты передают фиксированный +`Clock`, включая границу московской полуночи, поэтому переход бизнес-даты воспроизводим. + +--- + ## Аутентификация Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow: diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 2dbe1ca..c9d9232 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -66,9 +66,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие ### Учебные группы (Student Groups) -- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения +- **Поля:** Название, положительная численность, форма обучения, кафедра, специальность, профиль обучения, положительный год начала обучения - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число -- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе. +- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Численность каждой подгруппы обязательна и положительна, а сумма численностей активных подгрупп не может превышать численность группы. Численность группы нельзя уменьшить ниже этой суммы. Изменения группы и подгрупп сериализуются блокировкой родительской группы и дополнительно защищены триггерами V1, поэтому параллельные запросы не нарушают инвариант. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе. - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график - **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика - **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным. @@ -193,6 +193,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра. +Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными +датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат +разрешены, 121 дата отклоняется до генерации. + Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`, `departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c841e6a..6815389 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -2,11 +2,13 @@ ## Общая информация -- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine3.20`, продакшн — managed PostgreSQL) +- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL) - **Управление схемой:** Flyway (программный запуск) - **Hibernate DDL:** Отключён (`ddl-auto=none`) - **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов) - **Мультитенантность:** Каждый тенант = отдельная БД +- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant` +- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow` --- @@ -44,9 +46,9 @@ erDiagram VARCHAR status DATE active_from DATE active_to - TIMESTAMP archived_at - TIMESTAMP created_at - TIMESTAMP updated_at + TIMESTAMPTZ archived_at + TIMESTAMPTZ created_at + TIMESTAMPTZ updated_at } auth_refresh_tokens { @@ -54,9 +56,9 @@ erDiagram BIGINT user_id FK VARCHAR tenant VARCHAR token_hash UK - TIMESTAMP issued_at - TIMESTAMP expires_at - TIMESTAMP revoked_at + TIMESTAMPTZ issued_at + TIMESTAMPTZ expires_at + TIMESTAMPTZ revoked_at VARCHAR rotated_to_token_hash } @@ -66,10 +68,10 @@ erDiagram 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 + TIMESTAMPTZ window_started_at + TIMESTAMPTZ last_failure_at + TIMESTAMPTZ blocked_until + TIMESTAMPTZ updated_at } auth_login_attempt_audit { @@ -78,7 +80,7 @@ erDiagram VARCHAR username_normalized VARCHAR client_ip VARCHAR outcome - TIMESTAMP occurred_at + TIMESTAMPTZ occurred_at INTEGER retry_after_seconds } @@ -86,7 +88,7 @@ erDiagram BIGSERIAL id PK VARCHAR name UK TEXT description - TIMESTAMP created_at + TIMESTAMPTZ created_at } student_groups { @@ -98,7 +100,7 @@ erDiagram BIGINT specialty_id FK BIGINT specialty_profile_id FK BIGINT year_start_study - TIMESTAMP created_at + TIMESTAMPTZ created_at } subgroups { @@ -114,7 +116,7 @@ erDiagram VARCHAR code BIGINT department_id FK TEXT description - TIMESTAMP created_at + TIMESTAMPTZ created_at } lesson_types { @@ -141,9 +143,9 @@ erDiagram VARCHAR status DATE active_from DATE active_to - TIMESTAMP archived_at + TIMESTAMPTZ archived_at TEXT description - TIMESTAMP created_at + TIMESTAMPTZ created_at } classroom_equipments { @@ -180,7 +182,7 @@ erDiagram VARCHAR status BIGINT requested_by FK BIGINT reviewed_by FK - TIMESTAMP reviewed_at + TIMESTAMPTZ reviewed_at TEXT review_comment BIGINT created_teacher_id FK } @@ -190,7 +192,7 @@ erDiagram BIGINT subject_id FK BIGINT author_id FK TEXT comment - TIMESTAMP created_at + TIMESTAMPTZ created_at } teacher_lesson_types { @@ -256,8 +258,8 @@ erDiagram BIGINT specialty_profile_id FK BIGINT study_form_id FK INT course_count - TIMESTAMP created_at - TIMESTAMP updated_at + TIMESTAMPTZ created_at + TIMESTAMPTZ updated_at } academic_calendar_days { @@ -275,7 +277,7 @@ erDiagram BIGINT calendar_id FK INT semester_number BIGINT subject_id FK - TIMESTAMP created_at + TIMESTAMPTZ created_at } student_group_calendar_assignments { @@ -433,10 +435,10 @@ erDiagram | `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти | | `active_from` | DATE | Дата начала действия записи | | `active_to` | DATE | Дата окончания действия записи | -| `archived_at` | TIMESTAMP | Когда пользователь архивирован | +| `archived_at` | TIMESTAMPTZ | Когда пользователь архивирован | | `archive_reason` | TEXT | Причина архивирования | -| `created_at` | TIMESTAMP | Дата создания | -| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) | +| `created_at` | TIMESTAMPTZ | Дата создания | +| `updated_at` | TIMESTAMPTZ | Дата обновления (авто-триггер) | > **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. @@ -447,9 +449,9 @@ erDiagram | `user_id` | BIGINT FK → users (CASCADE) | Пользователь | | `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен | | `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена | -| `issued_at` | TIMESTAMP | Дата выдачи | -| `expires_at` | TIMESTAMP | Дата истечения | -| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии | +| `issued_at` | TIMESTAMPTZ | Дата выдачи | +| `expires_at` | TIMESTAMPTZ | Дата истечения | +| `revoked_at` | TIMESTAMPTZ | Дата отзыва, `NULL` для активной сессии | | `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации | | `user_agent` | VARCHAR(512) | User-Agent клиента | | `ip_address` | VARCHAR(64) | IP-адрес клиента | @@ -469,11 +471,11 @@ audit retention (по умолчанию 30 дней). Индексы `idx_auth_ | `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 | Последнее изменение состояния | +| `window_started_at` | TIMESTAMPTZ | Начало окна учёта попыток | +| `last_failure_at` | TIMESTAMPTZ | Время последнего отказа | +| `blocked_until` | TIMESTAMPTZ | Окончание временной блокировки либо `NULL` | +| `created_at` | TIMESTAMPTZ | Время создания состояния | +| `updated_at` | TIMESTAMPTZ | Последнее изменение состояния | Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её @@ -489,7 +491,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN | `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса | | `client_ip` | VARCHAR(64) | Проверенный IP клиента | | `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` | -| `occurred_at` | TIMESTAMP | Время события | +| `occurred_at` | TIMESTAMPTZ | Время события | | `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` | Таблица принципиально не содержит пароль, его хэш из запроса или признак существования @@ -512,16 +514,16 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN |---------|-----|----------| | `id` | BIGSERIAL PK | ID | | `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное | -| `group_size` | BIGINT | Количество студентов | +| `group_size` | BIGINT CHECK (> 0) | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | | `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | -| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | +| `year_start_study` | BIGINT CHECK (> 0) | Год начала обучения, используется для вычисления текущего курса | | `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` | | `active_from` | DATE | Дата начала действия группы | | `active_to` | DATE | Дата окончания действия группы для исторических расчётов | -| `archived_at` | TIMESTAMP | Дата и время архивирования | +| `archived_at` | TIMESTAMPTZ | Дата и время архивирования | | `archive_reason` | TEXT | Причина архивирования | #### `subgroups` — Подгруппы @@ -530,9 +532,18 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN | `id` | BIGSERIAL PK | ID | | `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа | | `name` | VARCHAR(100) | Название подгруппы | -| `student_capacity` | INT | Количество студентов | +| `student_capacity` | INT NOT NULL CHECK (> 0) | Количество студентов | -Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий. +Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах +могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное +создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий. + +CHECK-ограничения требуют положительные `student_groups.group_size`, +`student_groups.year_start_study` и `subgroups.student_capacity`. Триггеры +`validate_student_group_subgroup_capacity` и +`validate_subgroup_student_capacity` блокируют родительскую группу и не допускают, +чтобы сумма численностей активных подгрупп превышала `group_size`. Инвариант действует и +при прямой записи в БД, смене статуса/родительской группы и конкурентных транзакциях. #### `subjects` — Дисциплины | Колонка | Тип | Описание | @@ -561,7 +572,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN | `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях | | `active_from` | DATE | Дата начала действия записи | | `active_to` | DATE | Дата окончания действия записи | -| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации | +| `archived_at` | TIMESTAMPTZ | Когда аудитория выведена из эксплуатации | | `archive_reason` | TEXT | Причина архивирования | | `description` | TEXT | Описание | @@ -618,7 +629,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN | `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры | | `is_primary` | BOOLEAN | Основная кафедра преподавателя | | `comment` | TEXT | Комментарий к переводу | -| `created_at` | TIMESTAMP | Дата создания записи | +| `created_at` | TIMESTAMPTZ | Дата создания записи | | `created_by` | BIGINT FK → users | Кто оформил перевод | Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной @@ -641,11 +652,11 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN | `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` | | `requested_by` | BIGINT FK → users | Пользователь, создавший заявку | | `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку | -| `reviewed_at` | TIMESTAMP | Дата рассмотрения | +| `reviewed_at` | TIMESTAMPTZ | Дата рассмотрения | | `review_comment` | TEXT | Комментарий администратора | | `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения | -| `created_at` | TIMESTAMP | Дата создания заявки | -| `updated_at` | TIMESTAMP | Дата последнего изменения | +| `created_at` | TIMESTAMPTZ | Дата создания заявки | +| `updated_at` | TIMESTAMPTZ | Дата последнего изменения | Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином. @@ -656,7 +667,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN | `subject_id` | BIGINT FK → subjects | Дисциплина | | `author_id` | BIGINT FK → users | Автор комментария | | `comment` | TEXT | Текст комментария | -| `created_at` | TIMESTAMP | Дата создания | +| `created_at` | TIMESTAMPTZ | Дата создания | #### `time_slot_scopes` — Сетки времени | Колонка | Тип | Описание | @@ -748,8 +759,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | | `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | | `course_count` | INT CHECK(1–8) | Количество курсов в сетке | -| `created_at` | TIMESTAMP | Дата создания | -| `updated_at` | TIMESTAMP | Дата обновления | +| `created_at` | TIMESTAMPTZ | Дата создания | +| `updated_at` | TIMESTAMPTZ | Дата обновления | Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год, специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная @@ -777,7 +788,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл | `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | | `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... | | `subject_id` | BIGINT FK → subjects | Дисциплина из справочника | -| `created_at` | TIMESTAMP | Дата создания привязки | +| `created_at` | TIMESTAMPTZ | Дата создания привязки | Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend и триггером `trg_calendar_subjects_dimensions` по `academic_calendars.course_count * 2`. @@ -841,7 +852,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл | `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом | | `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом | | `locked_by` | BIGINT FK → users | Кто выполнил закрепление | -| `locked_at` | TIMESTAMP | Когда выполнено закрепление | +| `locked_at` | TIMESTAMPTZ | Когда выполнено закрепление | | `lock_comment` | TEXT | Комментарий к закреплению | V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить @@ -870,7 +881,7 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр | `new_lesson_format` | VARCHAR(30) | Новый формат занятия | | `comment` | TEXT | Причина изменения | | `created_by` | BIGINT FK → users | Автор изменения | -| `created_at` | TIMESTAMP | Дата создания | +| `created_at` | TIMESTAMPTZ | Дата создания | Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для одной и той же пары. Базовая схема V1 добавляет структурные инварианты: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e615db1..eeb75c3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,6 +23,21 @@ docker compose logs -f Приложение доступно: **http://localhost:80** +### Обновление контейнерных артефактов + +Базовые и служебные образы указываются как `точный-tag@sha256:manifest-digest`; сторонние +Actions — полным 40-символьным commit SHA. При обновлении версии одновременно обновите tag, +digest и комментарий версии, затем выполните: + +```bash +bash scripts/test-artifact-pinning.sh +K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh +``` + +Для скачиваемого исполняемого файла обязательны точная версия и проверка опубликованного +SHA-256 до установки. Отключать SBOM, provenance или блокирующее HIGH/CRITICAL-сканирование +ради прохождения pipeline запрещено. + ### Пересборка после изменений ```bash @@ -220,6 +235,18 @@ public class AbsenceController { --- +## Работа со временем + +- Для абсолютного момента используйте `Instant`; в БД ему соответствует `TIMESTAMPTZ`. +- Для календарной даты без времени используйте `LocalDate`; текущую бизнес-дату получайте + через инъекцию `BusinessTimeService`, а не через `LocalDate.now()`. +- Новую бизнес-логику, зависящую от текущего времени, проверяйте фиксированным `Clock` на + границе московской полуночи. +- Во frontend не формируйте date-only через `Date.toISOString().slice(0, 10)`: + используйте `formatLocalDate()` или исходную строку `YYYY-MM-DD`. + +--- + ## Работа с миграциями Flyway ### Правила diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6e4c5f2..a56fc3c 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -159,6 +159,10 @@ frontend/ ### Особенности админских вкладок +- Общий `formatLocalDate()` формирует `YYYY-MM-DD` из локальных компонентов `Date`, не + используя `toISOString()`. Кабинет кафедры и редактор академического календаря также + выполняют календарную арифметику локальными компонентами, поэтому даты первых часов + суток и границы месяца не сдвигаются из-за преобразования в UTC. - Вкладка `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 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index ad87888..e716460 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -7,16 +7,29 @@ ```yaml services: backend: # Spring Boot (Java 17), внутренний порт 8080 - frontend: # Apache httpd, публикует HTTP_PORT (по умолчанию 80) + frontend: # Apache httpd, внутренний порт 80 db: # PostgreSQL 16.3, внутренний порт 5432 ``` ### Сеть -Compose сам создаёт изолированную bridge-сеть `magistr`. Внешняя сеть или отдельно -установленный reverse proxy для локального запуска не нужны. Apache раздаёт frontend и -проксирует same-origin пути `/api` и `/actuator/health` в backend; backend и PostgreSQL не -публикуют порты на хосте. +Compose сам создаёт изолированную bridge-сеть `magistr` для приложения и подключает backend +с frontend к существующей внешней Docker-сети `proxy`. Caddy из `../сaddy-proxy/` также +подключён к `proxy`: на `localhost` он отправляет `/api/*` непосредственно в `backend:8080`, +а остальные пути — в `frontend:80`. Контейнеры Magistr не публикуют порты на хосте; +PostgreSQL доступен только во внутренней сети `magistr`. + +Перед первым запуском создайте общую сеть и поднимите Caddy: + +```bash +docker network inspect proxy >/dev/null 2>&1 || docker network create proxy +docker compose -f ../сaddy-proxy/compose.yaml up -d +docker compose up -d --build +``` + +Для `https://localhost` Caddy выпускает сертификат своим локальным CA. На CachyOS/Arch +корневой сертификат устанавливается в системное хранилище через `trust anchor`; точная +команда приведена в `STARTUP_GUIDE.md`. Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный `docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс. @@ -39,8 +52,9 @@ LOGIN_RATE_BASE_BLOCK_DURATION=1m LOGIN_RATE_MAX_BLOCK_DURATION=15m LOGIN_AUDIT_RETENTION=90d TRUSTED_PROXY_CIDRS=172.16.0.0/12 +BUSINESS_TIME_ZONE=Europe/Moscow +TZ=Europe/Moscow OTEL_SDK_DISABLED=true -HTTP_PORT=80 ``` Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env` @@ -51,10 +65,15 @@ HTTP_PORT=80 согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует. +`BUSINESS_TIME_ZONE` задаёт правила календарных бизнес-дат, а `TZ` и +`JAVA_TOOL_OPTIONS=-Duser.timezone=...` фиксируют timezone JVM и контейнера. Абсолютные +timestamps при этом всегда передаются между Java и PostgreSQL в UTC. Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён; при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы. +Kubernetes `app-config` также явно задаёт `BUSINESS_TIME_ZONE=Europe/Moscow`, +`TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow` для backend pod. Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение останавливает запуск; профиль `production` также требует Secure refresh-cookie. @@ -64,10 +83,10 @@ reverse proxy: заголовок `X-Forwarded-For` от остальных ис ### Dockerfile (Backend) Backend собирается через multi-stage сборку Maven: -1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package` +1. Этап сборки: `maven:3.9.9-eclipse-temurin-17@sha256:f58d59b...` → `mvn package` 2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и проверяется по закреплённому SHA-256 -3. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar` +3. Этап запуска: `eclipse-temurin:17-jre-alpine@sha256:02320dd4...` → `java -jar app.jar` ### Dockerfile (Frontend) @@ -82,7 +101,8 @@ 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. Первый этап собирает зафиксированный +Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным +tag и manifest digest. Первый этап frontend собирает зафиксированный OpenTelemetry bundle; второй раздаёт только runtime- файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`, `mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно @@ -340,16 +360,26 @@ Deployment-файлов запрещено. Расположение: `.gitea/workflows/docker-build.yaml` Основные шаги: -1. Backend unit/component tests, frontend static/unit tests, Compose validation и shell-тест - immutable rollout/rollback. +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:...`, сохраняет предыдущие + SHA-tag и release tag без `latest`. Сторонние Actions закреплены полными commit SHA. +3. BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build + jobs также возвращают registry digest каждого образа. +4. Отдельный обязательный job сканирует опубликованные digests закреплённым Trivy `0.63.0` и + блокирует deploy при исправимых `HIGH`/`CRITICAL` уязвимостях. +5. Deploy job устанавливает фиксированный `kubectl v1.33.12` только после SHA-256 проверки. + Java Agent также имеет точную версию и checksum. +6. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба предыдущих образа и повторно проверяет их готовность. -5. Workflow-wide concurrency lock не допускает одновременные production deployment. +7. Workflow-wide concurrency lock не допускает одновременные production deployment. + +`scripts/check-artifact-pinning.sh` проверяет Dockerfile, Compose, Actions, checksum и CI +gates. При передаче `K8S_DIR=../k8s` он дополнительно требует digest у каждого production +образа Kubernetes. Внешний `otel-collector.yaml` использует официальный Collector Contrib +`0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`; +версия и manifest digest обновляются только вместе и проверяются до rollout. --- diff --git a/docs/README.md b/docs/README.md index 7fa4038..6046bc0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ - Docker и Docker Compose - Git +- запущенный Caddy из `../сaddy-proxy/` ### Локальный запуск @@ -41,13 +42,21 @@ cp .env.example .env # Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET. # JWT_SECRET можно сгенерировать командой: openssl rand -base64 48 -# 3. Запустить все сервисы +# 3. Один раз создать общую proxy-сеть и запустить Caddy +docker network inspect proxy >/dev/null 2>&1 || docker network create proxy +docker compose -f ../сaddy-proxy/compose.yaml up -d + +# 4. Запустить все сервисы Magistr docker compose up -d --build ``` -Compose сам создаёт внутреннюю сеть и именованный том PostgreSQL. После запуска приложение -доступно по адресу **http://localhost:80**; если в `.env` задан другой `HTTP_PORT`, используйте -его. Backend и PostgreSQL наружу не публикуются: `/api` проксируется frontend-контейнером. +Compose создаёт внутреннюю сеть и именованный том PostgreSQL, а backend/frontend подключает +к общей внешней сети `proxy`. Они не публикуют порты на хосте: Caddy принимает запросы на +**https://localhost** (`http://localhost` перенаправляется на HTTPS), `/api` отправляет в +backend, остальные пути — во frontend. PostgreSQL остаётся только во внутренней сети. + +Caddy использует локальный корневой сертификат. В CachyOS/Arch его можно добавить в +системное хранилище командами из `STARTUP_GUIDE.md`; без этого браузер покажет предупреждение. **Учётные данные по умолчанию:** diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 6becd8a..de46715 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -1,5 +1,5 @@ import { api } from '../api.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; const DAY_OPTIONS = [ { value: 1, label: 'Понедельник', shortLabel: 'Пн' }, @@ -1473,24 +1473,36 @@ export async function initAcademicCalendar() { } function previousIsoDate(value) { - const date = new Date(`${value}T00:00:00Z`); - date.setUTCDate(date.getUTCDate() - 1); - return date.toISOString().slice(0, 10); + const date = parseLocalIsoDate(value); + date.setDate(date.getDate() - 1); + return formatLocalDate(date); } function enumerateDates(startIso, endIso) { const result = []; - const current = new Date(`${startIso}T00:00:00Z`); - const end = new Date(`${endIso}T00:00:00Z`); + const current = parseLocalIsoDate(startIso); + const end = parseLocalIsoDate(endIso); while (current <= end) { - const iso = current.toISOString().slice(0, 10); - const jsDay = current.getUTCDay(); + const iso = formatLocalDate(current); + const jsDay = current.getDay(); result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); - current.setUTCDate(current.getUTCDate() + 1); + current.setDate(current.getDate() + 1); } return result; } + function parseLocalIsoDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + const date = new Date(year, month - 1, day, 12); + if (!year || !month || !day || Number.isNaN(date.getTime()) + || date.getFullYear() !== year + || date.getMonth() !== month - 1 + || date.getDate() !== day) { + throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД'); + } + return date; + } + function groupBy(items, keyFn) { const grouped = new Map(); items.forEach(item => { diff --git a/scripts/check-artifact-pinning.sh b/scripts/check-artifact-pinning.sh new file mode 100755 index 0000000..4c603bd --- /dev/null +++ b/scripts/check-artifact-pinning.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_ROOT=${PROJECT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)} +WORKFLOW="$PROJECT_ROOT/.gitea/workflows/docker-build.yaml" + +failures=0 + +fail() { + printf '[АРТЕФАКТЫ] Ошибка: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +require_file() { + local file=$1 + if [[ ! -f "$file" ]]; then + fail "не найден обязательный файл ${file#"$PROJECT_ROOT"/}" + return 1 + fi +} + +check_dockerfile() { + local file=$1 + local line + + require_file "$file" || return + while IFS= read -r line; do + if [[ ! "$line" =~ ^FROM[[:space:]]+[^[:space:]]+@sha256:[0-9a-f]{64}([[:space:]]+AS[[:space:]]+[A-Za-z0-9._-]+)?$ ]]; then + fail "базовый образ в ${file#"$PROJECT_ROOT"/} должен содержать полный sha256 digest: $line" + fi + done < <(grep -E '^FROM[[:space:]]+' "$file") +} + +check_compose_images() { + local line + + require_file "$PROJECT_ROOT/compose.yaml" || return + while IFS= read -r line; do + if [[ ! "$line" =~ image:[[:space:]]+[^[:space:]]+@sha256:[0-9a-f]{64}[[:space:]]*$ ]]; then + fail "образ Compose должен содержать полный sha256 digest: ${line#*image: }" + fi + done < <(grep -E '^[[:space:]]+image:[[:space:]]+' "$PROJECT_ROOT/compose.yaml") +} + +check_actions() { + local action_ref + + require_file "$WORKFLOW" || return + while IFS= read -r action_ref; do + if [[ ! "$action_ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$ ]]; then + fail "Action должен быть закреплён полным commit SHA: $action_ref" + fi + done < <(sed -nE 's/^[[:space:]]+uses:[[:space:]]+([^[:space:]#]+).*/\1/p' "$WORKFLOW") +} + +require_count() { + local pattern=$1 + local expected=$2 + local description=$3 + local actual + + actual=$(grep -Ec "$pattern" "$WORKFLOW" || true) + if (( actual < expected )); then + fail "$description: ожидается не менее $expected, найдено $actual" + fi +} + +check_workflow_gates() { + require_file "$WORKFLOW" || return + + require_count '^[[:space:]]+sbom:[[:space:]]+true$' 2 \ + "для обоих образов должна публиковаться SBOM attestation" + require_count '^[[:space:]]+provenance:[[:space:]]+mode=max$' 2 \ + "для обоих образов должна публиковаться provenance attestation" + require_count 'image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL' 2 \ + "оба образа должны проходить блокирующее HIGH/CRITICAL-сканирование" + require_count 'TRIVY_IMAGE: [^[:space:]]+@sha256:[0-9a-f]{64}$' 1 \ + "образ Trivy должен быть закреплён digest" + require_count 'MAVEN_IMAGE: [^[:space:]]+@sha256:[0-9a-f]{64}$' 1 \ + "образ Maven для CI должен быть закреплён digest" + require_count 'NODE_IMAGE: [^[:space:]]+@sha256:[0-9a-f]{64}$' 1 \ + "образ Node.js для CI должен быть закреплён digest" + + if ! grep -Eq '^ needs: \[build-and-push-backend, build-and-push-frontend, security-scan\]$' "$WORKFLOW"; then + fail "deploy обязан зависеть от успешного security-scan" + fi + + if ! grep -Eq '^ KUBECTL_VERSION: v[0-9]+\.[0-9]+\.[0-9]+$' "$WORKFLOW" \ + || ! grep -Eq '^ KUBECTL_SHA256: [0-9a-f]{64}$' "$WORKFLOW" \ + || ! grep -Fq 'sha256sum --check --strict' "$WORKFLOW"; then + fail "kubectl должен иметь точную версию и обязательную SHA-256 проверку" + fi +} + +check_downloaded_backend_artifact() { + local dockerfile="$PROJECT_ROOT/backend/Dockerfile" + + require_file "$dockerfile" || return + if ! grep -Eq '^ARG OTEL_JAVAAGENT_VERSION=[0-9]+\.[0-9]+\.[0-9]+$' "$dockerfile" \ + || ! grep -Eq '^ARG OTEL_JAVAAGENT_SHA256=[0-9a-f]{64}$' "$dockerfile" \ + || ! grep -Fq 'sha256sum -c -' "$dockerfile"; then + fail "OpenTelemetry Java Agent должен иметь точную версию и SHA-256 проверку" + fi +} + +check_testcontainer_images() { + local image_catalog="$PROJECT_ROOT/backend/src/test/java/com/magistr/app/testing/TestContainerImages.java" + + [[ -d "$PROJECT_ROOT/backend/src/test/java" ]] || return 0 + require_file "$image_catalog" || return + if ! grep -Eq 'postgres:[^"[:space:]]+@sha256:[0-9a-f]{64}' "$image_catalog" \ + || ! grep -Fq 'asCompatibleSubstituteFor("postgres")' "$image_catalog"; then + fail "образ PostgreSQL Testcontainers должен содержать digest и явную совместимость" + fi +} + +check_optional_kubernetes_images() { + local k8s_dir=${K8S_DIR:-} + local line + + [[ -n "$k8s_dir" ]] || return 0 + if [[ ! -d "$k8s_dir" ]]; then + fail "каталог Kubernetes не найден: $k8s_dir" + return + fi + + while IFS= read -r line; do + if [[ "$line" =~ image:[[:space:]]+([^[:space:]]+) ]]; then + local image_ref=${BASH_REMATCH[1]} + if [[ ! "$image_ref" =~ @sha256:[0-9a-f]{64}$ ]]; then + fail "production-образ Kubernetes должен содержать digest: $image_ref" + fi + fi + done < <(grep -R -h -E '^[[:space:]]+image:[[:space:]]+' "$k8s_dir" --include='*.yaml' --include='*.yml' || true) +} + +check_dockerfile "$PROJECT_ROOT/backend/Dockerfile" +check_dockerfile "$PROJECT_ROOT/frontend/Dockerfile" +check_compose_images +check_actions +check_workflow_gates +check_downloaded_backend_artifact +check_testcontainer_images +check_optional_kubernetes_images + +if (( failures > 0 )); then + printf '[АРТЕФАКТЫ] Проверка завершена с ошибками: %d\n' "$failures" >&2 + exit 1 +fi + +printf '[АРТЕФАКТЫ] Все версии, digest, checksum, SBOM и scan gates проверены\n' diff --git a/scripts/test-artifact-pinning.sh b/scripts/test-artifact-pinning.sh new file mode 100755 index 0000000..9abd5e7 --- /dev/null +++ b/scripts/test-artifact-pinning.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +CHECK_SCRIPT="$ROOT_DIR/scripts/check-artifact-pinning.sh" +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"' EXIT + +mkdir -p "$FIXTURE_DIR/backend" "$FIXTURE_DIR/frontend" "$FIXTURE_DIR/.gitea/workflows" +cp "$ROOT_DIR/backend/Dockerfile" "$FIXTURE_DIR/backend/Dockerfile" +cp "$ROOT_DIR/frontend/Dockerfile" "$FIXTURE_DIR/frontend/Dockerfile" +cp "$ROOT_DIR/compose.yaml" "$FIXTURE_DIR/compose.yaml" +cp "$ROOT_DIR/.gitea/workflows/docker-build.yaml" \ + "$FIXTURE_DIR/.gitea/workflows/docker-build.yaml" + +PROJECT_ROOT="$FIXTURE_DIR" bash "$CHECK_SCRIPT" >/dev/null + +sed -i -E '0,/@sha256:[0-9a-f]{64}/s///' "$FIXTURE_DIR/backend/Dockerfile" +if PROJECT_ROOT="$FIXTURE_DIR" bash "$CHECK_SCRIPT" >/dev/null 2>&1; then + printf 'Ошибка теста: незакреплённый базовый образ не был отклонён\n' >&2 + exit 1 +fi + +cp "$ROOT_DIR/backend/Dockerfile" "$FIXTURE_DIR/backend/Dockerfile" +sed -i '0,/sbom: true/{/sbom: true/d;}' "$FIXTURE_DIR/.gitea/workflows/docker-build.yaml" +if PROJECT_ROOT="$FIXTURE_DIR" bash "$CHECK_SCRIPT" >/dev/null 2>&1; then + printf 'Ошибка теста: отсутствие SBOM для одного образа не было отклонено\n' >&2 + exit 1 +fi + +printf 'Проверка закрепления артефактов завершена успешно\n'