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