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

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

@@ -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 добавляет структурные инварианты: