баг-фикс завершён
This commit is contained in:
30
docs/API.md
30
docs/API.md
@@ -8,7 +8,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-05-27T19:47:54",
|
||||
"timestamp": "2026-05-27T16:47:54Z",
|
||||
"status": 400,
|
||||
"error": "Некорректный запрос",
|
||||
"message": "Некорректные параметры запроса",
|
||||
@@ -18,6 +18,11 @@
|
||||
|
||||
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||||
|
||||
Все поля момента времени (`createdAt`, `updatedAt`, `reviewedAt`, `archivedAt` и
|
||||
аналогичные) сериализуются как ISO-8601 UTC с суффиксом `Z`. Поля календарной даты
|
||||
(`date`, `validFrom`, `validTo`, `activeFrom`, `activeTo`) остаются строками `YYYY-MM-DD`
|
||||
без часового пояса и вычисляются по бизнес-зоне `Europe/Moscow`.
|
||||
|
||||
Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK
|
||||
возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским
|
||||
сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не
|
||||
@@ -360,7 +365,13 @@ Refresh-токен ротируется при каждом успешном о
|
||||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||||
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`.
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120
|
||||
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля
|
||||
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и
|
||||
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
|
||||
расписание для неё возвращается пустым списком. Время пары берётся из базового слота
|
||||
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
|
||||
`/api/admin/time-slots`.
|
||||
|
||||
**Пример:**
|
||||
```http
|
||||
@@ -973,7 +984,11 @@ payload обрабатываются один раз, используется
|
||||
}
|
||||
```
|
||||
|
||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
|
||||
`groupSize`, `yearStartStudy` и все связанные идентификаторы должны быть положительными;
|
||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как
|
||||
legacy-alias для старых клиентов и исторически содержит ID записи из
|
||||
`/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже
|
||||
`0`, если обучение ещё не началось.
|
||||
|
||||
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
|
||||
|
||||
@@ -1000,6 +1015,10 @@ payload обрабатываются один раз, используется
|
||||
Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без
|
||||
изменений.
|
||||
|
||||
Если у группы есть активные подгруппы, `groupSize` нельзя уменьшить ниже суммы их
|
||||
`studentCapacity`. Такой запрос отклоняется без изменения группы. Параллельные изменения
|
||||
группы и подгрупп сериализуются на backend и проверяются ограничениями PostgreSQL.
|
||||
|
||||
### `DELETE /api/groups/{id}`
|
||||
|
||||
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||||
@@ -1012,7 +1031,10 @@ payload обрабатываются один раз, используется
|
||||
|
||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||||
|
||||
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
|
||||
`studentCapacity` обязателен и должен быть больше нуля. Для одной учебной группы сумма
|
||||
численностей активных подгрупп не может превышать численность самой группы. Frontend на
|
||||
вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или
|
||||
три подгруппы.
|
||||
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
|
||||
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
|
||||
|
||||
|
||||
@@ -273,6 +273,12 @@ GiST exclusion constraints и триггеры PostgreSQL, связывающи
|
||||
ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт
|
||||
устаревшее назначение.
|
||||
|
||||
Создание и обновление группы используют один validator положительных размерностей.
|
||||
Уменьшение численности дополнительно сверяется с суммой активных подгрупп. CRUD подгрупп и
|
||||
изменение группы захватывают строку родительской группы `FOR UPDATE`; V1 повторяет проверку
|
||||
триггерами обеих таблиц. Поэтому конкурентно могут завершиться только совместимые
|
||||
изменения, а сумма активных `student_capacity` никогда не превышает `group_size`.
|
||||
|
||||
`TeacherDepartmentService` является единым источником датированных решений о кафедре
|
||||
преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты
|
||||
нагрузки читают `teacher_department_assignments` на целевую дату, не используют
|
||||
@@ -329,6 +335,8 @@ rollback. Инвалидация кэша зарегистрирована че
|
||||
lost update или проверке устаревшего снимка.
|
||||
|
||||
Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов.
|
||||
Обе границы диапазона включительны, поэтому общий лимит 120 дат вычисляется как
|
||||
`endDate - startDate + 1` одинаково в query- и generator-слоях.
|
||||
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
|
||||
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
|
||||
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
|
||||
@@ -345,6 +353,23 @@ Teacher-only чтение строит базовое расписание на
|
||||
|
||||
---
|
||||
|
||||
## Временная модель
|
||||
|
||||
`BusinessTimeService` является единым источником текущей календарной даты и абсолютного
|
||||
момента. Бизнес-дата вычисляется из инъецируемого `Clock` в зоне
|
||||
`BUSINESS_TIME_ZONE` (по умолчанию `Europe/Moscow`), поэтому lifecycle, доступность
|
||||
расписания, назначения кафедр и создание периодов не зависят от timezone JVM или хоста.
|
||||
|
||||
Абсолютные моменты представлены `Instant`, хранятся в PostgreSQL как `TIMESTAMPTZ` и
|
||||
сериализуются в UTC. Hibernate принудительно использует `hibernate.jdbc.time_zone=UTC`,
|
||||
Jackson — UTC. Отдельные календарные даты представлены `LocalDate`/SQL `DATE`; для них
|
||||
смещение и время суток не передаются.
|
||||
|
||||
В production-конструкторах используется системный UTC clock. Тесты передают фиксированный
|
||||
`Clock`, включая границу московской полуночи, поэтому переход бизнес-даты воспроизводим.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
|
||||
|
||||
@@ -66,9 +66,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
### Учебные группы (Student Groups)
|
||||
|
||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
||||
- **Поля:** Название, положительная численность, форма обучения, кафедра, специальность, профиль обучения, положительный год начала обучения
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
|
||||
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Численность каждой подгруппы обязательна и положительна, а сумма численностей активных подгрупп не может превышать численность группы. Численность группы нельзя уменьшить ниже этой суммы. Изменения группы и подгрупп сериализуются блокировкой родительской группы и дополнительно защищены триггерами V1, поэтому параллельные запросы не нарушают инвариант. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
|
||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
|
||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||
@@ -193,6 +193,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||
|
||||
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными
|
||||
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат
|
||||
разрешены, 121 дата отклоняется до генерации.
|
||||
|
||||
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
|
||||
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
|
||||
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию
|
||||
|
||||
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 (программный запуск)
|
||||
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
|
||||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
|
||||
- **Мультитенантность:** Каждый тенант = отдельная БД
|
||||
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
|
||||
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
|
||||
|
||||
---
|
||||
|
||||
@@ -44,9 +46,9 @@ erDiagram
|
||||
VARCHAR status
|
||||
DATE active_from
|
||||
DATE active_to
|
||||
TIMESTAMP archived_at
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMP updated_at
|
||||
TIMESTAMPTZ archived_at
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
auth_refresh_tokens {
|
||||
@@ -54,9 +56,9 @@ erDiagram
|
||||
BIGINT user_id FK
|
||||
VARCHAR tenant
|
||||
VARCHAR token_hash UK
|
||||
TIMESTAMP issued_at
|
||||
TIMESTAMP expires_at
|
||||
TIMESTAMP revoked_at
|
||||
TIMESTAMPTZ issued_at
|
||||
TIMESTAMPTZ expires_at
|
||||
TIMESTAMPTZ revoked_at
|
||||
VARCHAR rotated_to_token_hash
|
||||
}
|
||||
|
||||
@@ -66,10 +68,10 @@ erDiagram
|
||||
VARCHAR username_normalized UK
|
||||
VARCHAR client_ip UK
|
||||
INTEGER failure_count
|
||||
TIMESTAMP window_started_at
|
||||
TIMESTAMP last_failure_at
|
||||
TIMESTAMP blocked_until
|
||||
TIMESTAMP updated_at
|
||||
TIMESTAMPTZ window_started_at
|
||||
TIMESTAMPTZ last_failure_at
|
||||
TIMESTAMPTZ blocked_until
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
auth_login_attempt_audit {
|
||||
@@ -78,7 +80,7 @@ erDiagram
|
||||
VARCHAR username_normalized
|
||||
VARCHAR client_ip
|
||||
VARCHAR outcome
|
||||
TIMESTAMP occurred_at
|
||||
TIMESTAMPTZ occurred_at
|
||||
INTEGER retry_after_seconds
|
||||
}
|
||||
|
||||
@@ -86,7 +88,7 @@ erDiagram
|
||||
BIGSERIAL id PK
|
||||
VARCHAR name UK
|
||||
TEXT description
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
student_groups {
|
||||
@@ -98,7 +100,7 @@ erDiagram
|
||||
BIGINT specialty_id FK
|
||||
BIGINT specialty_profile_id FK
|
||||
BIGINT year_start_study
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
subgroups {
|
||||
@@ -114,7 +116,7 @@ erDiagram
|
||||
VARCHAR code
|
||||
BIGINT department_id FK
|
||||
TEXT description
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
lesson_types {
|
||||
@@ -141,9 +143,9 @@ erDiagram
|
||||
VARCHAR status
|
||||
DATE active_from
|
||||
DATE active_to
|
||||
TIMESTAMP archived_at
|
||||
TIMESTAMPTZ archived_at
|
||||
TEXT description
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
classroom_equipments {
|
||||
@@ -180,7 +182,7 @@ erDiagram
|
||||
VARCHAR status
|
||||
BIGINT requested_by FK
|
||||
BIGINT reviewed_by FK
|
||||
TIMESTAMP reviewed_at
|
||||
TIMESTAMPTZ reviewed_at
|
||||
TEXT review_comment
|
||||
BIGINT created_teacher_id FK
|
||||
}
|
||||
@@ -190,7 +192,7 @@ erDiagram
|
||||
BIGINT subject_id FK
|
||||
BIGINT author_id FK
|
||||
TEXT comment
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
teacher_lesson_types {
|
||||
@@ -256,8 +258,8 @@ erDiagram
|
||||
BIGINT specialty_profile_id FK
|
||||
BIGINT study_form_id FK
|
||||
INT course_count
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMP updated_at
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
academic_calendar_days {
|
||||
@@ -275,7 +277,7 @@ erDiagram
|
||||
BIGINT calendar_id FK
|
||||
INT semester_number
|
||||
BIGINT subject_id FK
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
student_group_calendar_assignments {
|
||||
@@ -433,10 +435,10 @@ erDiagram
|
||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти |
|
||||
| `active_from` | DATE | Дата начала действия записи |
|
||||
| `active_to` | DATE | Дата окончания действия записи |
|
||||
| `archived_at` | TIMESTAMP | Когда пользователь архивирован |
|
||||
| `archived_at` | TIMESTAMPTZ | Когда пользователь архивирован |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||
| `updated_at` | TIMESTAMPTZ | Дата обновления (авто-триггер) |
|
||||
|
||||
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
||||
|
||||
@@ -447,9 +449,9 @@ erDiagram
|
||||
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
|
||||
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
|
||||
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
|
||||
| `issued_at` | TIMESTAMP | Дата выдачи |
|
||||
| `expires_at` | TIMESTAMP | Дата истечения |
|
||||
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
|
||||
| `issued_at` | TIMESTAMPTZ | Дата выдачи |
|
||||
| `expires_at` | TIMESTAMPTZ | Дата истечения |
|
||||
| `revoked_at` | TIMESTAMPTZ | Дата отзыва, `NULL` для активной сессии |
|
||||
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
|
||||
| `user_agent` | VARCHAR(512) | User-Agent клиента |
|
||||
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
|
||||
@@ -469,11 +471,11 @@ audit retention (по умолчанию 30 дней). Индексы `idx_auth_
|
||||
| `username_normalized` | VARCHAR(100) | NFKC-нормализованное имя в нижнем регистре |
|
||||
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
|
||||
| `failure_count` | INTEGER | Число отказов в текущем окне, не меньше нуля |
|
||||
| `window_started_at` | TIMESTAMP | Начало окна учёта попыток |
|
||||
| `last_failure_at` | TIMESTAMP | Время последнего отказа |
|
||||
| `blocked_until` | TIMESTAMP | Окончание временной блокировки либо `NULL` |
|
||||
| `created_at` | TIMESTAMP | Время создания состояния |
|
||||
| `updated_at` | TIMESTAMP | Последнее изменение состояния |
|
||||
| `window_started_at` | TIMESTAMPTZ | Начало окна учёта попыток |
|
||||
| `last_failure_at` | TIMESTAMPTZ | Время последнего отказа |
|
||||
| `blocked_until` | TIMESTAMPTZ | Окончание временной блокировки либо `NULL` |
|
||||
| `created_at` | TIMESTAMPTZ | Время создания состояния |
|
||||
| `updated_at` | TIMESTAMPTZ | Последнее изменение состояния |
|
||||
|
||||
Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля
|
||||
backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её
|
||||
@@ -489,7 +491,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса |
|
||||
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
|
||||
| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` |
|
||||
| `occurred_at` | TIMESTAMP | Время события |
|
||||
| `occurred_at` | TIMESTAMPTZ | Время события |
|
||||
| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` |
|
||||
|
||||
Таблица принципиально не содержит пароль, его хэш из запроса или признак существования
|
||||
@@ -512,16 +514,16 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное |
|
||||
| `group_size` | BIGINT | Количество студентов |
|
||||
| `group_size` | BIGINT CHECK (> 0) | Количество студентов |
|
||||
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
|
||||
| `department_id` | BIGINT FK → departments | Кафедра |
|
||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
||||
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
||||
| `year_start_study` | BIGINT CHECK (> 0) | Год начала обучения, используется для вычисления текущего курса |
|
||||
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
|
||||
| `active_from` | DATE | Дата начала действия группы |
|
||||
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
|
||||
| `archived_at` | TIMESTAMP | Дата и время архивирования |
|
||||
| `archived_at` | TIMESTAMPTZ | Дата и время архивирования |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
|
||||
#### `subgroups` — Подгруппы
|
||||
@@ -530,9 +532,18 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
|
||||
| `name` | VARCHAR(100) | Название подгруппы |
|
||||
| `student_capacity` | INT | Количество студентов |
|
||||
| `student_capacity` | INT NOT NULL CHECK (> 0) | Количество студентов |
|
||||
|
||||
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
|
||||
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах
|
||||
могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное
|
||||
создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
|
||||
|
||||
CHECK-ограничения требуют положительные `student_groups.group_size`,
|
||||
`student_groups.year_start_study` и `subgroups.student_capacity`. Триггеры
|
||||
`validate_student_group_subgroup_capacity` и
|
||||
`validate_subgroup_student_capacity` блокируют родительскую группу и не допускают,
|
||||
чтобы сумма численностей активных подгрупп превышала `group_size`. Инвариант действует и
|
||||
при прямой записи в БД, смене статуса/родительской группы и конкурентных транзакциях.
|
||||
|
||||
#### `subjects` — Дисциплины
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -561,7 +572,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
|
||||
| `active_from` | DATE | Дата начала действия записи |
|
||||
| `active_to` | DATE | Дата окончания действия записи |
|
||||
| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации |
|
||||
| `archived_at` | TIMESTAMPTZ | Когда аудитория выведена из эксплуатации |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
| `description` | TEXT | Описание |
|
||||
|
||||
@@ -618,7 +629,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры |
|
||||
| `is_primary` | BOOLEAN | Основная кафедра преподавателя |
|
||||
| `comment` | TEXT | Комментарий к переводу |
|
||||
| `created_at` | TIMESTAMP | Дата создания записи |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания записи |
|
||||
| `created_by` | BIGINT FK → users | Кто оформил перевод |
|
||||
|
||||
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной
|
||||
@@ -641,11 +652,11 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
|
||||
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
|
||||
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
|
||||
| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
|
||||
| `reviewed_at` | TIMESTAMPTZ | Дата рассмотрения |
|
||||
| `review_comment` | TEXT | Комментарий администратора |
|
||||
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
|
||||
| `created_at` | TIMESTAMP | Дата создания заявки |
|
||||
| `updated_at` | TIMESTAMP | Дата последнего изменения |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания заявки |
|
||||
| `updated_at` | TIMESTAMPTZ | Дата последнего изменения |
|
||||
|
||||
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
|
||||
|
||||
@@ -656,7 +667,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|
||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||
| `author_id` | BIGINT FK → users | Автор комментария |
|
||||
| `comment` | TEXT | Текст комментария |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||
|
||||
#### `time_slot_scopes` — Сетки времени
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -748,8 +759,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
|
||||
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
|
||||
| `course_count` | INT CHECK(1–8) | Количество курсов в сетке |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
| `updated_at` | TIMESTAMP | Дата обновления |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||
| `updated_at` | TIMESTAMPTZ | Дата обновления |
|
||||
|
||||
Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год,
|
||||
специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная
|
||||
@@ -777,7 +788,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
||||
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
|
||||
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
|
||||
| `created_at` | TIMESTAMP | Дата создания привязки |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания привязки |
|
||||
|
||||
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend и триггером `trg_calendar_subjects_dimensions` по `academic_calendars.course_count * 2`.
|
||||
|
||||
@@ -841,7 +852,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом |
|
||||
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
|
||||
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
|
||||
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
|
||||
| `locked_at` | TIMESTAMPTZ | Когда выполнено закрепление |
|
||||
| `lock_comment` | TEXT | Комментарий к закреплению |
|
||||
|
||||
V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
|
||||
@@ -870,7 +881,7 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|
||||
| `new_lesson_format` | VARCHAR(30) | Новый формат занятия |
|
||||
| `comment` | TEXT | Причина изменения |
|
||||
| `created_by` | BIGINT FK → users | Автор изменения |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||||
|
||||
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
|
||||
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
|
||||
|
||||
@@ -23,6 +23,21 @@ docker compose logs -f
|
||||
|
||||
Приложение доступно: **http://localhost:80**
|
||||
|
||||
### Обновление контейнерных артефактов
|
||||
|
||||
Базовые и служебные образы указываются как `точный-tag@sha256:manifest-digest`; сторонние
|
||||
Actions — полным 40-символьным commit SHA. При обновлении версии одновременно обновите tag,
|
||||
digest и комментарий версии, затем выполните:
|
||||
|
||||
```bash
|
||||
bash scripts/test-artifact-pinning.sh
|
||||
K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh
|
||||
```
|
||||
|
||||
Для скачиваемого исполняемого файла обязательны точная версия и проверка опубликованного
|
||||
SHA-256 до установки. Отключать SBOM, provenance или блокирующее HIGH/CRITICAL-сканирование
|
||||
ради прохождения pipeline запрещено.
|
||||
|
||||
### Пересборка после изменений
|
||||
|
||||
```bash
|
||||
@@ -220,6 +235,18 @@ public class AbsenceController {
|
||||
|
||||
---
|
||||
|
||||
## Работа со временем
|
||||
|
||||
- Для абсолютного момента используйте `Instant`; в БД ему соответствует `TIMESTAMPTZ`.
|
||||
- Для календарной даты без времени используйте `LocalDate`; текущую бизнес-дату получайте
|
||||
через инъекцию `BusinessTimeService`, а не через `LocalDate.now()`.
|
||||
- Новую бизнес-логику, зависящую от текущего времени, проверяйте фиксированным `Clock` на
|
||||
границе московской полуночи.
|
||||
- Во frontend не формируйте date-only через `Date.toISOString().slice(0, 10)`:
|
||||
используйте `formatLocalDate()` или исходную строку `YYYY-MM-DD`.
|
||||
|
||||
---
|
||||
|
||||
## Работа с миграциями Flyway
|
||||
|
||||
### Правила
|
||||
|
||||
@@ -159,6 +159,10 @@ frontend/
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Общий `formatLocalDate()` формирует `YYYY-MM-DD` из локальных компонентов `Date`, не
|
||||
используя `toISOString()`. Кабинет кафедры и редактор академического календаря также
|
||||
выполняют календарную арифметику локальными компонентами, поэтому даты первых часов
|
||||
суток и границы месяца не сдвигаются из-за преобразования в UTC.
|
||||
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся.
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
||||
|
||||
@@ -7,16 +7,29 @@
|
||||
```yaml
|
||||
services:
|
||||
backend: # Spring Boot (Java 17), внутренний порт 8080
|
||||
frontend: # Apache httpd, публикует HTTP_PORT (по умолчанию 80)
|
||||
frontend: # Apache httpd, внутренний порт 80
|
||||
db: # PostgreSQL 16.3, внутренний порт 5432
|
||||
```
|
||||
|
||||
### Сеть
|
||||
|
||||
Compose сам создаёт изолированную bridge-сеть `magistr`. Внешняя сеть или отдельно
|
||||
установленный reverse proxy для локального запуска не нужны. Apache раздаёт frontend и
|
||||
проксирует same-origin пути `/api` и `/actuator/health` в backend; backend и PostgreSQL не
|
||||
публикуют порты на хосте.
|
||||
Compose сам создаёт изолированную bridge-сеть `magistr` для приложения и подключает backend
|
||||
с frontend к существующей внешней Docker-сети `proxy`. Caddy из `../сaddy-proxy/` также
|
||||
подключён к `proxy`: на `localhost` он отправляет `/api/*` непосредственно в `backend:8080`,
|
||||
а остальные пути — в `frontend:80`. Контейнеры Magistr не публикуют порты на хосте;
|
||||
PostgreSQL доступен только во внутренней сети `magistr`.
|
||||
|
||||
Перед первым запуском создайте общую сеть и поднимите Caddy:
|
||||
|
||||
```bash
|
||||
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
|
||||
docker compose -f ../сaddy-proxy/compose.yaml up -d
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Для `https://localhost` Caddy выпускает сертификат своим локальным CA. На CachyOS/Arch
|
||||
корневой сертификат устанавливается в системное хранилище через `trust anchor`; точная
|
||||
команда приведена в `STARTUP_GUIDE.md`.
|
||||
|
||||
Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный
|
||||
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.
|
||||
@@ -39,8 +52,9 @@ LOGIN_RATE_BASE_BLOCK_DURATION=1m
|
||||
LOGIN_RATE_MAX_BLOCK_DURATION=15m
|
||||
LOGIN_AUDIT_RETENTION=90d
|
||||
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
||||
BUSINESS_TIME_ZONE=Europe/Moscow
|
||||
TZ=Europe/Moscow
|
||||
OTEL_SDK_DISABLED=true
|
||||
HTTP_PORT=80
|
||||
```
|
||||
|
||||
Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
|
||||
@@ -51,10 +65,15 @@ HTTP_PORT=80
|
||||
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
|
||||
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
|
||||
reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует.
|
||||
`BUSINESS_TIME_ZONE` задаёт правила календарных бизнес-дат, а `TZ` и
|
||||
`JAVA_TOOL_OPTIONS=-Duser.timezone=...` фиксируют timezone JVM и контейнера. Абсолютные
|
||||
timestamps при этом всегда передаются между Java и PostgreSQL в UTC.
|
||||
Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён;
|
||||
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.
|
||||
|
||||
В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
||||
Kubernetes `app-config` также явно задаёт `BUSINESS_TIME_ZONE=Europe/Moscow`,
|
||||
`TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow` для backend pod.
|
||||
|
||||
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
|
||||
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
|
||||
@@ -64,10 +83,10 @@ reverse proxy: заголовок `X-Forwarded-For` от остальных ис
|
||||
### Dockerfile (Backend)
|
||||
|
||||
Backend собирается через multi-stage сборку Maven:
|
||||
1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
|
||||
1. Этап сборки: `maven:3.9.9-eclipse-temurin-17@sha256:f58d59b...` → `mvn package`
|
||||
2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и
|
||||
проверяется по закреплённому SHA-256
|
||||
3. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
||||
3. Этап запуска: `eclipse-temurin:17-jre-alpine@sha256:02320dd4...` → `java -jar app.jar`
|
||||
|
||||
### Dockerfile (Frontend)
|
||||
|
||||
@@ -82,7 +101,8 @@ COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
|
||||
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
||||
```
|
||||
|
||||
Оба базовых образа зафиксированы tag и manifest digest. Первый этап собирает зафиксированный
|
||||
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
|
||||
tag и manifest digest. Первый этап frontend собирает зафиксированный
|
||||
OpenTelemetry bundle; второй раздаёт только runtime-
|
||||
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
|
||||
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
|
||||
@@ -340,16 +360,26 @@ Deployment-файлов запрещено.
|
||||
Расположение: `.gitea/workflows/docker-build.yaml`
|
||||
|
||||
Основные шаги:
|
||||
1. Backend unit/component tests, frontend static/unit tests, Compose validation и shell-тест
|
||||
immutable rollout/rollback.
|
||||
1. Backend unit/component tests, frontend static/unit tests, Compose validation, shell-тест
|
||||
immutable rollout/rollback и регрессионная проверка закрепления артефактов.
|
||||
2. Только после успешных gates — login, параллельная сборка и push backend/frontend с
|
||||
SHA-tag и release tag без `latest`.
|
||||
3. Build jobs публикуют digest обоих образов; deploy job устанавливает фиксированный
|
||||
`kubectl v1.33.12` после SHA-256 проверки.
|
||||
4. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
|
||||
SHA-tag и release tag без `latest`. Сторонние Actions закреплены полными commit SHA.
|
||||
3. BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build
|
||||
jobs также возвращают registry digest каждого образа.
|
||||
4. Отдельный обязательный job сканирует опубликованные digests закреплённым Trivy `0.63.0` и
|
||||
блокирует deploy при исправимых `HIGH`/`CRITICAL` уязвимостях.
|
||||
5. Deploy job устанавливает фиксированный `kubectl v1.33.12` только после SHA-256 проверки.
|
||||
Java Agent также имеет точную версию и checksum.
|
||||
6. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
|
||||
ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба
|
||||
предыдущих образа и повторно проверяет их готовность.
|
||||
5. Workflow-wide concurrency lock не допускает одновременные production deployment.
|
||||
7. Workflow-wide concurrency lock не допускает одновременные production deployment.
|
||||
|
||||
`scripts/check-artifact-pinning.sh` проверяет Dockerfile, Compose, Actions, checksum и CI
|
||||
gates. При передаче `K8S_DIR=../k8s` он дополнительно требует digest у каждого production
|
||||
образа Kubernetes. Внешний `otel-collector.yaml` использует официальный Collector Contrib
|
||||
`0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`;
|
||||
версия и manifest digest обновляются только вместе и проверяются до rollout.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
- Docker и Docker Compose
|
||||
- Git
|
||||
- запущенный Caddy из `../сaddy-proxy/`
|
||||
|
||||
### Локальный запуск
|
||||
|
||||
@@ -41,13 +42,21 @@ cp .env.example .env
|
||||
# Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET.
|
||||
# JWT_SECRET можно сгенерировать командой: openssl rand -base64 48
|
||||
|
||||
# 3. Запустить все сервисы
|
||||
# 3. Один раз создать общую proxy-сеть и запустить Caddy
|
||||
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
|
||||
docker compose -f ../сaddy-proxy/compose.yaml up -d
|
||||
|
||||
# 4. Запустить все сервисы Magistr
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Compose сам создаёт внутреннюю сеть и именованный том PostgreSQL. После запуска приложение
|
||||
доступно по адресу **http://localhost:80**; если в `.env` задан другой `HTTP_PORT`, используйте
|
||||
его. Backend и PostgreSQL наружу не публикуются: `/api` проксируется frontend-контейнером.
|
||||
Compose создаёт внутреннюю сеть и именованный том PostgreSQL, а backend/frontend подключает
|
||||
к общей внешней сети `proxy`. Они не публикуют порты на хосте: Caddy принимает запросы на
|
||||
**https://localhost** (`http://localhost` перенаправляется на HTTPS), `/api` отправляет в
|
||||
backend, остальные пути — во frontend. PostgreSQL остаётся только во внутренней сети.
|
||||
|
||||
Caddy использует локальный корневой сертификат. В CachyOS/Arch его можно добавить в
|
||||
системное хранилище командами из `STARTUP_GUIDE.md`; без этого браузер покажет предупреждение.
|
||||
|
||||
**Учётные данные по умолчанию:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user