8.6 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, синхронизирует тенантов |
ConfigMapUpdater |
Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
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 ConfigMap
tenants-config, монтируется в/config/tenants.json
Формат:
[
{
"name": "ЮЗГУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
]
Жизненный цикл тенанта
- Добавление через API:
POST /api/database/tenants→ создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap - Синхронизация подов:
TenantConfigWatcherкаждые 30 сек проверяетtenants.json→ добавляет новые / удаляет отсутствующие тенанты - Удаление:
DELETE /api/database/tenants/{domain}→ закрывает пул → обновляет ConfigMap
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 на асинхронных участках.
Аутентификация
Система использует 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.
Access JWT содержит claim'ы tenant, userId, username, role, departmentId, iat, exp, jti. AuthorizationInterceptor проверяет подпись, срок действия и совпадение tenant с TenantContext, затем применяет @RequireRoles. Это означает, что UI-роль в localStorage остаётся только удобством: backend возвращает 401, если токен отсутствует/некорректен, и 403, если роли недостаточно.
TenantInterceptor по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.