314 lines
26 KiB
Markdown
314 lines
26 KiB
Markdown
# 🏗 Архитектура системы
|
||
|
||
## Общая схема
|
||
|
||
```mermaid
|
||
graph TD
|
||
Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"]
|
||
Caddy -->|:80| Frontend["Frontend<br/>(Apache httpd:alpine)"]
|
||
Caddy -->|/api/*| Backend["Backend<br/>(Spring Boot 3.2.5)"]
|
||
|
||
Backend --> TenantRouter{"TenantRoutingDataSource"}
|
||
TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL<br/>swsu_db"]
|
||
TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL<br/>mgu_db"]
|
||
TenantRouter -->|...| DBn["PostgreSQL<br/>tenant_n_db"]
|
||
|
||
Backend -->|Метрики, Логи, Трейсы| OTel["OpenTelemetry Collector"]
|
||
OTel --> SigNoz["SigNoz"]
|
||
```
|
||
|
||
## Компоненты
|
||
|
||
### Frontend (Apache httpd:alpine)
|
||
- **Тип:** Статические файлы (HTML/CSS/JS)
|
||
- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server
|
||
- **Порт:** 80
|
||
- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/`
|
||
- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`)
|
||
|
||
### Backend (Spring Boot 3.2.5)
|
||
- **Тип:** REST API сервер
|
||
- **Язык:** Java 17
|
||
- **Порт:** 8080 (внутренний)
|
||
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
||
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
|
||
|
||
### PostgreSQL
|
||
- **Версия:** `postgres:alpine3.23`
|
||
- **Локально:** Одна БД `app_db` (тенант `default`)
|
||
- **Продакшн:** Множество БД, по одной на каждый университет (тенант)
|
||
|
||
### Caddy (реверс-прокси)
|
||
- **Расположение:** `../caddy-proxy/`
|
||
- **Назначение:** TLS-терминация, маршрутизация запросов к backend/frontend
|
||
- **Домен:** `*.zuev.company`
|
||
|
||
---
|
||
|
||
## Мультитенантная архитектура
|
||
|
||
Ключевая особенность системы — изоляция данных каждого университета в отдельной БД PostgreSQL.
|
||
|
||
### Принцип работы
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Browser as Браузер
|
||
participant Interceptor as TenantInterceptor
|
||
participant Context as TenantContext
|
||
participant Router as TenantRoutingDataSource
|
||
participant DB as PostgreSQL
|
||
|
||
Browser->>Interceptor: GET /api/users<br/>Host: swsu.zuev.company
|
||
Interceptor->>Interceptor: resolveTenant("swsu.zuev.company") → "swsu"
|
||
Interceptor->>Context: setCurrentTenant("swsu")
|
||
Note over Interceptor: Проверка: hasTenant("swsu")?
|
||
Interceptor-->>Browser: 404 если тенант не найден
|
||
|
||
Note over Context,Router: Обработка запроса контроллером
|
||
Router->>Router: determineCurrentLookupKey() → "swsu"
|
||
Router->>DB: SQL запрос к swsu_db
|
||
DB-->>Browser: Ответ с данными
|
||
```
|
||
|
||
### Ключевые классы
|
||
|
||
| Класс | Назначение |
|
||
|-------|-----------|
|
||
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
|
||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
|
||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||
| `TenantConfigWatcher` | Периодически перечитывает `tenants.json`, сравнивает полный нормализованный снимок и повторяет неудачную синхронизацию с ограниченным backoff |
|
||
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
|
||
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
|
||
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
|
||
| `TenantDatabaseMigrationService` | Запускает Flyway для подготовленного, ещё не опубликованного pool |
|
||
| `RetiredTenantPoolService` | После swap дожидается завершения активных подключений старого pool и закрывает его |
|
||
| `TenantReadinessRegistry` | Хранит атомарный снимок обязательных tenant, миграций и свежести проверок соединения |
|
||
| `TenantDatabaseHealthMonitor` | Ограниченно-параллельно проверяет соединения с обязательными tenant-БД в фоне |
|
||
| `TenantReadinessHealthIndicator` | Включает агрегированную готовность tenant-БД в Actuator readiness group |
|
||
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||
|
||
### Определение тенанта
|
||
|
||
Логика определения тенанта по заголовку `Host`:
|
||
|
||
| Host | Результат |
|
||
|------|----------|
|
||
| `swsu.zuev.company` | `swsu` |
|
||
| `mgu.zuev.company` | `mgu` |
|
||
| `localhost` | `default` |
|
||
| `localhost:8080` | `default` |
|
||
| `192.168.1.1` | `default` |
|
||
|
||
### Конфигурация тенантов
|
||
|
||
Список тенантов хранится в JSON-файле:
|
||
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
|
||
- **Продакшн:** внешний Kubernetes Secret `tenants-secret`, ключ `tenants.json` монтируется в `/config/tenants.json`
|
||
|
||
`tenants-secret` не создаётся отслеживаемыми production-манифестами. Его подготавливает
|
||
оператор через secret manager по [`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md).
|
||
|
||
Формат:
|
||
```json
|
||
[
|
||
{
|
||
"name": "ЮЗГУ",
|
||
"domain": "swsu",
|
||
"url": "jdbc:postgresql://db-host:5432/swsu_db",
|
||
"username": "dbuser",
|
||
"password": "dbpass"
|
||
}
|
||
]
|
||
```
|
||
|
||
### Жизненный цикл тенанта
|
||
|
||
1. **Добавление или обновление через API:** перед мутацией `TenantConfigWatcher` разбирает,
|
||
нормализует и при необходимости полностью применяет текущую mounted-проекцию как baseline.
|
||
Ошибка подготовки baseline прерывает API-операцию. Затем `TenantLifecycleService` создаёт
|
||
непубликуемый HikariCP candidate, проверяет реальное соединение, выполняет Flyway, а
|
||
`TenantConfigStore` читает актуальный Secret и применяет к нему только upsert домена.
|
||
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
|
||
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
|
||
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
|
||
состояние читается заново, а собственная мутация повторно применяется к нему. Число
|
||
попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция
|
||
не отправляет лишний `PUT`.
|
||
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
|
||
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
|
||
Успешный lifecycle возвращает внутренний `TenantLifecycleMutationResult` с конфигурацией
|
||
и `TenantSecretUpdateReceipt`, содержащей признаки `persisted`/`changed`, а также
|
||
семантические снимки `previousTenants` и `committedTenants`.
|
||
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
|
||
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
|
||
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
|
||
`resourceVersion`. Если другой pod уже записал более новую версию, автоматический откат
|
||
пропускается. Неопределённый сетевой результат сначала сверяется повторным `GET` и не
|
||
создаёт квитанцию для небезопасной компенсации. HTTP-граница получает русское безопасное
|
||
сообщение без JDBC/Flyway details.
|
||
5. **Drain старого pool:** после успешного swap новые запросы сразу используют candidate,
|
||
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
|
||
настраиваемого grace timeout.
|
||
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
|
||
`tenants.json` и сравнивает все поля нормализованного `TenantConfig`: `name`, `domain`,
|
||
`url`, `username` и `password`. Для нового или изменённого тенанта lifecycle без повторной
|
||
записи Secret выполняет prepare → проверку соединения → Flyway → атомарный swap; отсутствующие
|
||
домены удаляются под тем же локальным monitor.
|
||
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
|
||
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
|
||
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
|
||
ограничения компенсации по `resourceVersion`.
|
||
|
||
Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса.
|
||
Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт
|
||
заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный
|
||
monitor по-прежнему не считается межподовой блокировкой.
|
||
|
||
Вне Kubernetes `KubernetesTenantSecretUpdater` работает как runtime-only адаптер: Secret и
|
||
локальный `tenants.json` не изменяются. Постоянная локальная конфигурация задаётся файлом до
|
||
запуска приложения.
|
||
|
||
Для обращения к Kubernetes API backend загружает
|
||
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
|
||
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
|
||
завершает персистенцию безопасным отказом.
|
||
|
||
`TenantConfigWatcher` использует SHA-256 содержимого `tenants.json` как идентификатор файловой
|
||
ревизии, но решения о запаздывающих проекциях принимает по нормализованным семантическим
|
||
снимкам `name`/`domain`/`url`/`username`/`password`. Хеш становится последним применённым
|
||
только после успешного разбора и полного lifecycle sync. Ошибка сохраняет прежний хеш,
|
||
снимает readiness и запускает экспоненциальные повторы через 30–300 секунд; другая неудачная
|
||
файловая ревизия получает собственную попытку без ожидания backoff предыдущей.
|
||
|
||
После API-операции snapshot fence устанавливается только если `TenantSecretUpdateReceipt`
|
||
подтверждает реальное общее изменение: `persisted=true` и `changed=true`. Снимок до записи
|
||
и ранее ожидавшиеся committed-снимки временно считаются deferred, а новый committed-снимок —
|
||
ожидаемым. Поэтому при быстрых мутациях H0 → H1 → H2 запаздывающие H0 и H1 не откатывают
|
||
runtime, а H2 полностью синхронизируется и снимает fence. Неизвестный объединённый снимок,
|
||
которого нет среди deferred-состояний, также немедленно проходит полный sync и не теряет
|
||
изменение другого pod. Локальная персистенция (`persisted=false`) и подтверждённый no-op
|
||
(`changed=false`) fence не создают.
|
||
|
||
Если ошибка readiness произошла уже после успешного swap, следующий watcher retry видит
|
||
совпадающую активную конфигурацию, проверяет действующее соединение и восстанавливает
|
||
readiness без повторной миграции и второго swap.
|
||
|
||
### Liveness и readiness
|
||
|
||
Spring Boot Actuator публикует две независимые служебные группы:
|
||
|
||
- `GET /actuator/health/liveness` включает только `livenessState` приложения. Отказ любой
|
||
tenant-БД не переводит процесс в liveness failure и не создаёт restart storm;
|
||
- `GET /actuator/health/readiness` включает `readinessState` и `tenantReadiness`. Ответ имеет
|
||
статус `UP` только если задан непустой набор обязательных tenant, миграция каждого успешно
|
||
завершена и для каждого есть свежая успешная проверка соединения.
|
||
|
||
`TenantDataSourceConfig` регистрирует обязательный набор до startup-активации и сохраняет
|
||
неуспешную миграцию как readiness failure. H2-заглушка позволяет инициализировать Spring JPA,
|
||
но никогда не делает приложение готовым к production-трафику. Ошибка чтения обязательного
|
||
`tenants.json` также фиксируется отдельно от состояния процесса.
|
||
|
||
`TenantDatabaseHealthMonitor` по умолчанию запускается через секунду после старта, затем
|
||
проверяет соединения каждые 10 секунд пакетами до четырёх параллельных задач. Таймаут пакета
|
||
составляет 6 секунд: по его истечении отменяются только незавершённые задачи, а уже полученные
|
||
результаты сохраняются с фактическим временем завершения проверки. Результат старше 30 секунд
|
||
считается просроченным. Запоздалый результат привязан к HMAC-fingerprint конкретной конфигурации
|
||
и не может отметить новую конфигурацию готовой; исходные credentials в fingerprint и
|
||
health-ответ не попадают. Планировщик использует два потока, поэтому ожидающий health-pass не
|
||
задерживает периодическое чтение tenant Secret.
|
||
|
||
Tenant interceptor исключает `/actuator/**`, а authorization interceptor применяется только
|
||
к `/api/**`. При этом наружу опубликован только endpoint `health`, его details и components
|
||
скрыты: kubelet получает минимальный `UP`/`DOWN` без доменов и реквизитов tenant-БД.
|
||
|
||
### Fallback при отсутствии тенантов
|
||
|
||
Если при запуске нет ни одного настроенного тенанта:
|
||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||
|
||
Для production обязательность смонтированной конфигурации задаётся
|
||
`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness.
|
||
|
||
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||
|
||
---
|
||
|
||
## Транзакционные изменения расписания
|
||
|
||
`ScheduleRuleAdminController` является тонким HTTP-адаптером. Чтение, создание, изменение
|
||
и архивация правил выполняются через `ScheduleRuleService`; публичные write-методы сервиса
|
||
образуют транзакционные границы, поэтому ошибки валидации и конфликты выходят за Spring
|
||
proxy и приводят к rollback.
|
||
|
||
Создание правила до остальных запросов к БД захватывает `PESSIMISTIC_WRITE` на строке
|
||
целевого семестра. Update сначала блокирует строку правила, затем старый и новый семестры в
|
||
порядке ID; архивация блокирует правило и его семестр. После блокировок сервис заново
|
||
проверяет слоты payload и активные правила семестра, поэтому конкурентные запросы разных
|
||
pod не сохраняют два конфликтующих правила после проверки одного снимка.
|
||
|
||
`AcademicCalendarController` делегирует полную замену дневной сетки
|
||
`AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный
|
||
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются
|
||
`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает
|
||
rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется
|
||
только после успешного commit.
|
||
|
||
`ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет
|
||
`ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`.
|
||
Сервис строит базовый день через `ScheduleQueryService`, накладывает сохранённые overrides и
|
||
кандидат общей функцией и только затем проверяет результирующие интервалы и ресурсы.
|
||
|
||
До чтения снимка даты сервис захватывает `pg_advisory_xact_lock` с отдельным namespace и
|
||
точным `LocalDate.toEpochDay()`. Блокировка живёт до commit/rollback, работает между pod и
|
||
изолирована по tenant-БД. При `PUT` и `DELETE` дополнительно блокируется ID override;
|
||
при смене даты старая и новая даты захватываются в стабильном порядке. После блокировок
|
||
строка перечитывается с `PESSIMISTIC_WRITE`, поэтому параллельное изменение не приводит к
|
||
lost update или проверке устаревшего снимка.
|
||
|
||
Teacher-only чтение строит базовое расписание напрямую через
|
||
`ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно
|
||
находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой
|
||
релевантной даты и добавляет только целевые `baseRuleSlotId`. Все overrides применяются до
|
||
финального teacher-фильтра и дедупликации; без релевантных замен полный обход групп не
|
||
выполняется.
|
||
|
||
---
|
||
|
||
## Аутентификация
|
||
|
||
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
|
||
|
||
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 хэш.
|
||
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
|
||
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
|
||
|
||
Ротация 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`, если роли недостаточно.
|
||
|
||
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
|
||
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
|
||
проверки роли и повторно очищает контекст в `afterCompletion`. Поэтому отказ с `401`/`403`
|
||
или публичный endpoint не может получить пользователя от предыдущего запроса того же
|
||
потока контейнера.
|
||
|
||
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
|
||
|
||
JWT-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле
|
||
`prod` или `production` startup-проверка дополнительно отклоняет известные legacy/placeholder
|
||
значения и требует `JWT_REFRESH_COOKIE_SECURE=true`. Production deployment явно включает
|
||
профиль `production` и получает `JWT_SECRET` только через `secretKeyRef`.
|