баг-фикс 30/34

This commit is contained in:
Zuev
2026-07-19 14:40:43 +03:00
parent 3d798c13e3
commit bc0e1ab1b4
172 changed files with 13431 additions and 2910 deletions

View File

@@ -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-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле