баг-фикс 30/34
This commit is contained in:
@@ -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-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле
|
||||
|
||||
Reference in New Issue
Block a user