баг-фикс завершён
This commit is contained in:
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 добавляет структурные инварианты:
|
||||
|
||||
Reference in New Issue
Block a user