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

## Общая схема

```mermaid
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.

### Принцип работы

```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<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`

Формат:
```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 <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.

Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.

`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
