баг-фикс завершён
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# Скопируйте файл в .env и замените пустые значения локальными секретами.
|
# Скопируйте файл в .env и замените пустые значения локальными секретами.
|
||||||
POSTGRES_USER=myuser
|
POSTGRES_USER=myuser
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=supersecretpassword#<----
|
||||||
POSTGRES_DB=app_db
|
POSTGRES_DB=app_db
|
||||||
|
|
||||||
# Сгенерировать: openssl rand -base64 48
|
# Сгенерировать: openssl rand -base64 48
|
||||||
JWT_SECRET=
|
JWT_SECRET=#<----
|
||||||
JWT_ACCESS_TOKEN_TTL=15m
|
JWT_ACCESS_TOKEN_TTL=15m
|
||||||
JWT_REFRESH_TOKEN_TTL=7d
|
JWT_REFRESH_TOKEN_TTL=7d
|
||||||
JWT_REFRESH_COOKIE_NAME=magistr_refresh
|
JWT_REFRESH_COOKIE_NAME=magistr_refresh
|
||||||
@@ -35,6 +35,3 @@ TZ=Europe/Moscow
|
|||||||
|
|
||||||
# Локально Collector не входит в Compose. Укажите false при подключённом OTLP Collector.
|
# Локально Collector не входит в Compose. Укажите false при подключённом OTLP Collector.
|
||||||
OTEL_SDK_DISABLED=true
|
OTEL_SDK_DISABLED=true
|
||||||
|
|
||||||
# Измените, если порт 80 занят.
|
|
||||||
HTTP_PORT=80
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ env:
|
|||||||
REGISTRY: gitea.zuev.company
|
REGISTRY: gitea.zuev.company
|
||||||
BACKEND_IMAGE: zuev/magistr-backend
|
BACKEND_IMAGE: zuev/magistr-backend
|
||||||
FRONTEND_IMAGE: zuev/magistr-frontend
|
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:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
@@ -23,14 +26,14 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Получить исходный код
|
- name: Получить исходный код
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
|
||||||
- name: Backend unit и component tests
|
- name: Backend unit и component tests
|
||||||
run: >-
|
run: >-
|
||||||
docker run --rm
|
docker run --rm
|
||||||
-v "${{ github.workspace }}/backend:/workspace"
|
-v "${{ github.workspace }}/backend:/workspace"
|
||||||
-w /workspace
|
-w /workspace
|
||||||
maven:3.9.9-eclipse-temurin-17
|
"${{ env.MAVEN_IMAGE }}"
|
||||||
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
|
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
|
||||||
|
|
||||||
- name: Frontend static и unit tests
|
- name: Frontend static и unit tests
|
||||||
@@ -38,7 +41,7 @@ jobs:
|
|||||||
docker run --rm
|
docker run --rm
|
||||||
-v "${{ github.workspace }}/frontend:/workspace"
|
-v "${{ github.workspace }}/frontend:/workspace"
|
||||||
-w /workspace
|
-w /workspace
|
||||||
node:22-alpine3.23
|
"${{ env.NODE_IMAGE }}"
|
||||||
sh -c "npm ci && npm run check"
|
sh -c "npm ci && npm run check"
|
||||||
|
|
||||||
- name: Проверить Docker Compose
|
- name: Проверить Docker Compose
|
||||||
@@ -50,6 +53,9 @@ jobs:
|
|||||||
- name: Проверить сценарий immutable rollout и rollback
|
- name: Проверить сценарий immutable rollout и rollback
|
||||||
run: bash scripts/test-deploy-images.sh
|
run: bash scripts/test-deploy-images.sh
|
||||||
|
|
||||||
|
- name: Проверить закрепление артефактов и supply-chain gates
|
||||||
|
run: bash scripts/test-artifact-pinning.sh
|
||||||
|
|
||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
name: Собрать backend image
|
name: Собрать backend image
|
||||||
needs: checks
|
needs: checks
|
||||||
@@ -59,10 +65,10 @@ jobs:
|
|||||||
digest: ${{ steps.build.outputs.digest }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- name: Получить исходный код
|
- name: Получить исходный код
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
|
||||||
- name: Войти в Container Registry
|
- name: Войти в Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -70,7 +76,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Сформировать immutable tags и labels
|
- name: Сформировать immutable tags и labels
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
|
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
|
||||||
flavor: latest=false
|
flavor: latest=false
|
||||||
@@ -80,10 +86,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Собрать и опубликовать backend image
|
- name: Собрать и опубликовать backend image
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: |
|
labels: |
|
||||||
${{ steps.meta.outputs.labels }}
|
${{ steps.meta.outputs.labels }}
|
||||||
@@ -99,10 +107,10 @@ jobs:
|
|||||||
digest: ${{ steps.build.outputs.digest }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- name: Получить исходный код
|
- name: Получить исходный код
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
|
||||||
- name: Войти в Container Registry
|
- name: Войти в Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -110,7 +118,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Сформировать immutable tags и labels
|
- name: Сформировать immutable tags и labels
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
|
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
|
||||||
flavor: latest=false
|
flavor: latest=false
|
||||||
@@ -120,19 +128,53 @@ jobs:
|
|||||||
|
|
||||||
- name: Собрать и опубликовать frontend image
|
- name: Собрать и опубликовать frontend image
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: |
|
labels: |
|
||||||
${{ steps.meta.outputs.labels }}
|
${{ steps.meta.outputs.labels }}
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
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:
|
deploy-to-k8s:
|
||||||
name: Развернуть проверенные digests
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
environment: production
|
environment: production
|
||||||
@@ -143,7 +185,7 @@ jobs:
|
|||||||
KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e
|
KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e
|
||||||
steps:
|
steps:
|
||||||
- name: Получить deploy-скрипт
|
- name: Получить deploy-скрипт
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
|
||||||
- name: Создать kubeconfig
|
- name: Создать kubeconfig
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
|
| `V2__subgroups_active_unique_name.sql` | `68f5525a50ddba4f8800a63cab3cd0f84779e301c4a1f29a6e0399f97fbdb30b` |
|
||||||
|
|
||||||
Текущая единая baseline-миграция: `V1__init.sql`, SHA-256
|
Текущая единая baseline-миграция: `V1__init.sql`, SHA-256
|
||||||
`250cab68c9ab6d8401619447585b47d6122458d1cb00a6e76af308414f89958d`.
|
`fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`.
|
||||||
Упоминания V2–V7 в исторических записях ниже описывают последовательность разработки до
|
Упоминания V2–V7 в исторических записях ниже описывают последовательность разработки до
|
||||||
консолидации и не означают наличие этих файлов в текущем дереве.
|
консолидации и не означают наличие этих файлов в текущем дереве.
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. Чётность и точный дубль закреплены в V1. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. |
|
| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. Чётность и точный дубль закреплены в V1. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. |
|
||||||
| 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. |
|
| 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. |
|
| 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ persistence мог сочетаться с изменённым локальным состоянием. | `TenantLifecycleService` выполняет prepare → connection validation → Flyway → Secret persistence → atomic snapshot swap; candidate закрывается при отказе, прежний route сохраняется, Secret компенсируется, старый pool закрывается после drain. | 18 целевых unit/MockMvc/PostgreSQL tests; create/update, credentials, invalid connection, Flyway, persistence, swap, HTTP `503` и in-flight connection. Полный backend: 132/0/0/0. |
|
||||||
| 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. |
|
| 10 | исправлено и проверено | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Kubernetes содержит RBAC `get/update`, directory mount без `subPath`, required tenant-config и HTTP probes. | 64 целевых теста; полный backend 277/0/0/0; Kustomize, secret-scan и синтаксис deploy-скрипта прошли. |
|
||||||
| 11 | исправлено и проверено | Воскресенье вычислялось повторным мутированием одной даты, date-only строился через UTC, а ошибки кафедральных запросов превращались в пустой успешный результат. | `dashboard-conflicts.js` формирует локальный диапазон на отдельных объектах, загружает кафедры через `Promise.allSettled` и fail-closed различает `COMPLETE`/`PARTIAL`/`NOT_RUN`; зелёная карточка разрешена только для полного результата без конфликтов. | 12 `node:test`: 02.07.2026 00:15 Europe/Moscow, переход года, полный/частичный/нулевой успех и строгий UI-контракт; `npm run check` и синтаксис 25 JS/MJS-файлов — успешно. |
|
| 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 — успешно. |
|
| 12 | исправлено и проверено | Четыре frontend-клиента хранили access JWT и профиль в Web Storage; login/admin исполняли OTel с `esm.sh`, а inline JS/styles не позволяли включить строгий CSP. | Общий `auth-session.js` держит access JWT только в памяти и восстанавливает его через HttpOnly refresh-cookie; OTel собирается из exact npm-зависимостей в same-origin bundle; Apache выдаёт CSP без `unsafe-inline`/`unsafe-eval`. | 21 frontend test, `npm audit` 0 vulnerabilities, Docker build и live HTTP/CSP/bundle check — успешно. |
|
||||||
| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30–300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. |
|
| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30–300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. |
|
||||||
@@ -81,10 +81,10 @@
|
|||||||
| 26 | исправлено и проверено | Compose требовал внешнюю сеть, не публиковал HTTP, рассогласовывал DB/JWT и использовал anonymous volume. Live startup также выявил два неоднозначных Spring-конструктора. | Внутренняя bridge-сеть, Apache reverse proxy и `HTTP_PORT`; согласованные datasource/JWT env, healthchecks, named volume; production-конструкторы явно помечены для DI. | `up --build --wait`; PostgreSQL/backend/frontend healthy, `/` и liveness `200`, API `401`, named volume; полный backend 235/0/0/0. |
|
| 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 — успешно. |
|
| 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 проверены. |
|
| 28 | исправлено и проверено | Login не имел shared rate limit, различал архивного пользователя и безусловно доверял forwarded header. | Транзакционный PostgreSQL limiter по tenant + NFKC username + проверенному IP; прогрессивная блокировка, audit/cleanup, единый `401`, `429`/`Retry-After` и trusted proxy chain. | 12 целевых unit/PostgreSQL tests; два service instance дали ровно один `401` и один `429`, общий failure count 2; V1 и cleanup проверены. |
|
||||||
| 29 | не начато | Business dates зависят от timezone процесса; frontend date-only строится через UTC. | Инъекция `Clock`/`Europe/Moscow`, UTC timestamps, локальное форматирование date-only, TZ контейнеров. | Boundary tests 00:00–03:00 и первые дни месяца. |
|
| 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 tags/latest без checksum/SBOM/scan. | Pin versions+digests, SHA verification, SBOM и image scan gates. | Static scan production refs и CI configuration. |
|
| 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, locking, новые CHECK constraints. | Create/update/concurrency и migration tests. |
|
| 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. |
|
| 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-хэши синхронизированы. |
|
| 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 — успешно. |
|
| 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` и
|
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
|
||||||
`docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials
|
`docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials
|
||||||
существующего домена было оставлено проблеме № 13 и устранено следующим этапом.
|
существующего домена было оставлено проблеме № 13 и устранено следующим этапом.
|
||||||
- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
|
- В текущем внешнем `../k8s` подтверждены directory mount без `subPath`,
|
||||||
и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
|
|
||||||
`TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
|
`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. |
|
| Контейнерный полный `mvn test` | 169 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 40 suites. |
|
||||||
| `git diff --check` | Успешно. |
|
| `git diff --check` | Успешно. |
|
||||||
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
|
| Повторные 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-конфигурации
|
### № 13 — полная и повторяемая синхронизация tenant-конфигурации
|
||||||
|
|
||||||
@@ -926,14 +926,142 @@
|
|||||||
| Compose, Kustomize, secret-scan, `git diff --check` | Успешно. |
|
| Compose, Kustomize, secret-scan, `git diff --check` | Успешно. |
|
||||||
| Изменённые внешние файлы | `../k8s/config.yaml`, `../k8s/README.md`; каталог вне Git-корня и проверен отдельно. |
|
| Изменённые внешние файлы | `../k8s/config.yaml`, `../k8s/README.md`; каталог вне Git-корня и проверен отдельно. |
|
||||||
|
|
||||||
|
### № 29 — единая временная зона бизнес-дат
|
||||||
|
|
||||||
|
- Добавлен `BusinessTimeService`: production использует UTC `Clock`, а календарную дату
|
||||||
|
вычисляет в настраиваемой зоне `BUSINESS_TIME_ZONE` с безопасным default
|
||||||
|
`Europe/Moscow`. Контроллеры пользователей, групп, кабинета кафедры, заявок и дисциплин,
|
||||||
|
генератор расписания и история кафедр получают текущую бизнес-дату через этот сервис.
|
||||||
|
- Абсолютные моменты refresh/login-аудита, lifecycle, overrides, заявок, комментариев и
|
||||||
|
назначений переведены с `LocalDateTime` на `Instant`. Hibernate и Jackson настроены на
|
||||||
|
UTC; все соответствующие колонки единственной `V1__init.sql` используют `TIMESTAMPTZ`.
|
||||||
|
- `formatLocalDate()` и календарная арифметика frontend используют локальные компоненты
|
||||||
|
`Date`; `toISOString().slice(0, 10)` для date-only в frontend больше не используется.
|
||||||
|
- Compose передаёт `BUSINESS_TIME_ZONE`, `TZ` и
|
||||||
|
`JAVA_TOOL_OPTIONS=-Duser.timezone=...`; `.env.example` содержит явные значения.
|
||||||
|
Kubernetes `app-config` содержит те же production-поля, поэтому backend pod не зависит от
|
||||||
|
timezone node или базового образа.
|
||||||
|
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md`,
|
||||||
|
`docs/DATABASE.md`, `docs/DEVELOPMENT.md`, `docs/FRONTEND.md` и
|
||||||
|
`docs/INFRASTRUCTURE.md`.
|
||||||
|
- Новых миграционных файлов не создано; каталог Flyway содержит только `V1__init.sql`,
|
||||||
|
текущий SHA-256 после завершения № 31:
|
||||||
|
`fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`.
|
||||||
|
|
||||||
|
Фактические проверки этапа:
|
||||||
|
|
||||||
|
| Команда | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `BusinessTimeServiceTest` | В первом полном прогоне обе границы московской полуночи прошли. |
|
||||||
|
| `npm test`, `node --check` | Все 4 frontend test-suite и изменённые JS-файлы прошли. |
|
||||||
|
| Static time scan | В production Java нет `LocalDate.now()`/`LocalDateTime.now()` без явной зоны; во frontend нет date-only через `toISOString`; в V1 и документации нет `TIMESTAMP` без timezone. |
|
||||||
|
| Compose и diff | `docker compose config --quiet` с обязательными секретами и `git diff --check` успешны. |
|
||||||
|
| Kubernetes timezone | Kustomize-рендер содержит `BUSINESS_TIME_ZONE=Europe/Moscow`, `TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow`. |
|
||||||
|
| Первый полный `mvn test` | 269 тестов: 263 успешны. После прогона исправлены новое lifecycle-ожидание, JDBC binding `Instant` и три ранее нестабильных ожидания PostgreSQL `40P01`; также усилена блокировка календарного assignment. |
|
||||||
|
| Повторный Maven/Testcontainers | Полный прогон после № 31–32: 277 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||||
|
|
||||||
|
### № 30 — воспроизводимые и проверяемые артефакты
|
||||||
|
|
||||||
|
- Backend build/runtime, PostgreSQL Compose и все PostgreSQL Testcontainers закреплены
|
||||||
|
одновременно точной версией и manifest digest. Ранее закреплённые frontend base images
|
||||||
|
сохранены.
|
||||||
|
- Все сторонние Actions в Gitea workflow закреплены полными 40-символьными commit SHA;
|
||||||
|
Maven, Node.js и Trivy запускаются только по `tag@sha256:...`.
|
||||||
|
- OpenTelemetry Java Agent и `kubectl` имеют точные версии и обязательную SHA-256 проверку до
|
||||||
|
использования.
|
||||||
|
- Оба build job публикуют BuildKit provenance `mode=max` и SBOM-attestation. Отдельный
|
||||||
|
`security-scan` проверяет backend/frontend digests закреплённым Trivy и блокирует deploy при
|
||||||
|
исправимых `HIGH`/`CRITICAL` уязвимостях.
|
||||||
|
- Добавлены `scripts/check-artifact-pinning.sh` и его регрессионный shell-тест. Проверка
|
||||||
|
контролирует Dockerfile, Compose, Testcontainers, Actions, checksum, SBOM/provenance, scan
|
||||||
|
gate и зависимость deploy от scan. Опциональный `K8S_DIR` расширяет проверку на внешние
|
||||||
|
production-манифесты.
|
||||||
|
- AutoUpdateDocs синхронизировал `docs/DATABASE.md`, `docs/DEVELOPMENT.md`,
|
||||||
|
`docs/INFRASTRUCTURE.md` и `DEVOPS.md`.
|
||||||
|
- Внешний `../k8s/otel-collector.yaml` использует официальный Collector Contrib
|
||||||
|
`0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`.
|
||||||
|
Версия подтверждена официальным релизом OpenTelemetry, manifest digest получен напрямую
|
||||||
|
из Docker Hub Registry API.
|
||||||
|
- Новых Flyway-файлов не создано; в каталоге миграций остаётся только `V1__init.sql` с
|
||||||
|
SHA-256 `fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`.
|
||||||
|
|
||||||
|
Фактические проверки этапа:
|
||||||
|
|
||||||
|
| Команда | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `bash scripts/check-artifact-pinning.sh` | Успешно: repo image refs, Actions, checksum, SBOM и scan gates закреплены. |
|
||||||
|
| `bash scripts/test-artifact-pinning.sh` | Успешно: незакреплённый base image и удалённая SBOM гарантированно отклоняются. |
|
||||||
|
| `bash scripts/test-deploy-images.sh` | Успешно: immutable refs, rollout и парный rollback. |
|
||||||
|
| Workflow YAML, Compose config, `git diff --check` | Успешно. |
|
||||||
|
| `K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh` | Успешно: все production-образы имеют полный digest. |
|
||||||
|
| `kubectl kustomize ../k8s` | Успешно; рендер содержит закреплённый OTel Collector. |
|
||||||
|
|
||||||
|
### № 31 — инварианты численности групп и подгрупп
|
||||||
|
|
||||||
|
- Создание и изменение групп теперь проходят через общий validator
|
||||||
|
`AcademicStructureService`: численность, год начала обучения и все идентификаторы должны
|
||||||
|
быть положительными, обязательные поля не могут отсутствовать.
|
||||||
|
- При обновлении сервис блокирует группу и запрещает уменьшать `groupSize` ниже суммы
|
||||||
|
численностей активных подгрупп. Создание, обновление и архивация подгрупп также блокируют
|
||||||
|
строку родительской группы до завершения транзакции.
|
||||||
|
- `subgroups.student_capacity` стал обязательным. В V1 добавлены CHECK-ограничения
|
||||||
|
положительной численности группы, года начала обучения и численности подгруппы.
|
||||||
|
- Два триггера V1 защищают перекрёстный инвариант при прямых и конкурентных записях:
|
||||||
|
сумма активных подгрупп не может превышать размер группы. Ошибка сопоставляется с
|
||||||
|
безопасным русским ответом через `DatabaseConstraintViolationMapper`.
|
||||||
|
- Новых миграций не создано: вся итоговая схема находится в `V1__init.sql`, а V2–V4
|
||||||
|
отсутствуют.
|
||||||
|
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md`,
|
||||||
|
`docs/BUSINESS_LOGIC.md` и `docs/DATABASE.md`.
|
||||||
|
|
||||||
|
Фактические проверки этапа:
|
||||||
|
|
||||||
|
| Команда | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `AcademicStructureServiceTest` | 11 тестов: общий create/update validator, положительные значения и запрет уменьшения ниже подгрупп прошли. |
|
||||||
|
| `AcademicStructureInvariantIntegrationTest` | 4 PostgreSQL-теста: CHECK, уменьшение группы и гонка уменьшения с добавлением подгруппы прошли; инвариант сохранён. |
|
||||||
|
| Полный контейнерный `mvn test` | 277 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||||
|
| Каталог Flyway | Ровно один `V1__init.sql`, SHA-256 `fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`. |
|
||||||
|
|
||||||
|
### № 32 — точный включительный лимит диапазона расписания
|
||||||
|
|
||||||
|
- `ScheduleQueryService` и `ScheduleGeneratorService` одинаково вычисляют количество
|
||||||
|
календарных дат как `ChronoUnit.DAYS.between(startDate, endDate) + 1`.
|
||||||
|
- Диапазон из 120 дат разрешён, а из 121 даты возвращает русскую ошибку валидации.
|
||||||
|
- AutoUpdateDocs уточнил включительную семантику лимита в `docs/API.md`,
|
||||||
|
`docs/ARCHITECTURE.md` и `docs/BUSINESS_LOGIC.md`.
|
||||||
|
|
||||||
|
Фактические проверки этапа:
|
||||||
|
|
||||||
|
| Команда | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `ScheduleQueryServiceTest.rangeLimitCountsBothBoundaryDates` | Граница 120 разрешена, 121 отклонена. |
|
||||||
|
| `ScheduleGenerationQueryCountTest` | Существующая генерация 120 дат сохранена, запрос 121 даты отклонён до генерации. |
|
||||||
|
| Полный контейнерный `mvn test` | 277 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||||
|
|
||||||
|
## Финальный аудит
|
||||||
|
|
||||||
|
- Все 33 проблемы из области (`1` и `3–34`) имеют реализованную репозиторную часть и
|
||||||
|
воспроизводимое подтверждение. № 2 не изменялась, `BUG_REPORT.md` сохранён как исходный
|
||||||
|
реестр.
|
||||||
|
- Полностью закрыты и проверены 32 пункта. Для № 1 остаются только операторские ротация,
|
||||||
|
provisioning, rollout и очистка истории, которые исходный prompt прямо запрещает
|
||||||
|
выполнять без отдельных production-полномочий.
|
||||||
|
- Полный backend PostgreSQL/Testcontainers-прогон: 277 тестов, 0 failures, 0 errors,
|
||||||
|
0 skipped. Четыре frontend test-suite, `npm run check`, Compose validation,
|
||||||
|
Workflow YAML, artifact/deploy shell-тесты и `git diff --check` прошли.
|
||||||
|
- `kubectl kustomize ../k8s`, production secret-scan, синтаксис deploy-скрипта и расширенный
|
||||||
|
artifact-scan прошли. В production-манифестах отсутствуют mutable `latest` и `:main`.
|
||||||
|
- Каталог Flyway содержит ровно `V1__init.sql`; V2–V4 и другие дополнительные миграции
|
||||||
|
отсутствуют. Финальный SHA-256 V1:
|
||||||
|
`fffedf66e8bf7d25d2d8c1b1a6146b45dd5b7e52233d05a68de8bf62c817f7a7`.
|
||||||
|
|
||||||
## Точка продолжения
|
## Точка продолжения
|
||||||
|
|
||||||
Текущий этап: **№ 29 — единая временная зона бизнес-дат**.
|
Текущий этап: **реализация и проверка всех разрешённых изменений завершены**.
|
||||||
|
|
||||||
Следующая операция:
|
Оставшееся операторское действие владельца:
|
||||||
|
|
||||||
1. построить graphify-контекст всех `LocalDate.now()`/`LocalDateTime.now()` и frontend
|
1. при готовности production-полномочий выполнить `docs/SECURITY_RUNBOOK.md`: ротацию,
|
||||||
`toISOString()` в бизнес-операциях;
|
provisioning, rollout и очистку истории для № 1. Эти действия не выполнялись
|
||||||
2. внедрить единый `Clock`/`ZoneId` Europe/Moscow без изменения абсолютных timestamp;
|
автоматически и не требуются для завершения репозиторной реализации.
|
||||||
3. зафиксировать `TZ`/`user.timezone` в Compose и Kubernetes;
|
|
||||||
4. проверить границы московской полуночи и date-only форматирование без UTC-сдвига.
|
|
||||||
|
|||||||
26
DEVOPS.md
26
DEVOPS.md
@@ -88,12 +88,12 @@ graph TD
|
|||||||
|
|
||||||
* **Frontend (`frontend.yaml`)**:
|
* **Frontend (`frontend.yaml`)**:
|
||||||
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
|
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
|
||||||
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd (`httpd:alpine`).
|
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd; версия и manifest digest закреплены в Dockerfile.
|
||||||
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
|
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
|
||||||
|
|
||||||
* **Backend (`backend.yaml`)**:
|
* **Backend (`backend.yaml`)**:
|
||||||
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
|
* `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 для автоматического инструментирования трассировки и логов.
|
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
|
||||||
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
|
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
|
||||||
|
|
||||||
@@ -194,22 +194,26 @@ graph TD
|
|||||||
|
|
||||||
### 5.1 Автоматизация сборки (CI)
|
### 5.1 Автоматизация сборки (CI)
|
||||||
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
|
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
|
||||||
1. **Checkout**: Загрузка актуального исходного кода проекта на ранер.
|
1. **Checks**: Backend/frontend-тесты, Compose validation, тест immutable rollback и статическая проверка закрепления артефактов.
|
||||||
2. **Setup Buildx**: Инициализация Docker Buildx для оптимизации кэширования слоев.
|
2. **Build & Push**: Параллельная сборка и публикация backend/frontend с SHA-tag или release tag; mutable `latest` не создаётся.
|
||||||
3. **Login to Registry**: Аутентификация во встроенном реестре контейнеров Gitea Container Registry (`git.zuev.company`) с использованием сервисного токена `ZUEV_TOKEN` (права `write:package`).
|
3. **Attestations**: BuildKit добавляет к обоим образам SBOM и максимальную provenance-attestation.
|
||||||
4. **Build & Push**: Параллельная сборка Docker-образов для бэкенда и фронтенда с тегом `latest` и отправка их в приватный реестр.
|
4. **Security scan**: Закреплённый digest Trivy блокирует доставку при исправимых уязвимостях `HIGH`/`CRITICAL`.
|
||||||
|
5. **Deploy**: Только прошедшие gates registry digests передаются в production rollout. Все сторонние Actions закреплены полными commit SHA.
|
||||||
|
|
||||||
### 5.2 Доставка в кластер (CD)
|
### 5.2 Доставка в кластер (CD)
|
||||||
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет `gitea-registry` типа `docker-registry` в пространстве имен `magistr`. Этот секрет ассоциирован со спецификациями деплоев через директиву `imagePullSecrets`.
|
Для авторизации нод 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
|
```bash
|
||||||
kubectl rollout restart deployment backend frontend -n magistr
|
bash scripts/deploy-images.sh \
|
||||||
kubectl rollout status deployment/frontend -n magistr --timeout=120s
|
gitea.zuev.company/zuev/magistr-backend@sha256:<digest> \
|
||||||
kubectl rollout status deployment/backend -n magistr --timeout=300s
|
gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>
|
||||||
```
|
```
|
||||||
Поскольку манифесты используют `imagePullPolicy: Always` и тег `:main`, поды автоматически скачивают свежие слои собранных образов.
|
|
||||||
|
Скрипт принимает только полные `image@sha256:...`, ожидает готовность обоих Deployment и при
|
||||||
|
ошибке возвращает предыдущую пару digest. Поэтому содержимое релиза не зависит от повторного
|
||||||
|
разрешения mutable tag.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
118
STARTUP_GUIDE.md
Normal file
118
STARTUP_GUIDE.md
Normal 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 и удаление БД я не выполняю без отдельного явного разрешения: это меняет
|
||||||
|
внешнюю систему и может прервать работу либо уничтожить данные.
|
||||||
@@ -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
|
WORKDIR /app
|
||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
RUN mvn dependency:go-offline -B
|
RUN mvn dependency:go-offline -B
|
||||||
@@ -14,7 +14,7 @@ RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \
|
|||||||
-B \
|
-B \
|
||||||
&& echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c -
|
&& 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
|
# Best practice: run as a non-root user
|
||||||
RUN addgroup -S spring && adduser -S spring -G spring
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ final class DatabaseConstraintViolationMapper {
|
|||||||
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
|
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
|
||||||
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
|
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
|
||||||
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
|
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_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
|
||||||
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
|
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
|
||||||
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
|
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.magistr.app.dto.CreateGroupRequest;
|
|||||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||||
import com.magistr.app.dto.GroupResponse;
|
import com.magistr.app.dto.GroupResponse;
|
||||||
import com.magistr.app.model.AcademicCalendarSubject;
|
import com.magistr.app.model.AcademicCalendarSubject;
|
||||||
import com.magistr.app.model.EducationForm;
|
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Speciality;
|
import com.magistr.app.model.Speciality;
|
||||||
import com.magistr.app.model.SpecialtyProfile;
|
import com.magistr.app.model.SpecialtyProfile;
|
||||||
@@ -31,7 +30,6 @@ import java.time.LocalDate;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -43,9 +41,6 @@ public class GroupController {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||||
|
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final EducationFormRepository educationFormRepository;
|
|
||||||
private final SpecialtiesRepository specialtiesRepository;
|
|
||||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
|
||||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
@@ -54,25 +49,18 @@ public class GroupController {
|
|||||||
private final BusinessTimeService businessTime;
|
private final BusinessTimeService businessTime;
|
||||||
|
|
||||||
public GroupController(GroupRepository groupRepository,
|
public GroupController(GroupRepository groupRepository,
|
||||||
EducationFormRepository educationFormRepository,
|
|
||||||
SpecialtiesRepository specialtiesRepository,
|
|
||||||
SpecialtyProfileRepository specialtyProfileRepository,
|
|
||||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService,
|
ScheduleGeneratorService scheduleGeneratorService,
|
||||||
StudentGroupLifecycleService groupLifecycleService,
|
StudentGroupLifecycleService groupLifecycleService,
|
||||||
AcademicStructureService academicStructureService) {
|
AcademicStructureService academicStructureService) {
|
||||||
this(groupRepository, educationFormRepository, specialtiesRepository,
|
this(groupRepository, calendarSubjectRepository, assignmentRepository,
|
||||||
specialtyProfileRepository, calendarSubjectRepository, assignmentRepository,
|
|
||||||
scheduleGeneratorService, groupLifecycleService, academicStructureService,
|
scheduleGeneratorService, groupLifecycleService, academicStructureService,
|
||||||
BusinessTimeService.systemDefault());
|
BusinessTimeService.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public GroupController(GroupRepository groupRepository,
|
public GroupController(GroupRepository groupRepository,
|
||||||
EducationFormRepository educationFormRepository,
|
|
||||||
SpecialtiesRepository specialtiesRepository,
|
|
||||||
SpecialtyProfileRepository specialtyProfileRepository,
|
|
||||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService,
|
ScheduleGeneratorService scheduleGeneratorService,
|
||||||
@@ -80,9 +68,6 @@ public class GroupController {
|
|||||||
AcademicStructureService academicStructureService,
|
AcademicStructureService academicStructureService,
|
||||||
BusinessTimeService businessTime) {
|
BusinessTimeService businessTime) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.educationFormRepository = educationFormRepository;
|
|
||||||
this.specialtiesRepository = specialtiesRepository;
|
|
||||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
|
||||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||||
this.assignmentRepository = assignmentRepository;
|
this.assignmentRepository = assignmentRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
@@ -144,46 +129,10 @@ public class GroupController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequireRoles({Role.ADMIN})
|
@RequireRoles({Role.ADMIN})
|
||||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||||
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
|
logger.info("Получен запрос на создание новой группы");
|
||||||
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
|
StudentGroup group = academicStructureService.createGroup(request);
|
||||||
try {
|
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
return ResponseEntity.ok(mapToResponse(group));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@@ -222,36 +171,6 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(mapToResponse(group));
|
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) {
|
private GroupResponse mapToResponse(StudentGroup group) {
|
||||||
LocalDate today = businessTime.today();
|
LocalDate today = businessTime.today();
|
||||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
|
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
|||||||
import com.magistr.app.repository.SubgroupRepository;
|
import com.magistr.app.repository.SubgroupRepository;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -53,8 +54,9 @@ public class SubgroupController {
|
|||||||
|
|
||||||
@PostMapping("/api/groups/{groupId}/subgroups")
|
@PostMapping("/api/groups/{groupId}/subgroups")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
|
@Transactional
|
||||||
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
|
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) {
|
if (group == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -81,9 +83,14 @@ public class SubgroupController {
|
|||||||
|
|
||||||
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
|
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
|
@Transactional
|
||||||
public ResponseEntity<?> update(@PathVariable Long groupId,
|
public ResponseEntity<?> update(@PathVariable Long groupId,
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestBody SubgroupDto request) {
|
@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);
|
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||||
if (subgroup == null) {
|
if (subgroup == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -98,7 +105,7 @@ public class SubgroupController {
|
|||||||
if (duplicateName) {
|
if (duplicateName) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
|
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) {
|
if (capacityError != null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
|
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
|
||||||
}
|
}
|
||||||
@@ -112,7 +119,11 @@ public class SubgroupController {
|
|||||||
|
|
||||||
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
|
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||||
|
@Transactional
|
||||||
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
|
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);
|
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||||
if (subgroup == null) {
|
if (subgroup == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class Subgroup extends LifecycleEntity {
|
|||||||
@Column(nullable = false, length = 100)
|
@Column(nullable = false, length = 100)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "student_capacity")
|
@Column(name = "student_capacity", nullable = false)
|
||||||
private Integer studentCapacity;
|
private Integer studentCapacity;
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.magistr.app.repository.GroupRepository;
|
|||||||
import com.magistr.app.repository.SpecialtiesRepository;
|
import com.magistr.app.repository.SpecialtiesRepository;
|
||||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||||
|
import com.magistr.app.repository.SubgroupRepository;
|
||||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -41,6 +42,7 @@ public class AcademicStructureService {
|
|||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
|
private final SubgroupRepository subgroupRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
|
||||||
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
||||||
@@ -52,6 +54,7 @@ public class AcademicStructureService {
|
|||||||
EducationFormRepository educationFormRepository,
|
EducationFormRepository educationFormRepository,
|
||||||
GroupRepository groupRepository,
|
GroupRepository groupRepository,
|
||||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
|
SubgroupRepository subgroupRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService) {
|
ScheduleGeneratorService scheduleGeneratorService) {
|
||||||
this.calendarRepository = calendarRepository;
|
this.calendarRepository = calendarRepository;
|
||||||
this.calendarDayRepository = calendarDayRepository;
|
this.calendarDayRepository = calendarDayRepository;
|
||||||
@@ -62,9 +65,22 @@ public class AcademicStructureService {
|
|||||||
this.educationFormRepository = educationFormRepository;
|
this.educationFormRepository = educationFormRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.assignmentRepository = assignmentRepository;
|
this.assignmentRepository = assignmentRepository;
|
||||||
|
this.subgroupRepository = subgroupRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
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
|
@Transactional
|
||||||
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
|
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
|
||||||
validateCalendarRequest(request);
|
validateCalendarRequest(request);
|
||||||
@@ -122,6 +138,13 @@ public class AcademicStructureService {
|
|||||||
validateGroupRequest(request);
|
validateGroupRequest(request);
|
||||||
StudentGroup group = groupRepository.findByIdForUpdate(id)
|
StudentGroup group = groupRepository.findByIdForUpdate(id)
|
||||||
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
|
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
|
||||||
|
long activeSubgroupCapacity = subgroupRepository.sumActiveStudentCapacityByGroupId(id);
|
||||||
|
if (request.getGroupSize() < activeSubgroupCapacity) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Численность группы не может быть меньше суммы численностей активных подгрупп ("
|
||||||
|
+ activeSubgroupCapacity + " чел.)"
|
||||||
|
);
|
||||||
|
}
|
||||||
GroupDimensions candidate = resolveGroupDimensions(request);
|
GroupDimensions candidate = resolveGroupDimensions(request);
|
||||||
|
|
||||||
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
|
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
|
||||||
@@ -139,13 +162,7 @@ public class AcademicStructureService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
group.setName(request.getName().trim());
|
applyGroupDimensions(group, request, candidate);
|
||||||
group.setGroupSize(request.getGroupSize());
|
|
||||||
group.setEducationForm(candidate.studyForm());
|
|
||||||
group.setDepartmentId(request.getDepartmentId());
|
|
||||||
group.setYearStartStudy(request.getYearStartStudy());
|
|
||||||
group.setSpeciality(candidate.speciality());
|
|
||||||
group.setSpecialtyProfile(candidate.profile());
|
|
||||||
StudentGroup saved = saveGroup(group);
|
StudentGroup saved = saveGroup(group);
|
||||||
clearScheduleCacheAfterCommit();
|
clearScheduleCacheAfterCommit();
|
||||||
return saved;
|
return saved;
|
||||||
@@ -217,26 +234,38 @@ public class AcademicStructureService {
|
|||||||
if (request.getName() == null || request.getName().isBlank()) {
|
if (request.getName() == null || request.getName().isBlank()) {
|
||||||
throw new IllegalArgumentException("Название группы обязательно");
|
throw new IllegalArgumentException("Название группы обязательно");
|
||||||
}
|
}
|
||||||
if (request.getGroupSize() == null) {
|
if (request.getGroupSize() == null || request.getGroupSize() <= 0) {
|
||||||
throw new IllegalArgumentException("Численность группы обязательна");
|
throw new IllegalArgumentException("Численность группы должна быть больше нуля");
|
||||||
}
|
}
|
||||||
if (request.getEducationFormId() == null) {
|
if (request.getEducationFormId() == null || request.getEducationFormId() <= 0) {
|
||||||
throw new IllegalArgumentException("Форма обучения обязательна");
|
throw new IllegalArgumentException("Форма обучения обязательна");
|
||||||
}
|
}
|
||||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
if (request.getDepartmentId() == null || request.getDepartmentId() <= 0) {
|
||||||
throw new IllegalArgumentException("ID кафедры обязателен");
|
throw new IllegalArgumentException("ID кафедры обязателен");
|
||||||
}
|
}
|
||||||
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
|
if (request.getYearStartStudy() == null || request.getYearStartStudy() <= 0) {
|
||||||
throw new IllegalArgumentException("Год начала обучения обязателен");
|
throw new IllegalArgumentException("Год начала обучения должен быть больше нуля");
|
||||||
}
|
}
|
||||||
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
|
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() <= 0) {
|
||||||
throw new IllegalArgumentException("Код специальности обязателен");
|
throw new IllegalArgumentException("Код специальности обязателен");
|
||||||
}
|
}
|
||||||
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
|
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() <= 0) {
|
||||||
throw new IllegalArgumentException("Профиль обучения обязателен");
|
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) {
|
private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) {
|
||||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
|
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ public class ScheduleGeneratorService {
|
|||||||
if (endDate.isBefore(startDate)) {
|
if (endDate.isBefore(startDate)) {
|
||||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
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 дней");
|
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,8 @@ public class ScheduleQueryService {
|
|||||||
if (endDate.isBefore(startDate)) {
|
if (endDate.isBefore(startDate)) {
|
||||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
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 дней");
|
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,12 +254,14 @@ ON CONFLICT (name) DO NOTHING;
|
|||||||
CREATE TABLE IF NOT EXISTS student_groups (
|
CREATE TABLE IF NOT EXISTS student_groups (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL,
|
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),
|
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
|
||||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
|
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
|
||||||
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(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,
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
active_from DATE,
|
active_from DATE,
|
||||||
@@ -289,7 +291,8 @@ CREATE TABLE IF NOT EXISTS subgroups (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(100) NOT NULL,
|
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',
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
active_from DATE,
|
active_from DATE,
|
||||||
active_to DATE,
|
active_to DATE,
|
||||||
@@ -299,6 +302,76 @@ CREATE TABLE IF NOT EXISTS subgroups (
|
|||||||
UNIQUE(group_id, name)
|
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)
|
INSERT INTO subgroups (group_id, name, student_capacity)
|
||||||
SELECT
|
SELECT
|
||||||
sg.id,
|
sg.id,
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ class LoginRateLimitConcurrencyIntegrationTest {
|
|||||||
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
|
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@DynamicPropertySource
|
@DynamicPropertySource
|
||||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ class RefreshTokenServiceConcurrencyIntegrationTest {
|
|||||||
private static final String TENANT = "test-tenant";
|
private static final String TENANT = "test-tenant";
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@DynamicPropertySource
|
@DynamicPropertySource
|
||||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class AcademicPeriodInvariantMigrationIntegrationTest {
|
|||||||
private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";
|
private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")
|
@DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import static org.assertj.core.api.Assertions.catchThrowable;
|
|||||||
class AcademicStructureInvariantIntegrationTest {
|
class AcademicStructureInvariantIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы")
|
@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,
|
private WriteOutcome concurrentGroupUpdate(long groupId,
|
||||||
long educationFormId,
|
long educationFormId,
|
||||||
CountDownLatch ready,
|
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 {
|
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
INSERT INTO academic_calendar_days (
|
INSERT INTO academic_calendar_days (
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import static org.assertj.core.api.Assertions.catchThrowable;
|
|||||||
class ScheduleOverrideMigrationIntegrationTest {
|
class ScheduleOverrideMigrationIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")
|
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload";
|
private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload";
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class SubjectOwnershipInvariantIntegrationTest {
|
|||||||
private static final String UNIQUE_INDEX = "uq_subjects_name_ci";
|
private static final String UNIQUE_INDEX = "uq_subjects_name_ci";
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра")
|
@DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра")
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";
|
private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
|
@DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
|||||||
private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";
|
private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
|
@DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ import static org.mockito.Mockito.verify;
|
|||||||
class AcademicCalendarGridServiceIntegrationTest {
|
class AcademicCalendarGridServiceIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@DynamicPropertySource
|
@DynamicPropertySource
|
||||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.magistr.app.repository.GroupRepository;
|
|||||||
import com.magistr.app.repository.SpecialtiesRepository;
|
import com.magistr.app.repository.SpecialtiesRepository;
|
||||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||||
|
import com.magistr.app.repository.SubgroupRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -69,6 +70,8 @@ class AcademicStructureServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private StudentGroupCalendarAssignmentRepository assignmentRepository;
|
private StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
@Mock
|
@Mock
|
||||||
|
private SubgroupRepository subgroupRepository;
|
||||||
|
@Mock
|
||||||
private ScheduleGeneratorService scheduleGeneratorService;
|
private ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
|
||||||
private AcademicStructureService service;
|
private AcademicStructureService service;
|
||||||
@@ -92,6 +95,7 @@ class AcademicStructureServiceTest {
|
|||||||
educationFormRepository,
|
educationFormRepository,
|
||||||
groupRepository,
|
groupRepository,
|
||||||
assignmentRepository,
|
assignmentRepository,
|
||||||
|
subgroupRepository,
|
||||||
scheduleGeneratorService
|
scheduleGeneratorService
|
||||||
);
|
);
|
||||||
academicYear = academicYear();
|
academicYear = academicYear();
|
||||||
@@ -111,6 +115,7 @@ class AcademicStructureServiceTest {
|
|||||||
when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm));
|
when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm));
|
||||||
when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm));
|
when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm));
|
||||||
when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group));
|
when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group));
|
||||||
|
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
|
||||||
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
|
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
|
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
|
||||||
@@ -192,6 +197,56 @@ class AcademicStructureServiceTest {
|
|||||||
verify(scheduleGeneratorService).clearCache();
|
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
|
@Test
|
||||||
void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() {
|
void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() {
|
||||||
group.setYearStartStudy(2020);
|
group.setYearStartStudy(2020);
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class PostgreSqlAdvisoryLockIntegrationTest {
|
|||||||
private static final LocalDate TEST_DATE = LocalDate.of(2026, 9, 1);
|
private static final LocalDate TEST_DATE = LocalDate.of(2026, 9, 1);
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@DynamicPropertySource
|
@DynamicPropertySource
|
||||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.util.stream.IntStream;
|
|||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.anyCollection;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
@@ -134,6 +135,29 @@ class ScheduleGenerationQueryCountTest {
|
|||||||
assertThat(repositoryCalls).isEqualTo(7);
|
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() {
|
private AcademicYear academicYear() {
|
||||||
AcademicYear academicYear = new AcademicYear();
|
AcademicYear academicYear = new AcademicYear();
|
||||||
academicYear.setId(1L);
|
academicYear.setId(1L);
|
||||||
|
|||||||
@@ -73,6 +73,37 @@ class ScheduleQueryServiceTest {
|
|||||||
verify(groupRepository, never()).findAll();
|
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
|
@ParameterizedTest
|
||||||
@ValueSource(ints = {51, 100})
|
@ValueSource(ints = {51, 100})
|
||||||
void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) {
|
void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) {
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ import static org.mockito.Mockito.verify;
|
|||||||
class ScheduleRuleServiceConcurrencyIntegrationTest {
|
class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@DynamicPropertySource
|
@DynamicPropertySource
|
||||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
private static final String VALID_DATABASE = "tenant_valid";
|
private static final String VALID_DATABASE = "tenant_valid";
|
||||||
|
|
||||||
@Container
|
@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 TenantRoutingDataSource routingDataSource;
|
||||||
private TenantDatabaseMigrationService migrationService;
|
private TenantDatabaseMigrationService migrationService;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,22 +45,22 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
networks:
|
networks:
|
||||||
- magistr
|
- magistr
|
||||||
|
- proxy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
|
||||||
- "${HTTP_PORT:-80}:80"
|
|
||||||
networks:
|
networks:
|
||||||
- magistr
|
- magistr
|
||||||
|
- proxy
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16.3-alpine3.20
|
image: postgres:16.3-alpine@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-myuser}
|
POSTGRES_USER: ${POSTGRES_USER:-myuser}
|
||||||
@@ -79,6 +79,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
magistr:
|
magistr:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
30
docs/API.md
30
docs/API.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2026-05-27T19:47:54",
|
"timestamp": "2026-05-27T16:47:54Z",
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"error": "Некорректный запрос",
|
"error": "Некорректный запрос",
|
||||||
"message": "Некорректные параметры запроса",
|
"message": "Некорректные параметры запроса",
|
||||||
@@ -18,6 +18,11 @@
|
|||||||
|
|
||||||
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||||||
|
|
||||||
|
Все поля момента времени (`createdAt`, `updatedAt`, `reviewedAt`, `archivedAt` и
|
||||||
|
аналогичные) сериализуются как ISO-8601 UTC с суффиксом `Z`. Поля календарной даты
|
||||||
|
(`date`, `validFrom`, `validTo`, `activeFrom`, `activeTo`) остаются строками `YYYY-MM-DD`
|
||||||
|
без часового пояса и вычисляются по бизнес-зоне `Europe/Moscow`.
|
||||||
|
|
||||||
Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK
|
Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK
|
||||||
возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским
|
возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским
|
||||||
сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не
|
сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не
|
||||||
@@ -360,7 +365,13 @@ Refresh-токен ротируется при каждом успешном о
|
|||||||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||||||
| `endDate` | Да | Конец периода в формате `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
|
```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`.
|
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
|
||||||
|
|
||||||
@@ -1000,6 +1015,10 @@ payload обрабатываются один раз, используется
|
|||||||
Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без
|
Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без
|
||||||
изменений.
|
изменений.
|
||||||
|
|
||||||
|
Если у группы есть активные подгруппы, `groupSize` нельзя уменьшить ниже суммы их
|
||||||
|
`studentCapacity`. Такой запрос отклоняется без изменения группы. Параллельные изменения
|
||||||
|
группы и подгрупп сериализуются на backend и проверяются ограничениями PostgreSQL.
|
||||||
|
|
||||||
### `DELETE /api/groups/{id}`
|
### `DELETE /api/groups/{id}`
|
||||||
|
|
||||||
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||||||
@@ -1012,7 +1031,10 @@ payload обрабатываются один раз, используется
|
|||||||
|
|
||||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||||||
|
|
||||||
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
|
`studentCapacity` обязателен и должен быть больше нуля. Для одной учебной группы сумма
|
||||||
|
численностей активных подгрупп не может превышать численность самой группы. Frontend на
|
||||||
|
вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или
|
||||||
|
три подгруппы.
|
||||||
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
|
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
|
||||||
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
|
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,12 @@ GiST exclusion constraints и триггеры PostgreSQL, связывающи
|
|||||||
ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт
|
ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт
|
||||||
устаревшее назначение.
|
устаревшее назначение.
|
||||||
|
|
||||||
|
Создание и обновление группы используют один validator положительных размерностей.
|
||||||
|
Уменьшение численности дополнительно сверяется с суммой активных подгрупп. CRUD подгрупп и
|
||||||
|
изменение группы захватывают строку родительской группы `FOR UPDATE`; V1 повторяет проверку
|
||||||
|
триггерами обеих таблиц. Поэтому конкурентно могут завершиться только совместимые
|
||||||
|
изменения, а сумма активных `student_capacity` никогда не превышает `group_size`.
|
||||||
|
|
||||||
`TeacherDepartmentService` является единым источником датированных решений о кафедре
|
`TeacherDepartmentService` является единым источником датированных решений о кафедре
|
||||||
преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты
|
преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты
|
||||||
нагрузки читают `teacher_department_assignments` на целевую дату, не используют
|
нагрузки читают `teacher_department_assignments` на целевую дату, не используют
|
||||||
@@ -329,6 +335,8 @@ rollback. Инвалидация кэша зарегистрирована че
|
|||||||
lost update или проверке устаревшего снимка.
|
lost update или проверке устаревшего снимка.
|
||||||
|
|
||||||
Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов.
|
Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов.
|
||||||
|
Обе границы диапазона включительны, поэтому общий лимит 120 дат вычисляется как
|
||||||
|
`endDate - startDate + 1` одинаково в query- и generator-слоях.
|
||||||
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
|
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
|
||||||
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
|
`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:
|
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
|
|
||||||
### Учебные группы (Student Groups)
|
### Учебные группы (Student Groups)
|
||||||
|
|
||||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
- **Поля:** Название, положительная численность, форма обучения, кафедра, специальность, профиль обучения, положительный год начала обучения
|
||||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||||
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
|
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Численность каждой подгруппы обязательна и положительна, а сумма численностей активных подгрупп не может превышать численность группы. Численность группы нельзя уменьшить ниже этой суммы. Изменения группы и подгрупп сериализуются блокировкой родительской группы и дополнительно защищены триггерами V1, поэтому параллельные запросы не нарушают инвариант. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
|
||||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||||
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
|
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
|
||||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||||
@@ -193,6 +193,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
|
|
||||||
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||||
|
|
||||||
|
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными
|
||||||
|
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат
|
||||||
|
разрешены, 121 дата отклоняется до генерации.
|
||||||
|
|
||||||
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
|
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
|
||||||
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
|
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
|
||||||
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию
|
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию
|
||||||
|
|||||||
111
docs/DATABASE.md
111
docs/DATABASE.md
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
## Общая информация
|
## Общая информация
|
||||||
|
|
||||||
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine3.20`, продакшн — managed PostgreSQL)
|
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
|
||||||
- **Управление схемой:** Flyway (программный запуск)
|
- **Управление схемой:** Flyway (программный запуск)
|
||||||
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
|
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
|
||||||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
|
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
|
||||||
- **Мультитенантность:** Каждый тенант = отдельная БД
|
- **Мультитенантность:** Каждый тенант = отдельная БД
|
||||||
|
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
|
||||||
|
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,9 +46,9 @@ erDiagram
|
|||||||
VARCHAR status
|
VARCHAR status
|
||||||
DATE active_from
|
DATE active_from
|
||||||
DATE active_to
|
DATE active_to
|
||||||
TIMESTAMP archived_at
|
TIMESTAMPTZ archived_at
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
TIMESTAMP updated_at
|
TIMESTAMPTZ updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
auth_refresh_tokens {
|
auth_refresh_tokens {
|
||||||
@@ -54,9 +56,9 @@ erDiagram
|
|||||||
BIGINT user_id FK
|
BIGINT user_id FK
|
||||||
VARCHAR tenant
|
VARCHAR tenant
|
||||||
VARCHAR token_hash UK
|
VARCHAR token_hash UK
|
||||||
TIMESTAMP issued_at
|
TIMESTAMPTZ issued_at
|
||||||
TIMESTAMP expires_at
|
TIMESTAMPTZ expires_at
|
||||||
TIMESTAMP revoked_at
|
TIMESTAMPTZ revoked_at
|
||||||
VARCHAR rotated_to_token_hash
|
VARCHAR rotated_to_token_hash
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +68,10 @@ erDiagram
|
|||||||
VARCHAR username_normalized UK
|
VARCHAR username_normalized UK
|
||||||
VARCHAR client_ip UK
|
VARCHAR client_ip UK
|
||||||
INTEGER failure_count
|
INTEGER failure_count
|
||||||
TIMESTAMP window_started_at
|
TIMESTAMPTZ window_started_at
|
||||||
TIMESTAMP last_failure_at
|
TIMESTAMPTZ last_failure_at
|
||||||
TIMESTAMP blocked_until
|
TIMESTAMPTZ blocked_until
|
||||||
TIMESTAMP updated_at
|
TIMESTAMPTZ updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
auth_login_attempt_audit {
|
auth_login_attempt_audit {
|
||||||
@@ -78,7 +80,7 @@ erDiagram
|
|||||||
VARCHAR username_normalized
|
VARCHAR username_normalized
|
||||||
VARCHAR client_ip
|
VARCHAR client_ip
|
||||||
VARCHAR outcome
|
VARCHAR outcome
|
||||||
TIMESTAMP occurred_at
|
TIMESTAMPTZ occurred_at
|
||||||
INTEGER retry_after_seconds
|
INTEGER retry_after_seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@ erDiagram
|
|||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
VARCHAR name UK
|
VARCHAR name UK
|
||||||
TEXT description
|
TEXT description
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
student_groups {
|
student_groups {
|
||||||
@@ -98,7 +100,7 @@ erDiagram
|
|||||||
BIGINT specialty_id FK
|
BIGINT specialty_id FK
|
||||||
BIGINT specialty_profile_id FK
|
BIGINT specialty_profile_id FK
|
||||||
BIGINT year_start_study
|
BIGINT year_start_study
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
subgroups {
|
subgroups {
|
||||||
@@ -114,7 +116,7 @@ erDiagram
|
|||||||
VARCHAR code
|
VARCHAR code
|
||||||
BIGINT department_id FK
|
BIGINT department_id FK
|
||||||
TEXT description
|
TEXT description
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
lesson_types {
|
lesson_types {
|
||||||
@@ -141,9 +143,9 @@ erDiagram
|
|||||||
VARCHAR status
|
VARCHAR status
|
||||||
DATE active_from
|
DATE active_from
|
||||||
DATE active_to
|
DATE active_to
|
||||||
TIMESTAMP archived_at
|
TIMESTAMPTZ archived_at
|
||||||
TEXT description
|
TEXT description
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
classroom_equipments {
|
classroom_equipments {
|
||||||
@@ -180,7 +182,7 @@ erDiagram
|
|||||||
VARCHAR status
|
VARCHAR status
|
||||||
BIGINT requested_by FK
|
BIGINT requested_by FK
|
||||||
BIGINT reviewed_by FK
|
BIGINT reviewed_by FK
|
||||||
TIMESTAMP reviewed_at
|
TIMESTAMPTZ reviewed_at
|
||||||
TEXT review_comment
|
TEXT review_comment
|
||||||
BIGINT created_teacher_id FK
|
BIGINT created_teacher_id FK
|
||||||
}
|
}
|
||||||
@@ -190,7 +192,7 @@ erDiagram
|
|||||||
BIGINT subject_id FK
|
BIGINT subject_id FK
|
||||||
BIGINT author_id FK
|
BIGINT author_id FK
|
||||||
TEXT comment
|
TEXT comment
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
teacher_lesson_types {
|
teacher_lesson_types {
|
||||||
@@ -256,8 +258,8 @@ erDiagram
|
|||||||
BIGINT specialty_profile_id FK
|
BIGINT specialty_profile_id FK
|
||||||
BIGINT study_form_id FK
|
BIGINT study_form_id FK
|
||||||
INT course_count
|
INT course_count
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
TIMESTAMP updated_at
|
TIMESTAMPTZ updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
academic_calendar_days {
|
academic_calendar_days {
|
||||||
@@ -275,7 +277,7 @@ erDiagram
|
|||||||
BIGINT calendar_id FK
|
BIGINT calendar_id FK
|
||||||
INT semester_number
|
INT semester_number
|
||||||
BIGINT subject_id FK
|
BIGINT subject_id FK
|
||||||
TIMESTAMP created_at
|
TIMESTAMPTZ created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
student_group_calendar_assignments {
|
student_group_calendar_assignments {
|
||||||
@@ -433,10 +435,10 @@ erDiagram
|
|||||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти |
|
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти |
|
||||||
| `active_from` | DATE | Дата начала действия записи |
|
| `active_from` | DATE | Дата начала действия записи |
|
||||||
| `active_to` | DATE | Дата окончания действия записи |
|
| `active_to` | DATE | Дата окончания действия записи |
|
||||||
| `archived_at` | TIMESTAMP | Когда пользователь архивирован |
|
| `archived_at` | TIMESTAMPTZ | Когда пользователь архивирован |
|
||||||
| `archive_reason` | TEXT | Причина архивирования |
|
| `archive_reason` | TEXT | Причина архивирования |
|
||||||
| `created_at` | TIMESTAMP | Дата создания |
|
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||||
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
|
| `updated_at` | TIMESTAMPTZ | Дата обновления (авто-триггер) |
|
||||||
|
|
||||||
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
||||||
|
|
||||||
@@ -447,9 +449,9 @@ erDiagram
|
|||||||
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
|
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
|
||||||
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
|
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
|
||||||
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
|
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
|
||||||
| `issued_at` | TIMESTAMP | Дата выдачи |
|
| `issued_at` | TIMESTAMPTZ | Дата выдачи |
|
||||||
| `expires_at` | TIMESTAMP | Дата истечения |
|
| `expires_at` | TIMESTAMPTZ | Дата истечения |
|
||||||
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
|
| `revoked_at` | TIMESTAMPTZ | Дата отзыва, `NULL` для активной сессии |
|
||||||
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
|
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
|
||||||
| `user_agent` | VARCHAR(512) | User-Agent клиента |
|
| `user_agent` | VARCHAR(512) | User-Agent клиента |
|
||||||
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
|
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
|
||||||
@@ -469,11 +471,11 @@ audit retention (по умолчанию 30 дней). Индексы `idx_auth_
|
|||||||
| `username_normalized` | VARCHAR(100) | NFKC-нормализованное имя в нижнем регистре |
|
| `username_normalized` | VARCHAR(100) | NFKC-нормализованное имя в нижнем регистре |
|
||||||
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
|
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
|
||||||
| `failure_count` | INTEGER | Число отказов в текущем окне, не меньше нуля |
|
| `failure_count` | INTEGER | Число отказов в текущем окне, не меньше нуля |
|
||||||
| `window_started_at` | TIMESTAMP | Начало окна учёта попыток |
|
| `window_started_at` | TIMESTAMPTZ | Начало окна учёта попыток |
|
||||||
| `last_failure_at` | TIMESTAMP | Время последнего отказа |
|
| `last_failure_at` | TIMESTAMPTZ | Время последнего отказа |
|
||||||
| `blocked_until` | TIMESTAMP | Окончание временной блокировки либо `NULL` |
|
| `blocked_until` | TIMESTAMPTZ | Окончание временной блокировки либо `NULL` |
|
||||||
| `created_at` | TIMESTAMP | Время создания состояния |
|
| `created_at` | TIMESTAMPTZ | Время создания состояния |
|
||||||
| `updated_at` | TIMESTAMP | Последнее изменение состояния |
|
| `updated_at` | TIMESTAMPTZ | Последнее изменение состояния |
|
||||||
|
|
||||||
Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля
|
Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля
|
||||||
backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её
|
backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её
|
||||||
@@ -489,7 +491,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
|||||||
| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса |
|
| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса |
|
||||||
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
|
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
|
||||||
| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` |
|
| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` |
|
||||||
| `occurred_at` | TIMESTAMP | Время события |
|
| `occurred_at` | TIMESTAMPTZ | Время события |
|
||||||
| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` |
|
| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` |
|
||||||
|
|
||||||
Таблица принципиально не содержит пароль, его хэш из запроса или признак существования
|
Таблица принципиально не содержит пароль, его хэш из запроса или признак существования
|
||||||
@@ -512,16 +514,16 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
|||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
| `id` | BIGSERIAL PK | ID |
|
| `id` | BIGSERIAL PK | ID |
|
||||||
| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное |
|
| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное |
|
||||||
| `group_size` | BIGINT | Количество студентов |
|
| `group_size` | BIGINT CHECK (> 0) | Количество студентов |
|
||||||
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
|
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
|
||||||
| `department_id` | BIGINT FK → departments | Кафедра |
|
| `department_id` | BIGINT FK → departments | Кафедра |
|
||||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
||||||
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
| `year_start_study` | BIGINT CHECK (> 0) | Год начала обучения, используется для вычисления текущего курса |
|
||||||
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
|
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
|
||||||
| `active_from` | DATE | Дата начала действия группы |
|
| `active_from` | DATE | Дата начала действия группы |
|
||||||
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
|
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
|
||||||
| `archived_at` | TIMESTAMP | Дата и время архивирования |
|
| `archived_at` | TIMESTAMPTZ | Дата и время архивирования |
|
||||||
| `archive_reason` | TEXT | Причина архивирования |
|
| `archive_reason` | TEXT | Причина архивирования |
|
||||||
|
|
||||||
#### `subgroups` — Подгруппы
|
#### `subgroups` — Подгруппы
|
||||||
@@ -530,9 +532,18 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
|||||||
| `id` | BIGSERIAL PK | ID |
|
| `id` | BIGSERIAL PK | ID |
|
||||||
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
|
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
|
||||||
| `name` | VARCHAR(100) | Название подгруппы |
|
| `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` — Дисциплины
|
#### `subjects` — Дисциплины
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
@@ -561,7 +572,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
|||||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
|
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
|
||||||
| `active_from` | DATE | Дата начала действия записи |
|
| `active_from` | DATE | Дата начала действия записи |
|
||||||
| `active_to` | DATE | Дата окончания действия записи |
|
| `active_to` | DATE | Дата окончания действия записи |
|
||||||
| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации |
|
| `archived_at` | TIMESTAMPTZ | Когда аудитория выведена из эксплуатации |
|
||||||
| `archive_reason` | TEXT | Причина архивирования |
|
| `archive_reason` | TEXT | Причина архивирования |
|
||||||
| `description` | TEXT | Описание |
|
| `description` | TEXT | Описание |
|
||||||
|
|
||||||
@@ -618,7 +629,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
|||||||
| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры |
|
| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры |
|
||||||
| `is_primary` | BOOLEAN | Основная кафедра преподавателя |
|
| `is_primary` | BOOLEAN | Основная кафедра преподавателя |
|
||||||
| `comment` | TEXT | Комментарий к переводу |
|
| `comment` | TEXT | Комментарий к переводу |
|
||||||
| `created_at` | TIMESTAMP | Дата создания записи |
|
| `created_at` | TIMESTAMPTZ | Дата создания записи |
|
||||||
| `created_by` | BIGINT FK → users | Кто оформил перевод |
|
| `created_by` | BIGINT FK → users | Кто оформил перевод |
|
||||||
|
|
||||||
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной
|
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной
|
||||||
@@ -641,11 +652,11 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
|||||||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
|
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
|
||||||
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
|
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
|
||||||
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
|
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
|
||||||
| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
|
| `reviewed_at` | TIMESTAMPTZ | Дата рассмотрения |
|
||||||
| `review_comment` | TEXT | Комментарий администратора |
|
| `review_comment` | TEXT | Комментарий администратора |
|
||||||
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
|
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
|
||||||
| `created_at` | TIMESTAMP | Дата создания заявки |
|
| `created_at` | TIMESTAMPTZ | Дата создания заявки |
|
||||||
| `updated_at` | TIMESTAMP | Дата последнего изменения |
|
| `updated_at` | TIMESTAMPTZ | Дата последнего изменения |
|
||||||
|
|
||||||
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
|
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `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 | Дисциплина |
|
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||||
| `author_id` | BIGINT FK → users | Автор комментария |
|
| `author_id` | BIGINT FK → users | Автор комментария |
|
||||||
| `comment` | TEXT | Текст комментария |
|
| `comment` | TEXT | Текст комментария |
|
||||||
| `created_at` | TIMESTAMP | Дата создания |
|
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||||
|
|
||||||
#### `time_slot_scopes` — Сетки времени
|
#### `time_slot_scopes` — Сетки времени
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
@@ -748,8 +759,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
|||||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
|
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
|
||||||
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
|
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
|
||||||
| `course_count` | INT CHECK(1–8) | Количество курсов в сетке |
|
| `course_count` | INT CHECK(1–8) | Количество курсов в сетке |
|
||||||
| `created_at` | TIMESTAMP | Дата создания |
|
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||||
| `updated_at` | TIMESTAMP | Дата обновления |
|
| `updated_at` | TIMESTAMPTZ | Дата обновления |
|
||||||
|
|
||||||
Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год,
|
Триггер `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) | Календарный график |
|
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
||||||
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
|
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
|
||||||
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
|
| `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`.
|
Уникальность задаётся по `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 | Аудитория закреплена учебным отделом |
|
| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом |
|
||||||
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
|
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
|
||||||
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
|
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
|
||||||
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
|
| `locked_at` | TIMESTAMPTZ | Когда выполнено закрепление |
|
||||||
| `lock_comment` | TEXT | Комментарий к закреплению |
|
| `lock_comment` | TEXT | Комментарий к закреплению |
|
||||||
|
|
||||||
V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
|
V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
|
||||||
@@ -870,7 +881,7 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|
|||||||
| `new_lesson_format` | VARCHAR(30) | Новый формат занятия |
|
| `new_lesson_format` | VARCHAR(30) | Новый формат занятия |
|
||||||
| `comment` | TEXT | Причина изменения |
|
| `comment` | TEXT | Причина изменения |
|
||||||
| `created_by` | BIGINT FK → users | Автор изменения |
|
| `created_by` | BIGINT FK → users | Автор изменения |
|
||||||
| `created_at` | TIMESTAMP | Дата создания |
|
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||||
|
|
||||||
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
|
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
|
||||||
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
|
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ docker compose logs -f
|
|||||||
|
|
||||||
Приложение доступно: **http://localhost:80**
|
Приложение доступно: **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
|
```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
|
## Работа с миграциями Flyway
|
||||||
|
|
||||||
### Правила
|
### Правила
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ frontend/
|
|||||||
|
|
||||||
### Особенности админских вкладок
|
### Особенности админских вкладок
|
||||||
|
|
||||||
|
- Общий `formatLocalDate()` формирует `YYYY-MM-DD` из локальных компонентов `Date`, не
|
||||||
|
используя `toISOString()`. Кабинет кафедры и редактор академического календаря также
|
||||||
|
выполняют календарную арифметику локальными компонентами, поэтому даты первых часов
|
||||||
|
суток и границы месяца не сдвигаются из-за преобразования в UTC.
|
||||||
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся.
|
- Вкладка `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`.
|
- Вкладка `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-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
||||||
|
|||||||
@@ -7,16 +7,29 @@
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
backend: # Spring Boot (Java 17), внутренний порт 8080
|
backend: # Spring Boot (Java 17), внутренний порт 8080
|
||||||
frontend: # Apache httpd, публикует HTTP_PORT (по умолчанию 80)
|
frontend: # Apache httpd, внутренний порт 80
|
||||||
db: # PostgreSQL 16.3, внутренний порт 5432
|
db: # PostgreSQL 16.3, внутренний порт 5432
|
||||||
```
|
```
|
||||||
|
|
||||||
### Сеть
|
### Сеть
|
||||||
|
|
||||||
Compose сам создаёт изолированную bridge-сеть `magistr`. Внешняя сеть или отдельно
|
Compose сам создаёт изолированную bridge-сеть `magistr` для приложения и подключает backend
|
||||||
установленный reverse proxy для локального запуска не нужны. Apache раздаёт frontend и
|
с frontend к существующей внешней Docker-сети `proxy`. Caddy из `../сaddy-proxy/` также
|
||||||
проксирует same-origin пути `/api` и `/actuator/health` в backend; backend и PostgreSQL не
|
подключён к `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`. Обычный
|
Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный
|
||||||
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.
|
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.
|
||||||
@@ -39,8 +52,9 @@ LOGIN_RATE_BASE_BLOCK_DURATION=1m
|
|||||||
LOGIN_RATE_MAX_BLOCK_DURATION=15m
|
LOGIN_RATE_MAX_BLOCK_DURATION=15m
|
||||||
LOGIN_AUDIT_RETENTION=90d
|
LOGIN_AUDIT_RETENTION=90d
|
||||||
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
||||||
|
BUSINESS_TIME_ZONE=Europe/Moscow
|
||||||
|
TZ=Europe/Moscow
|
||||||
OTEL_SDK_DISABLED=true
|
OTEL_SDK_DISABLED=true
|
||||||
HTTP_PORT=80
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
|
Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
|
||||||
@@ -51,10 +65,15 @@ HTTP_PORT=80
|
|||||||
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
|
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
|
||||||
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
|
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
|
||||||
reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует.
|
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 по умолчанию отключён;
|
Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён;
|
||||||
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.
|
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.
|
||||||
|
|
||||||
В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
||||||
|
Kubernetes `app-config` также явно задаёт `BUSINESS_TIME_ZONE=Europe/Moscow`,
|
||||||
|
`TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow` для backend pod.
|
||||||
|
|
||||||
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
|
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
|
||||||
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
|
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
|
||||||
@@ -64,10 +83,10 @@ reverse proxy: заголовок `X-Forwarded-For` от остальных ис
|
|||||||
### Dockerfile (Backend)
|
### Dockerfile (Backend)
|
||||||
|
|
||||||
Backend собирается через multi-stage сборку Maven:
|
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-артефакт и
|
2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и
|
||||||
проверяется по закреплённому SHA-256
|
проверяется по закреплённому 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)
|
### 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
|
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
Оба базовых образа зафиксированы tag и manifest digest. Первый этап собирает зафиксированный
|
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
|
||||||
|
tag и manifest digest. Первый этап frontend собирает зафиксированный
|
||||||
OpenTelemetry bundle; второй раздаёт только runtime-
|
OpenTelemetry bundle; второй раздаёт только runtime-
|
||||||
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
|
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
|
||||||
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
|
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
|
||||||
@@ -340,16 +360,26 @@ Deployment-файлов запрещено.
|
|||||||
Расположение: `.gitea/workflows/docker-build.yaml`
|
Расположение: `.gitea/workflows/docker-build.yaml`
|
||||||
|
|
||||||
Основные шаги:
|
Основные шаги:
|
||||||
1. Backend unit/component tests, frontend static/unit tests, Compose validation и shell-тест
|
1. Backend unit/component tests, frontend static/unit tests, Compose validation, shell-тест
|
||||||
immutable rollout/rollback.
|
immutable rollout/rollback и регрессионная проверка закрепления артефактов.
|
||||||
2. Только после успешных gates — login, параллельная сборка и push backend/frontend с
|
2. Только после успешных gates — login, параллельная сборка и push backend/frontend с
|
||||||
SHA-tag и release tag без `latest`.
|
SHA-tag и release tag без `latest`. Сторонние Actions закреплены полными commit SHA.
|
||||||
3. Build jobs публикуют digest обоих образов; deploy job устанавливает фиксированный
|
3. BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build
|
||||||
`kubectl v1.33.12` после SHA-256 проверки.
|
jobs также возвращают registry digest каждого образа.
|
||||||
4. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
|
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. При отказе автоматически возвращает оба
|
ссылки, применяет оба 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
- Docker и Docker Compose
|
- Docker и Docker Compose
|
||||||
- Git
|
- Git
|
||||||
|
- запущенный Caddy из `../сaddy-proxy/`
|
||||||
|
|
||||||
### Локальный запуск
|
### Локальный запуск
|
||||||
|
|
||||||
@@ -41,13 +42,21 @@ cp .env.example .env
|
|||||||
# Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET.
|
# Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET.
|
||||||
# JWT_SECRET можно сгенерировать командой: openssl rand -base64 48
|
# 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
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose сам создаёт внутреннюю сеть и именованный том PostgreSQL. После запуска приложение
|
Compose создаёт внутреннюю сеть и именованный том PostgreSQL, а backend/frontend подключает
|
||||||
доступно по адресу **http://localhost:80**; если в `.env` задан другой `HTTP_PORT`, используйте
|
к общей внешней сети `proxy`. Они не публикуют порты на хосте: Caddy принимает запросы на
|
||||||
его. Backend и PostgreSQL наружу не публикуются: `/api` проксируется frontend-контейнером.
|
**https://localhost** (`http://localhost` перенаправляется на HTTPS), `/api` отправляет в
|
||||||
|
backend, остальные пути — во frontend. PostgreSQL остаётся только во внутренней сети.
|
||||||
|
|
||||||
|
Caddy использует локальный корневой сертификат. В CachyOS/Arch его можно добавить в
|
||||||
|
системное хранилище командами из `STARTUP_GUIDE.md`; без этого браузер покажет предупреждение.
|
||||||
|
|
||||||
**Учётные данные по умолчанию:**
|
**Учётные данные по умолчанию:**
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||||||
@@ -1473,24 +1473,36 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previousIsoDate(value) {
|
function previousIsoDate(value) {
|
||||||
const date = new Date(`${value}T00:00:00Z`);
|
const date = parseLocalIsoDate(value);
|
||||||
date.setUTCDate(date.getUTCDate() - 1);
|
date.setDate(date.getDate() - 1);
|
||||||
return date.toISOString().slice(0, 10);
|
return formatLocalDate(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enumerateDates(startIso, endIso) {
|
function enumerateDates(startIso, endIso) {
|
||||||
const result = [];
|
const result = [];
|
||||||
const current = new Date(`${startIso}T00:00:00Z`);
|
const current = parseLocalIsoDate(startIso);
|
||||||
const end = new Date(`${endIso}T00:00:00Z`);
|
const end = parseLocalIsoDate(endIso);
|
||||||
while (current <= end) {
|
while (current <= end) {
|
||||||
const iso = current.toISOString().slice(0, 10);
|
const iso = formatLocalDate(current);
|
||||||
const jsDay = current.getUTCDay();
|
const jsDay = current.getDay();
|
||||||
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
|
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
|
||||||
current.setUTCDate(current.getUTCDate() + 1);
|
current.setDate(current.getDate() + 1);
|
||||||
}
|
}
|
||||||
return result;
|
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) {
|
function groupBy(items, keyFn) {
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
|
|||||||
152
scripts/check-artifact-pinning.sh
Executable file
152
scripts/check-artifact-pinning.sh
Executable 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'
|
||||||
32
scripts/test-artifact-pinning.sh
Executable file
32
scripts/test-artifact-pinning.sh
Executable 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'
|
||||||
Reference in New Issue
Block a user