баг-фикс 30/34
This commit is contained in:
152
docs/DATABASE.md
152
docs/DATABASE.md
@@ -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(1–7) | День недели 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-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
### Этап разработки
|
||||
|
||||
V2–V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1–V3.
|
||||
Перед добавлением ограничений V3 считает нарушения `CANCEL`, `MOVE`, `REPLACE` и формата.
|
||||
Если найдены legacy-строки, миграция полностью откатывается и сообщает только количества
|
||||
нарушений. Оператор должен исправить бизнес-данные tenant-БД и повторить миграцию;
|
||||
автоматическое удаление или переписывание overrides не выполняется.
|
||||
|
||||
Перед ограничениями V4 отдельно считаются нечётные лимиты лекций, лабораторных и практик,
|
||||
а также группы точных дублей слотов. Любое нарушение останавливает V4 с русским сообщением;
|
||||
ограничения не остаются частично применёнными и legacy-данные автоматически не меняются.
|
||||
|
||||
```bash
|
||||
# После исправления legacy-данных перезапустите backend,
|
||||
# чтобы TenantConfigWatcher повторил Flyway migrate для tenant-БД.
|
||||
docker compose restart backend
|
||||
```
|
||||
По прямому решению владельца проекта все миграции V2–V7 объединены в V1, поскольку
|
||||
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно
|
||||
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в
|
||||
`flyway_schema_history` не поддерживается.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user