Files
magistr/docs/ARCHITECTURE.md
2026-07-16 22:56:43 +03:00

24 KiB
Raw Blame History

🏗 Архитектура системы

Общая схема

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 Маршрутизирует запросы и атомарно публикует неизменяемый снимок TenantConfig + DataSource
TenantDataSourceConfig Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы
TenantWebMvcConfig Регистрирует TenantInterceptor и AuthorizationInterceptor в MVC-слое
TenantConfigWatcher Периодически (каждые 30 сек) перечитывает tenants.json, синхронизирует тенантов
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.

Формат:

[
  {
    "name": "ЮЗГУ",
    "domain": "swsu",
    "url": "jdbc:postgresql://db-host:5432/swsu_db",
    "username": "dbuser",
    "password": "dbpass"
  }
]

Жизненный цикл тенанта

  1. Добавление или обновление через API: TenantLifecycleService нормализует конфигурацию, создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет Flyway. Затем TenantConfigStore читает актуальный Secret и применяет к нему только upsert запрошенного домена.
  2. Межподовая запись: KubernetesTenantSecretUpdater выполняет GET текущего tenants-secret, нормализует и сортирует список, после чего отправляет полный объект условным PUT с прочитанным metadata.resourceVersion. При 409 Conflict актуальное состояние читается заново, а собственная мутация повторно применяется к нему. Число попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция не отправляет лишний PUT.
  3. Атомарная локальная публикация: только после успешной или подтверждённой повторным чтением персистенции одной публикацией заменяется связка TenantConfig + DataSource.
  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 и применяет добавление/удаление под тем же локальным lifecycle-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 сравнивает содержимое смонтированного из Secret tenants.json по SHA-256, а не по String.hashCode(), чтобы изменение файла не пропускалось из-за 32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие, но ещё не заменяет подключение существующего домена при изменении его URL или credentials. Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться готовым по старому подключению.

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.