баг-фикс завершён

This commit is contained in:
Zuev
2026-07-19 20:16:12 +03:00
parent bc0e1ab1b4
commit ee876f1acd
43 changed files with 1228 additions and 258 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -21,7 +21,7 @@
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
Текущая единая baseline-миграция: `V1__init.sql`, SHA-256
`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`.
`fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`.
Упоминания V2V7 в исторических записях ниже описывают последовательность разработки до
консолидации и не означают наличие этих файлов в текущем дереве.
@@ -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-хеши V1V4 совпадают. 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 30300 секунд и 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:0003:00 и первые дни месяца. |
| 30 | не начато | Образы/скачиваемые инструменты используют mutable tags/latest без checksum/SBOM/scan. | Pin versions+digests, SHA verification, SBOM и image scan gates. | Static scan production refs и CI configuration. |
| 31 | не начато | Group create/update допускает неположительные значения и уменьшение ниже активных подгрупп. | Общий validator, locking, новые CHECK constraints. | Create/update/concurrency и migration tests. |
| 32 | не начато | `between(start,end) > 120` разрешает 121 включительную дату. | Проверять `between + 1 <= 120` в обоих сервисах. | Границы 120/121. |
| 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 | Полный прогон после № 3132: 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`, а V2V4
отсутствуют.
- 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` и `334`) имеют реализованную репозиторную часть и
воспроизводимое подтверждение. № 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`; V2V4 и другие дополнительные миграции
отсутствуют. Финальный 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. Эти действия не выполнялись
автоматически и не требуются для завершения репозиторной реализации.

View File

@@ -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:<digest> \
gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>
```
Поскольку манифесты используют `imagePullPolicy: Always` и тег `:main`, поды автоматически скачивают свежие слои собранных образов.
Скрипт принимает только полные `image@sha256:...`, ожидает готовность обоих Deployment и при
ошибке возвращает предыдущую пару digest. Поэтому содержимое релиза не зависит от повторного
разрешения mutable tag.
---

118
STARTUP_GUIDE.md Normal file
View File

@@ -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:<digest>'
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>'
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 и удаление БД я не выполняю без отдельного явного разрешения: это меняет
внешнюю систему и может прервать работу либо уничтожить данные.

View File

@@ -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

View File

@@ -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", "Неделя начала занятия должна быть положительной"),

View File

@@ -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<EducationForm> 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);

View File

@@ -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();

View File

@@ -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() {

View File

@@ -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("Учебный год не найден"));

View File

@@ -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 дней");
}
}

View File

@@ -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 дней");
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 или выход семестра за год")

View File

@@ -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<Future<WriteOutcome>> 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<WriteOutcome> 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 (

View File

@@ -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 принимает допустимые варианты и отклоняет недопустимые данные")

View File

@@ -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 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")

View File

@@ -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 запрещает совпадение названия дисциплины без учёта регистра")

View File

@@ -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 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")

View File

@@ -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 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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() {
}
}

View File

@@ -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:

View File

@@ -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`.
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.

View File

@@ -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:

View File

@@ -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` сначала строятся через генерацию

View File

@@ -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(18) | Количество курсов в сетке |
| `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 добавляет структурные инварианты:

View File

@@ -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
### Правила

View File

@@ -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-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.

View File

@@ -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.
---

View File

@@ -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`; без этого браузер покажет предупреждение.
**Учётные данные по умолчанию:**

View File

@@ -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 => {

152
scripts/check-artifact-pinning.sh Executable file
View File

@@ -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'

View File

@@ -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'