# 🏗 Архитектура системы ## Общая схема ```mermaid graph TD Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"] Caddy -->|:80| Frontend["Frontend
(Apache httpd:alpine)"] Caddy -->|/api/*| Backend["Backend
(Spring Boot 3.2.5)"] Backend --> TenantRouter{"TenantRoutingDataSource"} TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL
swsu_db"] TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL
mgu_db"] TenantRouter -->|...| DBn["PostgreSQL
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
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` Формат: ```json [ { "name": "ЮЗГУ", "domain": "swsu", "url": "jdbc:postgresql://db-host:5432/swsu_db", "username": "dbuser", "password": "dbpass" } ] ``` ### Жизненный цикл тенанта 1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap 2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты 3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap `TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии. ### Fallback при отсутствии тенантов Если при запуске нет ни одного настроенного тенанта: 1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` 2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA `TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках. --- ## Аутентификация Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow: 1. Клиент отправляет `POST /api/auth/login` с `username` и `password`. 2. Backend проверяет пароль через `BCryptPasswordEncoder`. 3. При успехе возвращается access JWT для заголовка `Authorization: Bearer ` и устанавливается `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. Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно. `TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.