15 KiB
🏗 Архитектура системы
Общая схема
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.
Принцип работы
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 |
Наследует AbstractRoutingDataSource, маршрутизирует запросы к нужной БД |
TenantDataSourceConfig |
Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
TenantWebMvcConfig |
Регистрирует TenantInterceptor и AuthorizationInterceptor в MVC-слое |
TenantConfigWatcher |
Периодически (каждые 30 сек) перечитывает tenants.json, синхронизирует тенантов |
KubernetesTenantSecretUpdater |
Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA |
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.
Формат:
[
{
"name": "ЮЗГУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
]
Жизненный цикл тенанта
- Добавление через API:
POST /api/database/tenants→ создаёт HikariCP пул → запускает Flyway миграции → обновляетtenants-secret - Синхронизация подов:
TenantConfigWatcherкаждые 30 сек проверяет смонтированныйtenants.json→ добавляет новые / удаляет отсутствующие тенанты - Удаление:
DELETE /api/database/tenants/{domain}→ закрывает пул → обновляетtenants-secret
Для обращения к Kubernetes API backend загружает
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt, использует стандартную PKIX-проверку
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом.
TenantConfigWatcher сравнивает содержимое tenants.json по SHA-256, а не по String.hashCode(), чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
Fallback при отсутствии тенантов
Если при запуске нет ни одного настроенного тенанта:
- Проверяется наличие
spring.datasource.url→ создаётся тенантdefault - Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
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:
- Клиент отправляет
POST /api/auth/loginсusernameиpassword. - Backend проверяет пароль через
BCryptPasswordEncoder. - При успехе возвращается access JWT для заголовка
Authorization: Bearer <token>и устанавливаетсяHttpOnlyrefresh-cookie. - Access JWT хранится в
localStorage; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш. - При истечении access JWT клиент вызывает
POST /api/auth/refresh; refresh-токен ротируется, старый хэш отзывается. 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.