# 🏗 Архитектура системы ## Общая схема ```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`, синхронизирует тенантов | | `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`](SECURITY_RUNBOOK.md). Формат: ```json [ { "name": "ЮЗГУ", "domain": "swsu", "url": "jdbc:postgresql://db-host:5432/swsu_db", "username": "dbuser", "password": "dbpass" } ] ``` ### Жизненный цикл тенанта 1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret` 2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты 3. **Удаление:** `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 при отсутствии тенантов Если при запуске нет ни одного настроенного тенанта: 1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` 2. Если 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: 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. Ротация 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`.