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

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

View File

@@ -8,7 +8,7 @@
```json
{
"timestamp": "2026-05-27T19:47:54",
"timestamp": "2026-05-27T16:47:54Z",
"status": 400,
"error": "Некорректный запрос",
"message": "Некорректные параметры запроса",
@@ -18,6 +18,11 @@
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
Все поля момента времени (`createdAt`, `updatedAt`, `reviewedAt`, `archivedAt` и
аналогичные) сериализуются как ISO-8601 UTC с суффиксом `Z`. Поля календарной даты
(`date`, `validFrom`, `validTo`, `activeFrom`, `activeTo`) остаются строками `YYYY-MM-DD`
без часового пояса и вычисляются по бизнес-зоне `Europe/Moscow`.
Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK
возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским
сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не
@@ -360,7 +365,13 @@ Refresh-токен ротируется при каждом успешном о
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`.
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
расписание для неё возвращается пустым списком. Время пары берётся из базового слота
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
`/api/admin/time-slots`.
**Пример:**
```http
@@ -973,7 +984,11 @@ payload обрабатываются один раз, используется
}
```
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
`groupSize`, `yearStartStudy` и все связанные идентификаторы должны быть положительными;
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как
legacy-alias для старых клиентов и исторически содержит ID записи из
`/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже
`0`, если обучение ещё не началось.
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
@@ -1000,6 +1015,10 @@ payload обрабатываются один раз, используется
Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без
изменений.
Если у группы есть активные подгруппы, `groupSize` нельзя уменьшить ниже суммы их
`studentCapacity`. Такой запрос отклоняется без изменения группы. Параллельные изменения
группы и подгрупп сериализуются на backend и проверяются ограничениями PostgreSQL.
### `DELETE /api/groups/{id}`
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
@@ -1012,7 +1031,10 @@ payload обрабатываются один раз, используется
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
`studentCapacity` обязателен и должен быть больше нуля. Для одной учебной группы сумма
численностей активных подгрупп не может превышать численность самой группы. Frontend на
вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или
три подгруппы.
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.

View File

@@ -273,6 +273,12 @@ GiST exclusion constraints и триггеры PostgreSQL, связывающи
ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт
устаревшее назначение.
Создание и обновление группы используют один validator положительных размерностей.
Уменьшение численности дополнительно сверяется с суммой активных подгрупп. CRUD подгрупп и
изменение группы захватывают строку родительской группы `FOR UPDATE`; V1 повторяет проверку
триггерами обеих таблиц. Поэтому конкурентно могут завершиться только совместимые
изменения, а сумма активных `student_capacity` никогда не превышает `group_size`.
`TeacherDepartmentService` является единым источником датированных решений о кафедре
преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты
нагрузки читают `teacher_department_assignments` на целевую дату, не используют
@@ -329,6 +335,8 @@ rollback. Инвалидация кэша зарегистрирована че
lost update или проверке устаревшего снимка.
Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов.
Обе границы диапазона включительны, поэтому общий лимит 120 дат вычисляется как
`endDate - startDate + 1` одинаково в query- и generator-слоях.
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
@@ -345,6 +353,23 @@ Teacher-only чтение строит базовое расписание на
---
## Временная модель
`BusinessTimeService` является единым источником текущей календарной даты и абсолютного
момента. Бизнес-дата вычисляется из инъецируемого `Clock` в зоне
`BUSINESS_TIME_ZONE` (по умолчанию `Europe/Moscow`), поэтому lifecycle, доступность
расписания, назначения кафедр и создание периодов не зависят от timezone JVM или хоста.
Абсолютные моменты представлены `Instant`, хранятся в PostgreSQL как `TIMESTAMPTZ` и
сериализуются в UTC. Hibernate принудительно использует `hibernate.jdbc.time_zone=UTC`,
Jackson — UTC. Отдельные календарные даты представлены `LocalDate`/SQL `DATE`; для них
смещение и время суток не передаются.
В production-конструкторах используется системный UTC clock. Тесты передают фиксированный
`Clock`, включая границу московской полуночи, поэтому переход бизнес-даты воспроизводим.
---
## Аутентификация
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:

View File

@@ -66,9 +66,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Учебные группы (Student Groups)
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
- **Поля:** Название, положительная численность, форма обучения, кафедра, специальность, профиль обучения, положительный год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Численность каждой подгруппы обязательна и положительна, а сумма численностей активных подгрупп не может превышать численность группы. Численность группы нельзя уменьшить ниже этой суммы. Изменения группы и подгрупп сериализуются блокировкой родительской группы и дополнительно защищены триггерами V1, поэтому параллельные запросы не нарушают инвариант. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
@@ -193,6 +193,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат
разрешены, 121 дата отклоняется до генерации.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию

View File

@@ -2,11 +2,13 @@
## Общая информация
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine3.20`, продакшн — managed PostgreSQL)
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
- **Управление схемой:** Flyway (программный запуск)
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
- **Мультитенантность:** Каждый тенант = отдельная БД
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
---
@@ -44,9 +46,9 @@ erDiagram
VARCHAR status
DATE active_from
DATE active_to
TIMESTAMP archived_at
TIMESTAMP created_at
TIMESTAMP updated_at
TIMESTAMPTZ archived_at
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
auth_refresh_tokens {
@@ -54,9 +56,9 @@ erDiagram
BIGINT user_id FK
VARCHAR tenant
VARCHAR token_hash UK
TIMESTAMP issued_at
TIMESTAMP expires_at
TIMESTAMP revoked_at
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at
TIMESTAMPTZ revoked_at
VARCHAR rotated_to_token_hash
}
@@ -66,10 +68,10 @@ erDiagram
VARCHAR username_normalized UK
VARCHAR client_ip UK
INTEGER failure_count
TIMESTAMP window_started_at
TIMESTAMP last_failure_at
TIMESTAMP blocked_until
TIMESTAMP updated_at
TIMESTAMPTZ window_started_at
TIMESTAMPTZ last_failure_at
TIMESTAMPTZ blocked_until
TIMESTAMPTZ updated_at
}
auth_login_attempt_audit {
@@ -78,7 +80,7 @@ erDiagram
VARCHAR username_normalized
VARCHAR client_ip
VARCHAR outcome
TIMESTAMP occurred_at
TIMESTAMPTZ occurred_at
INTEGER retry_after_seconds
}
@@ -86,7 +88,7 @@ erDiagram
BIGSERIAL id PK
VARCHAR name UK
TEXT description
TIMESTAMP created_at
TIMESTAMPTZ created_at
}
student_groups {
@@ -98,7 +100,7 @@ erDiagram
BIGINT specialty_id FK
BIGINT specialty_profile_id FK
BIGINT year_start_study
TIMESTAMP created_at
TIMESTAMPTZ created_at
}
subgroups {
@@ -114,7 +116,7 @@ erDiagram
VARCHAR code
BIGINT department_id FK
TEXT description
TIMESTAMP created_at
TIMESTAMPTZ created_at
}
lesson_types {
@@ -141,9 +143,9 @@ erDiagram
VARCHAR status
DATE active_from
DATE active_to
TIMESTAMP archived_at
TIMESTAMPTZ archived_at
TEXT description
TIMESTAMP created_at
TIMESTAMPTZ created_at
}
classroom_equipments {
@@ -180,7 +182,7 @@ erDiagram
VARCHAR status
BIGINT requested_by FK
BIGINT reviewed_by FK
TIMESTAMP reviewed_at
TIMESTAMPTZ reviewed_at
TEXT review_comment
BIGINT created_teacher_id FK
}
@@ -190,7 +192,7 @@ erDiagram
BIGINT subject_id FK
BIGINT author_id FK
TEXT comment
TIMESTAMP created_at
TIMESTAMPTZ created_at
}
teacher_lesson_types {
@@ -256,8 +258,8 @@ erDiagram
BIGINT specialty_profile_id FK
BIGINT study_form_id FK
INT course_count
TIMESTAMP created_at
TIMESTAMP updated_at
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
academic_calendar_days {
@@ -275,7 +277,7 @@ erDiagram
BIGINT calendar_id FK
INT semester_number
BIGINT subject_id FK
TIMESTAMP created_at
TIMESTAMPTZ created_at
}
student_group_calendar_assignments {
@@ -433,10 +435,10 @@ erDiagram
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти |
| `active_from` | DATE | Дата начала действия записи |
| `active_to` | DATE | Дата окончания действия записи |
| `archived_at` | TIMESTAMP | Когда пользователь архивирован |
| `archived_at` | TIMESTAMPTZ | Когда пользователь архивирован |
| `archive_reason` | TEXT | Причина архивирования |
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
| `created_at` | TIMESTAMPTZ | Дата создания |
| `updated_at` | TIMESTAMPTZ | Дата обновления (авто-триггер) |
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
@@ -447,9 +449,9 @@ erDiagram
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
| `issued_at` | TIMESTAMP | Дата выдачи |
| `expires_at` | TIMESTAMP | Дата истечения |
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
| `issued_at` | TIMESTAMPTZ | Дата выдачи |
| `expires_at` | TIMESTAMPTZ | Дата истечения |
| `revoked_at` | TIMESTAMPTZ | Дата отзыва, `NULL` для активной сессии |
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
| `user_agent` | VARCHAR(512) | User-Agent клиента |
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
@@ -469,11 +471,11 @@ audit retention (по умолчанию 30 дней). Индексы `idx_auth_
| `username_normalized` | VARCHAR(100) | NFKC-нормализованное имя в нижнем регистре |
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
| `failure_count` | INTEGER | Число отказов в текущем окне, не меньше нуля |
| `window_started_at` | TIMESTAMP | Начало окна учёта попыток |
| `last_failure_at` | TIMESTAMP | Время последнего отказа |
| `blocked_until` | TIMESTAMP | Окончание временной блокировки либо `NULL` |
| `created_at` | TIMESTAMP | Время создания состояния |
| `updated_at` | TIMESTAMP | Последнее изменение состояния |
| `window_started_at` | TIMESTAMPTZ | Начало окна учёта попыток |
| `last_failure_at` | TIMESTAMPTZ | Время последнего отказа |
| `blocked_until` | TIMESTAMPTZ | Окончание временной блокировки либо `NULL` |
| `created_at` | TIMESTAMPTZ | Время создания состояния |
| `updated_at` | TIMESTAMPTZ | Последнее изменение состояния |
Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля
backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её
@@ -489,7 +491,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса |
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` |
| `occurred_at` | TIMESTAMP | Время события |
| `occurred_at` | TIMESTAMPTZ | Время события |
| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` |
Таблица принципиально не содержит пароль, его хэш из запроса или признак существования
@@ -512,16 +514,16 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное |
| `group_size` | BIGINT | Количество студентов |
| `group_size` | BIGINT CHECK (> 0) | Количество студентов |
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
| `department_id` | BIGINT FK → departments | Кафедра |
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
| `year_start_study` | BIGINT CHECK (> 0) | Год начала обучения, используется для вычисления текущего курса |
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
| `active_from` | DATE | Дата начала действия группы |
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
| `archived_at` | TIMESTAMP | Дата и время архивирования |
| `archived_at` | TIMESTAMPTZ | Дата и время архивирования |
| `archive_reason` | TEXT | Причина архивирования |
#### `subgroups` — Подгруппы
@@ -530,9 +532,18 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
| `id` | BIGSERIAL PK | ID |
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
| `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов |
| `student_capacity` | INT NOT NULL CHECK (> 0) | Количество студентов |
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах
могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное
создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
CHECK-ограничения требуют положительные `student_groups.group_size`,
`student_groups.year_start_study` и `subgroups.student_capacity`. Триггеры
`validate_student_group_subgroup_capacity` и
`validate_subgroup_student_capacity` блокируют родительскую группу и не допускают,
чтобы сумма численностей активных подгрупп превышала `group_size`. Инвариант действует и
при прямой записи в БД, смене статуса/родительской группы и конкурентных транзакциях.
#### `subjects` — Дисциплины
| Колонка | Тип | Описание |
@@ -561,7 +572,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
| `active_from` | DATE | Дата начала действия записи |
| `active_to` | DATE | Дата окончания действия записи |
| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации |
| `archived_at` | TIMESTAMPTZ | Когда аудитория выведена из эксплуатации |
| `archive_reason` | TEXT | Причина архивирования |
| `description` | TEXT | Описание |
@@ -618,7 +629,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры |
| `is_primary` | BOOLEAN | Основная кафедра преподавателя |
| `comment` | TEXT | Комментарий к переводу |
| `created_at` | TIMESTAMP | Дата создания записи |
| `created_at` | TIMESTAMPTZ | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод |
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной
@@ -641,11 +652,11 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
| `reviewed_at` | TIMESTAMPTZ | Дата рассмотрения |
| `review_comment` | TEXT | Комментарий администратора |
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
| `created_at` | TIMESTAMP | Дата создания заявки |
| `updated_at` | TIMESTAMP | Дата последнего изменения |
| `created_at` | TIMESTAMPTZ | Дата создания заявки |
| `updated_at` | TIMESTAMPTZ | Дата последнего изменения |
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
@@ -656,7 +667,7 @@ backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHIN
| `subject_id` | BIGINT FK → subjects | Дисциплина |
| `author_id` | BIGINT FK → users | Автор комментария |
| `comment` | TEXT | Текст комментария |
| `created_at` | TIMESTAMP | Дата создания |
| `created_at` | TIMESTAMPTZ | Дата создания |
#### `time_slot_scopes` — Сетки времени
| Колонка | Тип | Описание |
@@ -748,8 +759,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
| `course_count` | INT CHECK(18) | Количество курсов в сетке |
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления |
| `created_at` | TIMESTAMPTZ | Дата создания |
| `updated_at` | TIMESTAMPTZ | Дата обновления |
Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год,
специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная
@@ -777,7 +788,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
| `created_at` | TIMESTAMP | Дата создания привязки |
| `created_at` | TIMESTAMPTZ | Дата создания привязки |
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend и триггером `trg_calendar_subjects_dimensions` по `academic_calendars.course_count * 2`.
@@ -841,7 +852,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом |
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
| `locked_at` | TIMESTAMPTZ | Когда выполнено закрепление |
| `lock_comment` | TEXT | Комментарий к закреплению |
V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
@@ -870,7 +881,7 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
| `new_lesson_format` | VARCHAR(30) | Новый формат занятия |
| `comment` | TEXT | Причина изменения |
| `created_by` | BIGINT FK → users | Автор изменения |
| `created_at` | TIMESTAMP | Дата создания |
| `created_at` | TIMESTAMPTZ | Дата создания |
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:

View File

@@ -23,6 +23,21 @@ docker compose logs -f
Приложение доступно: **http://localhost:80**
### Обновление контейнерных артефактов
Базовые и служебные образы указываются как `точный-tag@sha256:manifest-digest`; сторонние
Actions — полным 40-символьным commit SHA. При обновлении версии одновременно обновите tag,
digest и комментарий версии, затем выполните:
```bash
bash scripts/test-artifact-pinning.sh
K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh
```
Для скачиваемого исполняемого файла обязательны точная версия и проверка опубликованного
SHA-256 до установки. Отключать SBOM, provenance или блокирующее HIGH/CRITICAL-сканирование
ради прохождения pipeline запрещено.
### Пересборка после изменений
```bash
@@ -220,6 +235,18 @@ public class AbsenceController {
---
## Работа со временем
- Для абсолютного момента используйте `Instant`; в БД ему соответствует `TIMESTAMPTZ`.
- Для календарной даты без времени используйте `LocalDate`; текущую бизнес-дату получайте
через инъекцию `BusinessTimeService`, а не через `LocalDate.now()`.
- Новую бизнес-логику, зависящую от текущего времени, проверяйте фиксированным `Clock` на
границе московской полуночи.
- Во frontend не формируйте date-only через `Date.toISOString().slice(0, 10)`:
используйте `formatLocalDate()` или исходную строку `YYYY-MM-DD`.
---
## Работа с миграциями Flyway
### Правила

View File

@@ -159,6 +159,10 @@ frontend/
### Особенности админских вкладок
- Общий `formatLocalDate()` формирует `YYYY-MM-DD` из локальных компонентов `Date`, не
используя `toISOString()`. Кабинет кафедры и редактор академического календаря также
выполняют календарную арифметику локальными компонентами, поэтому даты первых часов
суток и границы месяца не сдвигаются из-за преобразования в UTC.
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся.
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.

View File

@@ -7,16 +7,29 @@
```yaml
services:
backend: # Spring Boot (Java 17), внутренний порт 8080
frontend: # Apache httpd, публикует HTTP_PORT (по умолчанию 80)
frontend: # Apache httpd, внутренний порт 80
db: # PostgreSQL 16.3, внутренний порт 5432
```
### Сеть
Compose сам создаёт изолированную bridge-сеть `magistr`. Внешняя сеть или отдельно
установленный reverse proxy для локального запуска не нужны. Apache раздаёт frontend и
проксирует same-origin пути `/api` и `/actuator/health` в backend; backend и PostgreSQL не
публикуют порты на хосте.
Compose сам создаёт изолированную bridge-сеть `magistr` для приложения и подключает backend
с frontend к существующей внешней Docker-сети `proxy`. Caddy из `../сaddy-proxy/` также
подключён к `proxy`: на `localhost` он отправляет `/api/*` непосредственно в `backend:8080`,
а остальные пути — в `frontend:80`. Контейнеры Magistr не публикуют порты на хосте;
PostgreSQL доступен только во внутренней сети `magistr`.
Перед первым запуском создайте общую сеть и поднимите Caddy:
```bash
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker compose -f ../сaddy-proxy/compose.yaml up -d
docker compose up -d --build
```
Для `https://localhost` Caddy выпускает сертификат своим локальным CA. На CachyOS/Arch
корневой сертификат устанавливается в системное хранилище через `trust anchor`; точная
команда приведена в `STARTUP_GUIDE.md`.
Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.
@@ -39,8 +52,9 @@ LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_RETENTION=90d
TRUSTED_PROXY_CIDRS=172.16.0.0/12
BUSINESS_TIME_ZONE=Europe/Moscow
TZ=Europe/Moscow
OTEL_SDK_DISABLED=true
HTTP_PORT=80
```
Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
@@ -51,10 +65,15 @@ HTTP_PORT=80
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует.
`BUSINESS_TIME_ZONE` задаёт правила календарных бизнес-дат, а `TZ` и
`JAVA_TOOL_OPTIONS=-Duser.timezone=...` фиксируют timezone JVM и контейнера. Абсолютные
timestamps при этом всегда передаются между Java и PostgreSQL в UTC.
Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён;
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.
В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Kubernetes `app-config` также явно задаёт `BUSINESS_TIME_ZONE=Europe/Moscow`,
`TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow` для backend pod.
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
@@ -64,10 +83,10 @@ reverse proxy: заголовок `X-Forwarded-For` от остальных ис
### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3.9-eclipse-temurin-17``mvn package`
1. Этап сборки: `maven:3.9.9-eclipse-temurin-17@sha256:f58d59b...``mvn package`
2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и
проверяется по закреплённому SHA-256
3. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar`
3. Этап запуска: `eclipse-temurin:17-jre-alpine@sha256:02320dd4...``java -jar app.jar`
### Dockerfile (Frontend)
@@ -82,7 +101,8 @@ COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
```
Оба базовых образа зафиксированы tag и manifest digest. Первый этап собирает зафиксированный
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
@@ -340,16 +360,26 @@ Deployment-файлов запрещено.
Расположение: `.gitea/workflows/docker-build.yaml`
Основные шаги:
1. Backend unit/component tests, frontend static/unit tests, Compose validation и shell-тест
immutable rollout/rollback.
1. Backend unit/component tests, frontend static/unit tests, Compose validation, shell-тест
immutable rollout/rollback и регрессионная проверка закрепления артефактов.
2. Только после успешных gates — login, параллельная сборка и push backend/frontend с
SHA-tag и release tag без `latest`.
3. Build jobs публикуют digest обоих образов; deploy job устанавливает фиксированный
`kubectl v1.33.12` после SHA-256 проверки.
4. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
SHA-tag и release tag без `latest`. Сторонние Actions закреплены полными commit SHA.
3. BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build
jobs также возвращают registry digest каждого образа.
4. Отдельный обязательный job сканирует опубликованные digests закреплённым Trivy `0.63.0` и
блокирует deploy при исправимых `HIGH`/`CRITICAL` уязвимостях.
5. Deploy job устанавливает фиксированный `kubectl v1.33.12` только после SHA-256 проверки.
Java Agent также имеет точную версию и checksum.
6. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба
предыдущих образа и повторно проверяет их готовность.
5. Workflow-wide concurrency lock не допускает одновременные production deployment.
7. Workflow-wide concurrency lock не допускает одновременные production deployment.
`scripts/check-artifact-pinning.sh` проверяет Dockerfile, Compose, Actions, checksum и CI
gates. При передаче `K8S_DIR=../k8s` он дополнительно требует digest у каждого production
образа Kubernetes. Внешний `otel-collector.yaml` использует официальный Collector Contrib
`0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`;
версия и manifest digest обновляются только вместе и проверяются до rollout.
---

View File

@@ -29,6 +29,7 @@
- Docker и Docker Compose
- Git
- запущенный Caddy из `../сaddy-proxy/`
### Локальный запуск
@@ -41,13 +42,21 @@ cp .env.example .env
# Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET.
# JWT_SECRET можно сгенерировать командой: openssl rand -base64 48
# 3. Запустить все сервисы
# 3. Один раз создать общую proxy-сеть и запустить Caddy
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker compose -f ../сaddy-proxy/compose.yaml up -d
# 4. Запустить все сервисы Magistr
docker compose up -d --build
```
Compose сам создаёт внутреннюю сеть и именованный том PostgreSQL. После запуска приложение
доступно по адресу **http://localhost:80**; если в `.env` задан другой `HTTP_PORT`, используйте
его. Backend и PostgreSQL наружу не публикуются: `/api` проксируется frontend-контейнером.
Compose создаёт внутреннюю сеть и именованный том PostgreSQL, а backend/frontend подключает
к общей внешней сети `proxy`. Они не публикуют порты на хосте: Caddy принимает запросы на
**https://localhost** (`http://localhost` перенаправляется на HTTPS), `/api` отправляет в
backend, остальные пути — во frontend. PostgreSQL остаётся только во внутренней сети.
Caddy использует локальный корневой сертификат. В CachyOS/Arch его можно добавить в
системное хранилище командами из `STARTUP_GUIDE.md`; без этого браузер покажет предупреждение.
**Учётные данные по умолчанию:**