баг-фикс 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

@@ -3,7 +3,8 @@
Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны
под `/actuator/health/`. Ответы возвращаются в формате JSON.
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404`,
`409` и `500` используется общий JSON-формат:
```json
{
@@ -17,6 +18,11 @@
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK
возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским
сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не
передаются; неизвестное нарушение получает обобщённое сообщение.
---
## Служебные проверки состояния
@@ -108,7 +114,36 @@
}
```
Для несуществующего пользователя, неверного пароля и архивной учётной записи возвращается
одинаковый ответ `401`, поэтому endpoint не раскрывает наличие или состояние пользователя.
**Временная блокировка (429):**
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
```
```json
{
"success": false,
"message": "Слишком много попыток входа. Повторите позже",
"token": null,
"role": null,
"redirect": null,
"departmentId": null,
"userId": null
}
```
Backend ведёт общий для всех pod счётчик по tenant, нормализованному `username` и IP.
После пяти отказов по умолчанию комбинация блокируется на 60 секунд; следующие отказы
после окончания блокировки увеличивают задержку до максимума 15 минут. `Retry-After`
содержит оставшееся целое число секунд. Успешный вход сбрасывает счётчик.
> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
> Штатный web-клиент хранит access JWT только в памяти страницы; после reload он получает
> новый access JWT через `HttpOnly` refresh-cookie и `POST /api/auth/refresh`.
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
@@ -198,7 +233,9 @@ Refresh-токен ротируется при каждом успешном о
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
Выборка использует записи `teacher_department_assignments`, действующие на текущую дату.
Дополнительная связь (`is_primary=false`) также включает преподавателя в список кафедры.
Роль `DEPARTMENT` может запрашивать только свою кафедру.
### `POST /api/users`
@@ -247,9 +284,16 @@ Refresh-токен ротируется при каждом успешном о
}
```
Если `validFrom` находится в будущем, текущая основная кафедра остаётся действующей до дня,
предшествующего переводу. Поле совместимости `users.department_id` переключается только
когда новая основная запись уже действует на текущую дату. Пересекающиеся периоды двух
основных кафедр одного преподавателя отклоняются с `409 Conflict`.
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
Список преподавателей кафедры на конкретную дату по таблице истории назначений. Архивный
преподаватель входит в исторический ответ, если на указанную дату действовали и пользователь,
и его назначение. Роль `DEPARTMENT` может запрашивать только свою кафедру.
---
@@ -294,7 +338,7 @@ Refresh-токен ротируется при каждом успешном о
- `DEPARTMENT` не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API;
- `DEPARTMENT` создаёт и комментирует дисциплины через `/api/department/*`, где кафедра берётся из текущего пользователя;
- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только преподавателей и дисциплины своей кафедры;
- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только дисциплины своей кафедры и преподавателей с основной или дополнительной связью с ней на текущую дату;
- `SCHEDULE_VIEWER` имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности.
---
@@ -386,11 +430,18 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
"orderNumber": 1,
"scopeId": 1,
"startTime": "08:00:00",
"endTime": "09:30:00",
"durationMinutes": 90
"endTime": "09:30:00"
}
```
`durationMinutes` в запросе не требуется и не считается доверенным значением: backend
всегда вычисляет длительность как разницу `endTime - startTime` и возвращает результат в
ответе. Начало должно быть раньше окончания, а длительность — не меньше одной минуты.
Внутри одной сетки запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы;
соседние слоты, у которых окончание первого совпадает с началом второго, разрешены.
Конфликт номера или интервала возвращает `409 Conflict`. Слот, на который уже ссылается
правило расписания, нельзя перенести из базовой сетки `DEFAULT`.
**Создание ручной сетки:**
```json
{
@@ -424,6 +475,31 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| `PUT` | `/api/admin/calendar/activity-types/{id}` | Обновить код активности |
| `DELETE` | `/api/admin/calendar/activity-types/{id}` | Удалить код активности |
**Тело учебного года:**
```json
{
"title": "2026-2027",
"startDate": "2026-09-01",
"endDate": "2027-06-30"
}
```
**Тело семестра:**
```json
{
"semesterType": "autumn",
"startDate": "2026-09-01",
"endDate": "2027-01-31"
}
```
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
период разрешено начать на следующий календарный день после окончания предыдущего.
Повторное название года, повторный тип семестра или пересечение возвращаются как
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
**Тело создания/обновления кода активности:**
```json
{
@@ -465,6 +541,13 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
`courseCount` должен быть в диапазоне `1..8`.
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
`409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить
`courseCount`, пока существуют строки старших курсов или дисциплины семестров выше
`courseCount * 2`.
**Ячейка сетки графика:**
```json
{
@@ -632,6 +715,10 @@ CRUD доступен по:
разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с
просьбой уточнить группу или кафедру.
Ограничение относится только к интерактивному `/api/schedule/search`. Endpoints
`/api/workload/*` используют отдельный агрегирующий путь и обрабатывают все активные группы
разрешённого кафедрального scope, в том числе при количестве больше 50.
Пример:
```http
@@ -691,6 +778,21 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
Общие параметры для отчётов: `startDate`, `endDate`, опционально `departmentId`.
Роли `ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER` могут не передавать `departmentId`
для глобального отчёта или выбрать конкретную кафедру. Для роли `DEPARTMENT` backend всегда
использует кафедру из access JWT: отсутствие параметра означает свою кафедру, а попытка
передать чужой `departmentId` возвращает `403 Forbidden`. То же ограничение применяется к
`GET /api/workload/free-classrooms`, хотя этот endpoint не принимает `departmentId`.
Кафедра каждой записи определяется по `teacher_department_assignments` на дату занятия:
сначала используется основное, затем дополнительное назначение. Поэтому период, включающий
дату перевода, разделяет нагрузку одного преподавателя между прежней и новой кафедрами,
а историческая нагрузка не зависит от текущего значения `users.department_id`.
Workload и поиск свободных аудиторий строятся через специализированный агрегирующий метод
`ScheduleQueryService`: он сохраняет проверку диапазона, применение overrides, фильтр пары и
дедупликацию, но не наследует UI-лимит 50 групп из `/api/schedule/search`.
Пример:
```http
@@ -727,7 +829,15 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя |
| `GET` | `/api/department/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
`POST /api/department/subjects/import` нормализует пробелы по краям и сравнивает названия
без учёта регистра. Повторный импорт дисциплины своей кафедры обновляет код и восстанавливает
архивную запись, не создавая новый ID. Если такое название уже принадлежит другой кафедре,
API возвращает `409 Conflict`; владелец записи не изменяется. Повторы одного названия внутри
payload обрабатываются один раз, используется последнее значение.
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры только по
`teacher_department_assignments`. Основные и дополнительные назначения учитываются на
текущую дату; архивные и ещё не начавшиеся назначения исключаются.
`POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`.
@@ -793,6 +903,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
При создании специальности автоматически создаётся профиль `Без профиля`.
`EDUCATION_OFFICE` имеет read-only доступ к `GET /api/specialties`,
`GET /api/specialties/profiles` и `GET /api/specialties/{id}/profiles`, необходимый для
инициализации календарных учебных графиков. Создание, изменение, архивирование специальностей
и CRUD профилей остаются доступны только `ADMIN`.
**Тело создания/обновления профиля:**
```json
{
@@ -880,6 +995,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
Если у группы уже есть календарные назначения, изменение года начала обучения,
специальности, профиля или формы обучения повторно проверяется для каждого учебного года.
Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без
изменений.
### `DELETE /api/groups/{id}`
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
@@ -963,7 +1083,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и
форме обучения, что и группа. Вычисленный курс группы должен находиться в диапазоне
`1..courseCount`. Сохранение выполняется транзакционно с блокировкой группы, графика,
учебного года и существующего назначения; конкурентный конфликт возвращает `409 Conflict`.
---
@@ -1074,6 +1197,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
## Формы обучения
`GET /api/education-forms` доступен `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT` и
`SCHEDULE_VIEWER`. Создание `POST /api/education-forms` и удаление
`DELETE /api/education-forms/{id}` разрешены `ADMIN` и `EDUCATION_OFFICE`; удаление по-прежнему
отклоняется, если форма используется группой или календарным графиком.
### `GET /api/education-forms`
Список форм обучения.
@@ -1125,6 +1253,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
Для роли `DEPARTMENT` дисциплина должна принадлежать кафедре текущего пользователя, а у
преподавателя на текущую дату должна действовать основная или дополнительная связь с этой
кафедрой.
### `DELETE /api/teacher-subjects`
```json

View File

@@ -5,7 +5,7 @@
```mermaid
graph TD
Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"]
Caddy -->|:80| Frontend["Frontend<br/>(Apache httpd:alpine)"]
Caddy -->|:80| Frontend["Frontend<br/>(Apache httpd + строгий CSP)"]
Caddy -->|/api/*| Backend["Backend<br/>(Spring Boot 3.2.5)"]
Backend --> TenantRouter{"TenantRoutingDataSource"}
@@ -19,12 +19,13 @@ graph TD
## Компоненты
### Frontend (Apache httpd:alpine)
### Frontend (Apache httpd)
- **Тип:** Статические файлы (HTML/CSS/JS)
- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server
- **Контейнер:** multi-stage Node/esbuild → Apache HTTP Server на Alpine
- **Порт:** 80
- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/`
- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`)
- **Browser security:** same-origin runtime-ресурсы и CSP без `unsafe-inline`/`unsafe-eval`
### Backend (Spring Boot 3.2.5)
- **Тип:** REST API сервер
@@ -164,6 +165,11 @@ sequenceDiagram
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
ограничения компенсации по `resourceVersion`.
Пользовательские ошибки tenant-контура и production-сообщения журнала формулируются на
русском языке. JDBC/Flyway-текст не возвращается в DOM или HTTP-ответ; на уровнях
`WARN`/`ERROR` фиксируется безопасный тип исключения, а стек доступен только в русскоязычной
записи уровня `DEBUG`.
Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса.
Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт
заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный
@@ -241,11 +247,62 @@ Tenant interceptor исключает `/actuator/**`, а authorization intercept
## Транзакционные изменения расписания
`GlobalExceptionHandler` является последней границей между ошибками persistence-слоя и
HTTP-клиентом. `DataIntegrityViolationException` сопоставляется с известными ограничениями
V1 и безопасными статусами `400`/`409`; неизвестные SQLState и constraints получают
обобщённый `409`. Constraint и SQLState доступны только в структурированном журнале, а
JDBC/SQL-текст и stack trace не включаются в пользовательский ответ. Контроллеры не должны
формировать HTTP-body из `Exception.getMessage()`.
`ScheduleRuleAdminController` является тонким HTTP-адаптером. Чтение, создание, изменение
и архивация правил выполняются через `ScheduleRuleService`; публичные write-методы сервиса
образуют транзакционные границы, поэтому ошибки валидации и конфликты выходят за Spring
proxy и приводят к rollback.
`AcademicCalendarAdminController` делегирует CRUD учебных годов и семестров
`AcademicPeriodService`. Сервис нормализует названия, проверяет включительные диапазоны,
дубли и пересечения до мутации. Semester create/update блокируют родительский учебный год,
update семестра затем перечитывает собственную строку с `PESSIMISTIC_WRITE`. Кэш расписания
очищается только после commit. Межподовые гонки годов и семестров окончательно закрывают
GiST exclusion constraints и триггеры PostgreSQL, связывающие границы семестра с годом.
`AcademicCalendarController` и `GroupController` делегируют изменение ключевых измерений и
сохранение назначений `AcademicStructureService`. Сервис блокирует изменяемую группу или
график, повторно проверяет назначения, курсы, сетку и дисциплины до мутации, а кэш очищает
только после commit. Триггеры V1 дублируют совместимость на уровне PostgreSQL и блокируют
ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт
устаревшее назначение.
`TeacherDepartmentService` является единым источником датированных решений о кафедре
преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты
нагрузки читают `teacher_department_assignments` на целевую дату, не используют
`users.department_id` как fallback и учитывают дополнительные назначения. При построении
нагрузки назначения загружаются одним batch-запросом на весь диапазон, а кафедра разрешается
для даты каждого занятия, поэтому перевод внутри периода корректно разделяет агрегаты.
Перевод блокирует пользователя и историю его основных назначений. Будущий перевод закрывает
текущий период днём перед датой вступления в силу, но не меняет legacy-зеркало текущей
кафедры заранее. GiST exclusion constraint в V1 окончательно запрещает пересекающиеся
основные периоды при гонке нескольких backend-pod.
`WorkloadController` до обращения к `ScheduleQueryService` вычисляет разрешённый scope из
`AuthContext`. Для `DEPARTMENT` обязательна кафедра из подписанного JWT; отсутствие
параметра не расширяет выборку, а несовпадающий `departmentId` отклоняется с `403`. Это же
правило применяется к свободным аудиториям. Глобальный или явно выбранный scope остаётся у
`ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`.
После проверки scope контроллер вызывает специализированный
`ScheduleQueryService.searchForAggregation()`. Этот путь загружает все активные группы
scope, генерирует их расписание и применяет единый снимок overrides без интерактивного
лимита 50 групп. Публичный `search()` по-прежнему применяет лимит к широкому UI-запросу;
общие range validation, фильтрация и дедупликация находятся в одном внутреннем алгоритме.
Импорт дисциплин из `DepartmentWorkspaceController` делегирован транзакционному
`SubjectImportService`. Сервис сначала нормализует и дедуплицирует весь payload, затем до
мутации проверяет глобального владельца названия. Собственная запись обновляется на месте и
при необходимости восстанавливается; чужая приводит к `409`. Case-insensitive уникальный
индекс V1 окончательно закрывает конкурентное создание одинакового названия.
Создание правила до остальных запросов к БД захватывает `PESSIMISTIC_WRITE` на строке
целевого семестра. Update сначала блокирует строку правила, затем старый и новый семестры в
порядке ID; архивация блокирует правило и его семестр. После блокировок сервис заново
@@ -271,6 +328,14 @@ rollback. Инвалидация кэша зарегистрирована че
строка перечитывается с `PESSIMISTIC_WRITE`, поэтому параллельное изменение не приводит к
lost update или проверке устаревшего снимка.
Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов.
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup
по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время
одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует.
Teacher-only чтение строит базовое расписание напрямую через
`ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно
находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой
@@ -287,17 +352,51 @@ Teacher-only чтение строит базовое расписание на
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
2. Backend проверяет пароль через `BCryptPasswordEncoder`.
3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `HttpOnly` refresh-cookie.
4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш.
4. Access JWT и профиль клиента хранятся только в памяти страницы; refresh-токен хранится
только в cookie, а в БД сохраняется SHA-256 хэш.
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
`LoginRateLimitService` выполняет проверку пароля и изменение счётчика в одной транзакции.
Строка `auth_login_rate_limits` с ключом tenant + нормализованный username + IP блокируется
через `PESSIMISTIC_WRITE`, поэтому параллельные попытки на разных backend-pod сериализуются
общей tenant-БД. После порога применяется прогрессивная временная блокировка, API отвечает
`429` и передаёт `Retry-After`. Неизвестная, архивная и ошибочная учётная запись проходят
одинаковый bcrypt-путь и получают одинаковый `401`, что не позволяет определить наличие
пользователя по ответу. Отказы сохраняются в `auth_login_attempt_audit` и пишутся в журнал
только с коротким fingerprint имени; пароль не сохраняется и не логируется.
`ClientIpResolver` принимает `X-Forwarded-For` только если непосредственный источник входит
в `TRUSTED_PROXY_CIDRS`. Цепочка разбирается справа налево до первого недоверенного адреса;
заголовок от прямого клиента или некорректная цепочка игнорируются. Для Compose доверена
только внутренняя Docker-сеть, а production ConfigMap задаёт pod-сеть Traefik. При изменении
Docker/K3s CIDR это значение необходимо синхронно заменить фактической сетью proxy.
`LoginAttemptAuditCleanupJob` раз в сутки удаляет старше 90 дней audit-записи и неактивные
счётчики ограниченными пачками отдельно в каждой tenant-БД. `FOR UPDATE SKIP LOCKED`
позволяет нескольким pod безопасно выполнять очистку одновременно; активная блокировка
никогда не удаляется.
Ротация refresh-токена имеет single-use семантику. `RefreshTokenService.rotate()` выполняется
в транзакции, а `AuthRefreshTokenRepository` захватывает исходную строку через
`PESSIMISTIC_WRITE`. Поэтому два одновременных запроса с одним cookie сериализуются:
только первый создаёт следующий refresh-токен, второй видит уже отозванную строку и
завершается без выпуска новой сессии.
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
`RefreshTokenCleanupJob` раз в час берёт атомарный снимок активных тенантов из
`TenantRoutingDataSource`, устанавливает `TenantContext` до открытия транзакции и очищает
каждую tenant-БД ограниченными пачками. По умолчанию истёкшие и отозванные строки хранятся
30 дней для аудита; активные и более свежие строки не удаляются. PostgreSQL
`FOR UPDATE SKIP LOCKED` позволяет нескольким backend-pod разбирать непересекающиеся пачки,
а повторный проход идемпотентен. Внутри одного pod наложение запусков запрещено локальным
guard, а число пачек одного прохода ограничено.
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`,
`exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение
`tenant` с `TenantContext`, затем применяет `@RequireRoles`. После перехода или reload
frontend восстанавливает access JWT и профиль в памяти через refresh-cookie; Web Storage
для данных авторизации не используется. Backend возвращает `401`, если токен отсутствует
или некорректен, и `403`, если роли недостаточно.
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
@@ -305,6 +404,12 @@ Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `de
или публичный endpoint не может получить пользователя от предыдущего запроса того же
потока контейнера.
Frontend-матрица вкладок централизована в `admin/js/role-capabilities.js` и используется
основным admin SPA и settings SPA. Для `EDUCATION_OFFICE` backend разрешает read-only GET
специальностей/профилей и GET/POST/DELETE форм обучения; write-операции специальностей
наследуют class-level `ADMIN`. MockMvc role-тест проходит через реальный
`AuthorizationInterceptor`, поэтому видимые штатные экраны не зависят только от скрытия UI.
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
JWT-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле

View File

@@ -7,7 +7,7 @@
| Роль | Enum | Возможности |
|------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, учебные периоды и календарные графики, временные слоты, формы обучения, аудитории и загруженность; read-only справочники специальностей и профилей. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
@@ -27,9 +27,17 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя;
- привязки `/api/teacher-subjects` разрешены только если дисциплина принадлежит кафедре текущего пользователя, а у преподавателя на текущую дату действует основная или дополнительная связь с ней;
- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
- все `/api/workload/*`, включая свободные аудитории, принудительно используют кафедру из `AuthContext`; запрос с чужим `departmentId` отклоняется с `403`.
Глобальный workload и произвольный фильтр кафедры разрешены ролям `ADMIN`,
`EDUCATION_OFFICE` и `SCHEDULE_VIEWER`.
Ролевая матрица учебного отдела согласована с видимыми экранами: календарный график может
читать специальности и профили, а раздел настроек форм обучения выполняет GET/POST/DELETE.
Изменение самих специальностей и профилей остаётся административной операцией.
---
@@ -64,6 +72,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
- **Целостность назначения:** год, специальность, профиль и форма графика должны совпадать с группой, а вычисленный курс должен входить в `1..course_count`. После назначения несовместимое изменение группы или графика отклоняется целиком с `409 Conflict`; назначение не удаляется и не становится устаревшим.
- **Размерность графика:** уменьшение `course_count` запрещено, пока существуют строки сетки старших курсов или дисциплины семестров выше `course_count * 2`. Даты сетки всегда находятся внутри учебного года графика.
### Аудитории (Classrooms)
@@ -81,9 +91,11 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Дисциплины (Subjects)
- **Поля:** Название (уникальное), код, кафедра, описание
- **Поля:** Название (глобально уникальное без учёта регистра), код, кафедра, описание
- Привязка преподавателей через `teacher_subjects` (Many-to-Many)
- Кафедра может добавлять комментарии к дисциплине и загружать список дисциплин через кабинет кафедры
- Повторный импорт собственной дисциплины обновляет ту же запись и восстанавливает её из архива
- Совпадение названия с дисциплиной другой кафедры даёт `409 Conflict` и никогда не меняет владельца
### Жизненный цикл справочников
@@ -98,18 +110,24 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Кафедральные связи преподавателей
Основная кафедра преподавателя хранится в `users.department_id` для совместимости, а актуальные и исторические связи фиксируются в `teacher_department_assignments`. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся основной, дополнительные связи создаются как неосновные.
Актуальные и исторические связи преподавателя с кафедрами определяются только по
`teacher_department_assignments`. Поле `users.department_id` сохраняется как legacy-зеркало
основной кафедры на текущую дату и не используется для бизнес-решений или исторических
отчётов. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся
основной, дополнительные связи создаются как неосновные.
Правила:
- у преподавателя должна быть одна открытая основная кафедра;
- периоды двух основных кафедр одного преподавателя не могут пересекаться;
- преподаватель может иметь несколько открытых неосновных кафедр;
- одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи;
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
- при переводе старая запись закрывается днём перед `valid_from`, новая начинается с `valid_from`;
- будущий перевод не меняет текущую принадлежность и legacy-зеркало до даты вступления в силу;
- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
- исторические списки включают архивного преподавателя, если он и назначение действовали на целевую дату;
- нагрузка определяется для каждой даты занятия отдельно и разделяется между кафедрами при переводе внутри отчётного периода.
---
@@ -135,6 +153,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
года; сужение года, исключающее существующий семестр, отклоняется. Создание и изменение
семестра блокируют строку родительского года, а PostgreSQL exclusion constraints разрешают
глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений
поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк.
Полная замена `academic_calendar_days` выполняется через транзакционный
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
@@ -155,6 +181,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения
календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки
звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск
семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только
по lookup-картам. `ScheduleQueryService` передаёт все группы в один
`buildScheduleForGroups()`, поэтому число запросов не растёт как
`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются.
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
@@ -167,6 +201,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
единый снимок изменений и затем фильтрует итогового преподавателя. При отсутствии таких
замен полный список групп не загружается.
Отчёты workload и свободные аудитории не являются интерактивным поиском и используют
отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без
лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию
и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита
широкого `/api/schedule/search` остаётся прежней.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
@@ -175,8 +215,19 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. Ручное назначение выполняется из ячейки редактора календарного графика, потому что оно относится к конкретной учебной дате.
Создание и изменение слота выполняет транзакционный `TimeSlotService`. Время начала должно
быть раньше окончания, продолжительность вычисляется только backend, а внутри одной сетки
запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы. Поэтому соседние
интервалы, например `08:0009:30` и `09:3011:00`, допустимы. Операции блокируют строки
затронутых сеток в стабильном порядке; exclusion constraint PostgreSQL дополнительно
защищает инвариант между экземплярами backend и при прямых конкурентных записях.
Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание.
Правила расписания могут ссылаться только на слоты сетки `DEFAULT`. Используемый базовый
слот нельзя перенести в `WEEKDAY` или `MANUAL`, а сетку с используемыми слотами нельзя
сделать небазовой. Эти условия проверяются сервисом и транзакционными триггерами БД.
При миграции одинаковые стартовые слоты создаются для базовой и субботней сетки:
| № | Время |

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` не поддерживается.
### Полный сброс БД (локально)

View File

@@ -73,14 +73,16 @@ private static final Logger logger = LoggerFactory.getLogger(MyController.class)
// Информационные сообщения
logger.info("Запрос на получение всех занятий");
// Ошибки с полным стектрейсом
logger.error("Ошибка при сохранении: {}", e.getMessage(), e);
// Технические детали остаются только в журнале
logger.error("Ошибка при сохранении записи", e);
```
#### Валидация
- Для сложных правил — отдельные сервисы или приватные методы в контроллере с понятным сообщением об ошибке
- Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()`
- Не формируйте HTTP-ответ из `Exception.getMessage()`: JDBC/SQL-исключения передаются в
`GlobalExceptionHandler`, который возвращает безопасный русский `400`/`409`/`500`
#### Импорты
@@ -126,8 +128,8 @@ import com.magistr.app.repository.*;
#### Лучшие практики
```javascript
// ✅ Предпочитайте const
const token = localStorage.getItem('token');
// ✅ Авторизация только через общий модуль; access JWT не хранится в Web Storage
const session = getSession();
// ✅ Async/await вместо .then()
async function loadData() {
@@ -229,6 +231,10 @@ public class AbsenceController {
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца
содержимое прежних V2V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД.
### Применение
```bash

View File

@@ -7,8 +7,8 @@
| **Фреймворк** | Нет (Vanilla JavaScript) |
| **Модульная система** | ES6 Modules (`import`/`export`) |
| **Стили** | CSS (модульный подход) |
| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания |
| **Веб-сервер** | Apache httpd:alpine |
| **Шрифт** | Системный стек без внешних font-CDN |
| **Веб-сервер** | Apache httpd на Alpine со строгим CSP |
---
@@ -16,14 +16,24 @@
```
frontend/
├── package.json # Dependency-free проверки frontend через node:test
├── package.json # Exact build-зависимости и команды проверки
├── package-lock.json # Зафиксированное npm-дерево
├── auth-session.js # Access JWT и профиль сессии только в памяти вкладки
├── telemetry.js # Same-origin загрузчик собранного OTel bundle
├── security.conf # CSP и защитные HTTP-заголовки Apache
├── telemetry/
│ └── otel-entry.js # Исходная точка сборки OpenTelemetry
├── scripts/
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
├── tests/
── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации
├── theme-toggle.js # Переключение светлой/тёмной темы
├── Dockerfile # httpd:alpine
├── Dockerfile # Multi-stage: npm bundle → Apache httpd
├── admin/ # 👨‍💼 Интерфейс администратора
│ ├── index.html # SPA-оболочка с sidebar
@@ -36,10 +46,10 @@ frontend/
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
│ │ ├── dashboard.js # Дашборд
│ │ ├── users.js # Управление пользователями
@@ -81,14 +91,18 @@ frontend/
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
├── teacher/ # 👩‍🏫 Интерфейс преподавателя
── index.html # Недельный просмотр динамического расписания преподавателя
── index.html # CSP-совместимая HTML-оболочка
│ ├── app.js # Недельный просмотр и общий auth-session
│ └── style.css # Стили кабинета без inline-блока
├── department/ # 🏛 Кабинет кафедры
│ └── index.html # Redirect в `/admin/#department-workspace`
├── edu-office/ # 🗓 Кабинет учебного отдела
│ └── index.html # Redirect в `/admin/#schedule-view`
└── student/ # 🎓 Интерфейс студента
── index.html # Недельный просмотр динамического расписания группы
── index.html # CSP-совместимая HTML-оболочка
├── app.js # Недельный просмотр и общий auth-session
└── style.css # Стили кабинета без inline-блока
```
---
@@ -112,12 +126,13 @@ frontend/
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
4. Обновляет заголовок страницы (`#page-title`)
`main.js` также фильтрует вкладки по роли:
`main.js` и отдельный settings SPA получают разрешённые вкладки из единого
`admin/js/role-capabilities.js`; локальные дубли `ROLE_NAVIGATION`/`ROLE_TABS` удалены.
| Роль | Доступные вкладки |
|------|-------------------|
| `ADMIN` | Все вкладки |
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории, оборудование |
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний |
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
@@ -139,14 +154,14 @@ frontend/
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
### Особенности админских вкладок
- Вкладка `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 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
@@ -168,47 +183,32 @@ frontend/
- Кнопка «Назад в панель» для возврата в `/admin/`
- Текущие вкладки:
- **Общие настройки** — заглушка (только `ADMIN`)
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Поле длительности доступно только для чтения и пересчитывается при изменении времени начала или окончания; в API отправляются только границы, а окончательное значение вычисляет backend. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
---
## API-клиент (`api.js`)
Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
Все защищённые HTTP-запросы проходят через `fetchWithAuth()` из `auth-session.js`.
Access JWT и `role`/`departmentId`/`userId` существуют только в памяти JavaScript-модуля:
```javascript
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const response = await fetch(endpoint, {
method,
headers: getHeaders(body ? 'application/json' : null),
credentials: 'same-origin',
body: body ? JSON.stringify(body) : undefined
});
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) return apiFetch(endpoint, method, body, false);
clearAuthState();
window.location.href = '/';
export async function fetchWithAuth(endpoint, options = {}, retry = true) {
const headers = new Headers(options.headers || {});
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
const response = await fetch(endpoint, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401 && retry && await refreshAccessToken()) {
return fetchWithAuth(endpoint, options, false);
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return await response.json();
return response;
}
// Shortcut-методы
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),
put: (url, body) => apiFetch(url, 'PUT', body),
delete: (url, body) => apiFetch(url, 'DELETE', body)
};
```
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
При загрузке защищённой страницы память восстанавливается через `POST /api/auth/refresh` и
`HttpOnly` cookie. При `401` клиент выполняет не более одной общей refresh-ротации для всех
параллельных запросов и один раз повторяет исходный запрос. Неуспешный refresh очищает память
и возвращает пользователя на страницу входа. Legacy auth-ключи только удаляются из
`localStorage`/`sessionStorage`; новые данные авторизации туда не записываются.
После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы.
@@ -216,13 +216,21 @@ export const api = {
## Frontend-тесты
Регрессионные проверки frontend используют встроенный `node:test` без сторонних npm-зависимостей (Node.js 18+). Тесты `dashboard-conflicts.test.mjs` покрывают локальные даты `Europe/Moscow` в интервале 00:0003:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса.
Регрессионные проверки используют встроенный `node:test` (Node.js 18+). Помимо тестов
дашборда, `auth-session.test.mjs` покрывает login-state, reload через refresh-cookie,
single-flight ротацию, повтор после `401` и logout. `security-policy.test.mjs` запрещает
auth-данные в Web Storage, удалённые/inline-скрипты, динамические inline-стили и рассинхрон
SHA-256 style-хэшей с CSP. Там же закреплены безопасный DOM-рендеринг ошибок без
интерполяции `exception.message` в `innerHTML`, скрытые парольные поля с корректным
`autocomplete`, русские метки tenant-контура и отсутствие англоязычных/raw exception
сообщений в собственных frontend-логах.
Команды выполняются из каталога `frontend/`:
```bash
npm test # unit-тесты дат, загрузки кафедр и UI-состояний
npm run check # синтаксис модулей дашборда + unit-тесты
npm run build # собрать локальный dist/vendor/otel.js
npm test # frontend unit/static tests
npm run check # синтаксис auth/UI-модулей + все frontend-тесты
```
---
@@ -233,13 +241,10 @@ npm run check # синтаксис модулей дашборда + unit-те
1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`:
- `token` — access JWT
- `role` — роль пользователя
- `departmentId` — кафедра пользователя
- `userId` — ID пользователя для личного расписания преподавателя
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
5. Перенаправляет на соответствующий интерфейс:
3. При успехе кладёт access JWT и профиль пользователя только в память текущей страницы.
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript.
5. После перехода новый документ восстанавливает память через `POST /api/auth/refresh`.
6. Пользователь перенаправляется на соответствующий интерфейс:
- `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace`
@@ -249,18 +254,19 @@ npm run check # синтаксис модулей дашборда + unit-те
### Проверка авторизации
На каждой странице проверяется наличие токена и роли:
Каждая защищённая страница сначала восстанавливает сессию и проверяет роль из памяти:
```javascript
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
return getToken() && role === 'ADMIN';
const session = await restoreSession();
if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
window.location.replace('/');
}
```
### Выход
Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
Кнопка «Выйти» вызывает `POST /api/auth/logout`, очищает access JWT и профиль из памяти и
перенаправляет на `/`. Refresh-cookie очищает backend.
---
@@ -292,7 +298,8 @@ export function isAuthenticatedAsAdmin() {
### Преподаватель (`/teacher/`)
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`.
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из
восстановленного в памяти профиля сессии.
Основные элементы:
- навигация по неделям: предыдущая, текущая, следующая;
@@ -300,7 +307,7 @@ export function isAuthenticatedAsAdmin() {
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
Если refresh-cookie недействительна или роль не `TEACHER`, страница возвращает пользователя на вход.
### Студент (`/student/`)
@@ -368,17 +375,18 @@ CSS-переменные позволяют поддерживать светл
---
## OpenTelemetry (`otel.js`)
## OpenTelemetry (локальный bundle)
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`.
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на
same-origin путь `/otel/v1/traces`.
- **На production** — загружается автоматически через динамический `import()`
- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh`
- npm-зависимости зафиксированы exact-версиями в `package-lock.json`;
- `scripts/build-vendor.mjs` собирает их через esbuild в `/vendor/otel.js`;
- браузер импортирует только same-origin bundle, CDN-код в runtime отсутствует;
- на `localhost` телеметрия не включается.
```javascript
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
telemetryPromise = import('/vendor/otel.js');
```
---

View File

@@ -6,18 +6,20 @@
```yaml
services:
backend: # Spring Boot (Java 17), порт 8080
frontend: # Apache httpd:alpine, порт 80
db: # PostgreSQL alpine3.23, порт 5432
backend: # Spring Boot (Java 17), внутренний порт 8080
frontend: # Apache httpd, публикует HTTP_PORT (по умолчанию 80)
db: # PostgreSQL 16.3, внутренний порт 5432
```
### Сеть
Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском:
Compose сам создаёт изолированную bridge-сеть `magistr`. Внешняя сеть или отдельно
установленный reverse proxy для локального запуска не нужны. Apache раздаёт frontend и
проксирует same-origin пути `/api` и `/actuator/health` в backend; backend и PostgreSQL не
публикуют порты на хосте.
```bash
docker network create proxy
```
Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.
### Переменные окружения
@@ -30,9 +32,29 @@ POSTGRES_DB=app_db
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
JWT_REFRESH_CLEANUP_RETENTION=30d
LOGIN_RATE_MAX_FAILURES=5
LOGIN_RATE_ATTEMPT_WINDOW=15m
LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_RETENTION=90d
TRUSTED_PROXY_CIDRS=172.16.0.0/12
OTEL_SDK_DISABLED=true
HTTP_PORT=80
```
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
и заполните пустые секреты. `POSTGRES_PASSWORD` и `JWT_SECRET` обязательны для
`docker compose up` и не хранятся в `compose.yaml`; JWT должен быть случайным секретом длиной
минимум 32 байта (`openssl rand -base64 48`). `POSTGRES_DB` одновременно задаёт создаваемую
БД и входит в `SPRING_DATASOURCE_URL`, поэтому произвольное локальное имя остаётся
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует.
Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён;
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.
В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
@@ -43,25 +65,41 @@ JWT_REFRESH_TOKEN_TTL=7d
Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3.9-eclipse-temurin-17``mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar`
2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и
проверяется по закреплённому SHA-256
3. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar`
### Dockerfile (Frontend)
```dockerfile
FROM httpd:alpine
COPY . /usr/local/apache2/htdocs/
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
RUN npm ci
RUN npm run build:vendor
FROM httpd:alpine3.23@sha256:4a15e9c...
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
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. Первый этап собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
маршрутизировался в tenant `default`. Сервер
возвращает CSP с `script-src 'self'`, `script-src-attr 'none'`, `style-src 'self'` и точными
SHA-256 для оставшихся статических style-атрибутов. `unsafe-inline` и `unsafe-eval` не
используются. Дополнительно выставляются `nosniff`, `DENY`, строгий referrer policy и
ограниченная Permissions Policy.
---
## Kubernetes (продакшн)
### Расположение конфигурации
Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот
внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным
runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера.
Production-манифесты находятся в `../k8s/`. Каталог расположен вне Git-корня `magistr`,
поэтому его изменения проверяются и перечисляются отдельно от `git diff` репозитория.
### Ключевые ресурсы
@@ -78,6 +116,37 @@ runbook для оператора, а не утверждением о прим
- `JWT_ACCESS_TOKEN_TTL=15m`
- `JWT_REFRESH_TOKEN_TTL=7d`
- `JWT_REFRESH_COOKIE_SECURE=true`
- `JWT_REFRESH_CLEANUP_ENABLED=true`
- `JWT_REFRESH_CLEANUP_RETENTION=30d`
- `JWT_REFRESH_CLEANUP_BATCH_SIZE=500`
- `JWT_REFRESH_CLEANUP_MAX_BATCHES=20`
- `JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000`
- `JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000`
Cleanup проходит отдельно по каждой активной tenant-БД. `RETENTION` задаёт срок хранения
свежих истёкших и отозванных audit-записей, `BATCH_SIZE` и `MAX_BATCHES` ограничивают объём
одного прохода, а interval/initial delay управляют безопасным расписанием.
### Защита входа и доверенные proxy
`app-config` задаёт общий PostgreSQL rate limit для всех backend-pod:
- `LOGIN_RATE_MAX_FAILURES=5`
- `LOGIN_RATE_ATTEMPT_WINDOW=15m`
- `LOGIN_RATE_BASE_BLOCK_DURATION=1m`
- `LOGIN_RATE_MAX_BLOCK_DURATION=15m`
- `LOGIN_AUDIT_RETENTION=90d`
- `TRUSTED_PROXY_CIDRS=10.42.0.0/16`
Последнее значение соответствует стандартной pod-сети K3s, из которой Traefik обращается
к backend. Если кластер запущен с другим `cluster-cidr`, перед rollout нужно указать
фактическую сеть ingress proxy. Не следует добавлять публичные сети клиентов: backend
доверяет `X-Forwarded-For` только от непосредственного источника из этого списка.
Счётчики и audit-события находятся в каждой tenant-БД, поэтому два pod используют одно
атомарное состояние. Старая история очищается раз в сутки ограниченными `SKIP LOCKED`
пачками; параметры `LOGIN_AUDIT_CLEANUP_*` из `.env.example` позволяют изменить расписание
и максимальный объём одного прохода.
`app-secret` задаёт `JWT_SECRET`. Этот Secret не создаётся файлами `../k8s/`: его заранее
предоставляет внешний secret manager или оператор. Deployment явно включает профиль
@@ -194,8 +263,7 @@ namespace `magistr`. Secret должен существовать заранее
### Liveness и readiness probes backend
TCP probe подтверждает только открытый порт и не отражает готовность обязательных tenant-БД.
В production Deployment необходимо использовать раздельные HTTP probes:
Production Deployment использует раздельные HTTP probes:
```yaml
livenessProbe:
@@ -214,29 +282,20 @@ Liveness зависит только от состояния процесса. R
исключается из приёма трафика без перезапуска. Actuator не публикует components, домены,
JDBC URL или credentials.
Параметры `initialDelaySeconds`, `periodSeconds`, `timeoutSeconds` и `failureThreshold` нужно
согласовать с текущим Deployment; замена механизма probe не подтверждает автоматически
корректность этих порогов.
`../k8s/backend.yaml` монтирует каталог `/config` без `subPath`, а `app-config` задаёт
`TENANTS_CONFIG_PATH=/config/tenants.json` и `TENANTS_CONFIG_REQUIRED=true`. Отсутствующий
или невалидный обязательный Secret поэтому снимает readiness, не включая H2 fallback.
### Внешние действия для production Kubernetes
Каталог `../k8s/` не изменялся и не проверялся в рамках текущей рабочей копии. Оператору после
получения доступа к нему необходимо:
1. обновить backend Deployment: directory mount без `subPath`,
`TENANTS_CONFIG_REQUIRED=true` и две HTTP probes;
2. добавить либо сузить Role до `get`/`update` одного `tenants-secret` и проверить RoleBinding
фактического ServiceAccount;
3. убедиться, что внешний `tenants-secret` существует, содержит непустой ключ `tenants.json`
и допускает обновление;
4. отрендерить манифесты и выполнить серверную проверку без применения:
Перед production rollout оператор должен убедиться, что внешний `tenants-secret` существует,
содержит непустой ключ `tenants.json`, допускает обновление, и выполнить проверки без
применения:
```bash
kubectl kustomize ../k8s >/dev/null
kubectl apply --dry-run=server -k ../k8s
```
5. отдельно проверить полномочия фактического ServiceAccount:
Отдельно проверьте полномочия фактического ServiceAccount:
```bash
BACKEND_SERVICE_ACCOUNT='укажите-фактический-service-account'
@@ -246,15 +305,21 @@ kubectl auth can-i update secret/tenants-secret \
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
```
Применение манифестов, rollout и проверка реальных pod являются отдельными внешними
операциями и без разрешения автоматически не выполняются.
Production rollout и проверка реальных pod являются внешними операциями и без отдельного
разрешения из этой рабочей копии не выполнялись.
### Обновление backend
### Ручное обновление backend и frontend
```bash
kubectl rollout restart deployment backend -n magistr
export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<64 hex>'
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<64 hex>'
bash ../k8s/deploy.sh apply
```
Манифесты содержат нулевой digest-sentinel. `deploy.sh` требует два реальных digest,
локально подставляет их до `kubectl apply` и строго ждёт оба rollout; прямое применение
Deployment-файлов запрещено.
---
## Caddy (реверс-прокси)
@@ -275,10 +340,16 @@ kubectl rollout restart deployment backend -n magistr
Расположение: `.gitea/workflows/docker-build.yaml`
Основные шаги:
1. Checkout кода
2. Login в Docker Registry
3. Build + Push образов (`backend`, `frontend`)
4. Генерация меток через `docker/metadata-action`
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:...`, сохраняет предыдущие
ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба
предыдущих образа и повторно проверяет их готовность.
5. Workflow-wide concurrency lock не допускает одновременные production deployment.
---
@@ -289,7 +360,7 @@ kubectl rollout restart deployment backend -n magistr
```mermaid
graph LR
Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector
Frontend["JS (локальный OTel bundle)"] -->|OTLP HTTP| Collector
Collector --> SigNoz["SigNoz"]
Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"]
@@ -308,10 +379,13 @@ Tenant ID добавляется в:
### Интеграция Frontend
Файл `admin/js/otel.js` — клиентская телеметрия:
Собранный `/vendor/otel.js` — клиентская телеметрия:
- Метрики производительности страниц
- Трейсы пользовательских действий
Исходные npm-пакеты имеют exact-версии и lockfile, собираются внутри frontend-образа и не
загружаются браузером со стороннего CDN.
PostgreSQL receivers collector получают endpoint и credentials только из
`otel-postgres-secret`; ConfigMap collector содержит лишь `${env:...}` ссылки.

View File

@@ -106,12 +106,18 @@ public class MyClass {
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
public void doSomething() {
log.info("Операция выполнена: param={}", value);
log.error("Ошибка: {}", e.getMessage(), e); // со стектрейсом
log.info("Операция выполнена: параметр={}", value);
log.error("Операция завершилась ошибкой: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали ошибки операции", e);
}
}
```
Production-сообщения пишутся на русском языке и не включают `exception.getMessage()`,
JDBC URL, логины, пароли или содержимое Secret. Технический тип исключения допускается как
структурированное поле, а полный стек — в отдельной русскоязычной записи уровня `DEBUG`.
### Рекомендации по уровням
| Уровень | Когда использовать |

View File

@@ -36,14 +36,18 @@
# 1. Клонировать репозиторий
git clone <repo-url> magistr && cd magistr
# 2. Создать Docker-сеть (если ещё не создана)
docker network create proxy
# 2. Подготовить локальные переменные
cp .env.example .env
# Укажите в .env POSTGRES_PASSWORD и случайный JWT_SECRET.
# JWT_SECRET можно сгенерировать командой: openssl rand -base64 48
# 3. Запустить все сервисы
docker compose up -d --build
```
После запуска приложение доступно по адресу: **http://localhost:80**
Compose сам создаёт внутреннюю сеть и именованный том PostgreSQL. После запуска приложение
доступно по адресу **http://localhost:80**; если в `.env` задан другой `HTTP_PORT`, используйте
его. Backend и PostgreSQL наружу не публикуются: `/api` проксируется frontend-контейнером.
**Учётные данные по умолчанию:**
@@ -60,7 +64,7 @@ docker compose logs -f backend
# Полный сброс базы данных (удаление данных + повтор миграций)
docker compose down -v
docker compose up -d
docker compose up -d --build
# Остановка всех сервисов
docker compose down