баг-фикс 30/34

This commit is contained in:
Zuev
2026-07-19 14:40:43 +03:00
parent 3d798c13e3
commit bc0e1ab1b4
172 changed files with 13431 additions and 2910 deletions

View File

@@ -2,10 +2,10 @@
## Общая информация
- **СУБД:** PostgreSQL (локально `postgres:alpine3.23`, продакшн — managed PostgreSQL)
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine3.20`, продакшн — managed PostgreSQL)
- **Управление схемой:** Flyway (программный запуск)
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
- **Мультитенантность:** Каждый тенант = отдельная БД
---
@@ -59,6 +59,28 @@ erDiagram
TIMESTAMP revoked_at
VARCHAR rotated_to_token_hash
}
auth_login_rate_limits {
BIGSERIAL id PK
VARCHAR tenant UK
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
}
auth_login_attempt_audit {
BIGSERIAL id PK
VARCHAR tenant
VARCHAR username_normalized
VARCHAR client_ip
VARCHAR outcome
TIMESTAMP occurred_at
INTEGER retry_after_seconds
}
education_forms {
BIGSERIAL id PK
@@ -432,7 +454,47 @@ erDiagram
| `user_agent` | VARCHAR(512) | User-Agent клиента |
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie.
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый
refresh-токен отзывается, а клиент получает новый refresh-cookie. Фоновая tenant-aware
очистка удаляет только строки, чьи `expires_at` или `revoked_at` старше настраиваемого срока
audit retention (по умолчанию 30 дней). Индексы `idx_auth_refresh_tokens_cleanup_expires` и
`idx_auth_refresh_tokens_cleanup_revoked` обслуживают ограниченные batch-delete из V1.
#### `auth_login_rate_limits` — Общие счётчики попыток входа
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID состояния rate limit |
| `tenant` | VARCHAR(100) | Тенант запроса |
| `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 | Последнее изменение состояния |
Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля
backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её
`FOR UPDATE`; одна tenant-БД поэтому является общим атомарным хранилищем для всех pod.
Индексы по `blocked_until` и `updated_at` обслуживают проверку и очистку.
#### `auth_login_attempt_audit` — Аудит неудачных входов
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID события |
| `tenant` | VARCHAR(100) | Тенант запроса |
| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса |
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` |
| `occurred_at` | TIMESTAMP | Время события |
| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` |
Таблица принципиально не содержит пароль, его хэш из запроса или признак существования
пользователя. Записи старше настраиваемого срока (по умолчанию 90 дней), а также неактивные
счётчики удаляются tenant-aware задачей ограниченными `SKIP LOCKED` пачками.
### Учебный процесс
@@ -476,11 +538,15 @@ erDiagram
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(200) UNIQUE | Название |
| `name` | VARCHAR(200) NOT NULL | Название |
| `code` | VARCHAR(20) | Код предмета |
| `department_id` | BIGINT FK → departments | Кафедра |
| `description` | TEXT | Описание |
Уникальный функциональный индекс `uq_subjects_name_ci` на `lower(name)` гарантирует
глобальную уникальность названия без учёта регистра и защищает владение дисциплиной при
конкурентном импорте разных кафедр. Индекс входит в единую baseline-миграцию V1.
### Аудиторный фонд
#### `classrooms` — Аудитории
@@ -555,7 +621,13 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод |
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной кафедрой, но позволяет преподавателю иметь несколько открытых неосновных кафедр.
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной
кафедры у преподавателя. Ограничение
`ex_teacher_primary_department_no_overlap` запрещает пересечение любых закрытых или открытых
периодов основной кафедры одного преподавателя. Индекс
`uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной
кафедрой, но позволяет иметь несколько открытых неосновных кафедр. Все эти объекты входят в
единую baseline-миграцию `V1__init.sql`.
#### `teacher_creation_requests` — Заявки кафедр на создание преподавателей
| Колонка | Тип | Описание |
@@ -609,7 +681,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `end_time` | TIME | Время окончания |
| `duration_minutes` | INT | Длительность в минутах |
Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может быть только один слот с номером пары.
Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может
быть только один слот с номером пары. Базовая схема V1 дополнительно требует точного равенства
`duration_minutes` разнице `end_time - start_time` в полных минутах и запрещает
пересекающиеся интервалы одной сетки через GiST exclusion constraint
`ex_time_slots_scope_no_overlap`. Интервалы трактуются как полуоткрытые `[start, end)`,
поэтому соседние пары разрешены.
Триггеры V1 сохраняют связь правил с базовой сеткой: `schedule_rule_slots` принимает только
слот области `DEFAULT`, используемый слот нельзя перенести в небазовую область, а область с
используемыми слотами нельзя сделать небазовой. Блокировки строк слота и области закрывают
гонку между созданием правила и изменением сетки.
#### `time_slot_date_assignments` — Ручные назначения сеток времени
| Колонка | Тип | Описание |
@@ -626,6 +708,10 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `start_date` | DATE | Дата начала |
| `end_date` | DATE | Дата окончания |
V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` для включительных
диапазонов дат. Поэтому два учебных года не могут содержать одну и ту же календарную дату;
следующий год может начаться на следующий день после окончания предыдущего.
#### `semesters` — Семестры
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -635,6 +721,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `start_date` | DATE | Дата начала, от неё считается неделя 1 |
| `end_date` | DATE | Дата окончания |
Пара `(academic_year_id, semester_type)` уникальна. V1 дополнительно запрещает пересечение
включительных диапазонов семестров одного года через `ex_semesters_year_no_overlap`.
Триггеры требуют полного вхождения семестра в границы родительского года и запрещают
сужать учебный год так, чтобы существующий семестр оказался снаружи. Блокировка строки года
в триггере сериализует эти взаимные проверки с конкурентными insert/update.
#### `academic_calendar_activity_types` — Коды активностей графика
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -659,6 +751,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления |
Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год,
специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
остаются строки старших курсов или дисциплины старших семестров.
#### `academic_calendar_days` — Дневная сетка календарного графика
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -670,6 +767,9 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал
`academic_calendars.course_count`, а дата находилась внутри учебного года графика.
#### `academic_calendar_subjects` — Дисциплины календарного графика
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -679,7 +779,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
| `created_at` | TIMESTAMP | Дата создания привязки |
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend по `academic_calendars.course_count * 2`.
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend и триггером `trg_calendar_subjects_dimensions` по `academic_calendars.course_count * 2`.
#### `student_group_calendar_assignments` — Назначения графиков группам
| Колонка | Тип | Описание |
@@ -689,6 +789,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `academic_year_id` | BIGINT FK → academic_years (CASCADE) | Учебный год |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Назначенный график |
Назначение уникально для пары «группа + учебный год». Триггер
`trg_calendar_assignments_compatible` проверяет совпадение года, специальности, профиля и
формы обучения, а также попадание вычисленного курса группы в `1..course_count`. Обратные
триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки
ссылочных строк закрывают конкурентные записи между несколькими backend-pod.
#### `schedule_rules` — Правила расписания
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -709,7 +815,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
Миграция V4 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
Базовая схема V1 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
нули допустимы только как лимиты неиспользуемых типов занятия.
@@ -738,7 +844,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
| `lock_comment` | TEXT | Комментарий к закреплению |
V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
одинаковые день, чётность, базовый временной слот, преподавателя, аудиторию, тип и формат
занятия. Более широкие ресурсные пересечения и семантика подгрупп проверяются сервисом,
поскольку зависят от нескольких таблиц и фактических активных недель.
@@ -767,7 +873,7 @@ V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
| `created_at` | TIMESTAMP | Дата создания |
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
одной и той же пары. Миграция V3 добавляет структурные инварианты:
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
- `CANCEL` не содержит новых ресурсов;
- `MOVE` содержит новый временной слот или аудиторию;
@@ -793,28 +899,14 @@ V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
| `V2__subgroups_active_unique_name.sql` | Уникальность имени среди активных подгрупп одной группы |
| `V3__schedule_override_invariants.sql` | Матрица payload и допустимый формат точечных изменений расписания |
| `V4__schedule_rule_even_hours.sql` | Чётность лимитов академических часов и уникальность точного payload слота правила |
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
### Накатывание на существующих тенантов
### Этап разработки
V2V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1V3.
Перед добавлением ограничений V3 считает нарушения `CANCEL`, `MOVE`, `REPLACE` и формата.
Если найдены legacy-строки, миграция полностью откатывается и сообщает только количества
нарушений. Оператор должен исправить бизнес-данные tenant-БД и повторить миграцию;
автоматическое удаление или переписывание overrides не выполняется.
Перед ограничениями V4 отдельно считаются нечётные лимиты лекций, лабораторных и практик,
а также группы точных дублей слотов. Любое нарушение останавливает V4 с русским сообщением;
ограничения не остаются частично применёнными и legacy-данные автоматически не меняются.
```bash
# После исправления legacy-данных перезапустите backend,
# чтобы TenantConfigWatcher повторил Flyway migrate для tenant-БД.
docker compose restart backend
```
По прямому решению владельца проекта все миграции V2V7 объединены в V1, поскольку
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в
`flyway_schema_history` не поддерживается.
### Полный сброс БД (локально)