diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..cdcdc0f
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,77 @@
+# Руководство Backend-разработчика Magistr
+
+Добро пожаловать в проект Magistr! Этот бэкенд построен на **Spring Boot** и имеет сложную **мультитенантную архитектуру**, где одно приложение обслуживает множество независимых университетов, каждый со своей базой данных. В проекте также есть интеграция с Kubernetes для "горячего" управления этими тенантами.
+
+Здесь описано, как тут всё устроено, чтобы вы ничего не сломали.
+
+---
+
+## 1. Архитектура мультитенантности
+
+Мы используем подход **Separate Database per Tenant** (Отдельная БД для каждого клиента).
+
+- **Как приложение понимает, к какой базе обращаться?**
+ Все запросы с фронтенда приходят с заголовком `Host` (например, `swsu.zuev.company`).
+ В классе `TenantInterceptor` (находится в `config/tenant/TenantInterceptor.java`) мы перехватываем этот запрос ДО того, как он дойдёт до контроллеров, вытаскиваем поддомен (`swsu`) и сохраняем его в `ThreadLocal` переменную через класс `TenantContext`.
+
+- **Как переключаются базы данных?**
+ Класс `TenantRoutingDataSource` наследуется от спринговского `AbstractRoutingDataSource`. Перед каждым запросом в базу (любой `findById` или `save` из репозитория) Spring спрашивает этот класс: *"Какой сейчас ключ тенанта?"*. Класс берёт имя из `TenantContext` и переключает коннект на нужную БД на лету.
+
+> **Важно:** Вся логика переключения абсолютно прозрачна для бизнес-кода. В контроллерах и сервисах вы пишете обычный код (`userRepository.findAll()`), и он сам выполнится в нужной базе.
+
+---
+
+## 2. Динамическое управление тенантами (Kubernetes / ConfigMap)
+
+Бэкенд спроектирован для работы в **Kubernetes с несколькими репликами (replicas: 2+)**.
+
+Список тенантов не зашит в код:
+- В K8s он лежит в специальном `ConfigMap`, который монтируется внутрь пода как файл `tenants.json`.
+- В классе `DatabaseController` находится API для добавления нового тенанта из админки.
+- Чтобы изменения применились ко **всем подам** без перезагрузки, `DatabaseController` вызывает `ConfigMapUpdater`. Этот класс обращается напрямую к **Kubernetes API** (используя ServiceAccount токен пода) и патчит `ConfigMap`.
+- В фоне работает планировщик `TenantConfigWatcher` (каждые 30 секунд). Он следит за изменениями `tenants.json` и, если видит нового тенанта, на лету поднимает для него новый `HikariCP` пул соединений и добавляет в маршрутизатор баз данных.
+
+---
+
+## 3. Базы данных и Миграции (Flyway)
+
+Мы **НЕ используем** автоматическую генерацию таблиц через Hibernate (`spring.jpa.hibernate.ddl-auto=none`).
+Структурой баз данных правит **Flyway**.
+
+Поскольку баз данных много (они создаются динамически), стандартный Spring Boot Flyway отключён. Вместо этого `TenantConfigWatcher` вызывает Flyway **программно** в момент первого подключения нового тенанта.
+
+### 🛑 ПРАВИЛА ИЗМЕНЕНИЯ СТРУКТУРЫ БД:
+
+Если вам нужно добавить новую таблицу, колонку или изменить тип поля:
+
+1. **Запрещено трогать старые файлы миграций!**
+ Запомните: файл `V1__init.sql` (и любые другие V-файлы, которые уже попали в коммит) — **СВЯЩЕНЕН**. Если вы его измените, бэкенд не запустится на сервере с ошибкой `Migration checksum mismatch`.
+
+2. **Как правильно добавить таблицу?**
+ - Зайдите в папку `src/main/resources/db/migration/`.
+ - Создайте новый файл. Название **строго** по формату: `V<Номер>__<Описание>.sql`. Например: `V2__add_student_rating_table.sql`.
+ - Напишите в нём ваш SQL (`CREATE TABLE ...`, `ALTER TABLE ...`).
+ - Сохраните и запустите проект. Flyway **сам** пройдёт по всем базам данных тенантов и накатит этот скрипт.
+
+3. **Что если локально я накосячил в V2?**
+ Пока файл `V2_...` не залит в Git и крутится только у вас на локалке, вы можете его переписывать. Но для этого вам нужно зайти в вашу локальную БД (через DBeaver/pgAdmin), вручную откатить свои кривые изменения (удалить таблицу) и **удалить запись из истории Flyway**:
+ `DELETE FROM flyway_schema_history WHERE version = '2';`
+ Либо, что проще: удалите контейнер с локальной БД (`docker compose down -v`) и поднимите заново пустую.
+
+---
+
+## 4. Как запускать проект локально
+
+В корневой папке репозитория (где лежит `docker-compose.yaml`) поднимите инфраструктуру:
+```bash
+docker compose up -d
+```
+Соберется и запустится:
+- Фронтенд
+- Бэкенд
+- Ваша локальная тестовая PostgreSQL-база данных (на порту 5432, имя базы `app_db`, юзер `myuser`, логин/пароль см. в compose файле).
+
+Файл `backend/tenants.json` нужен для локальной разработки. Если вы запускаете бэкенд в Docker Compose, вы можете указать URL `jdbc:postgresql://db:5432/app_db` (где `db` — имя контейнера в compose сети).
+Либо, если вы тестируете взаимодействие бэкенда с вашим текущим IP-адресом (например, `192.168.1.87`), вы можете использовать этот IP. Оба варианта рабочие! Проект сразу подхватит настройки и накатит таблицы через Flyway.
+
+Контроллеры и бизнес-логику пишите как в обычном Spring Boot проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!
diff --git a/backend/pom.xml b/backend/pom.xml
index c5da69f..2a160d5 100755
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -32,6 +32,12 @@
org.springframework.boot
spring-boot-starter-data-jpa
+
+
+
+ org.flywaydb
+ flyway-core
+
org.postgresql
postgresql
diff --git a/backend/src/main/java/com/magistr/app/Application.java b/backend/src/main/java/com/magistr/app/Application.java
index 85ad604..7fe05b7 100755
--- a/backend/src/main/java/com/magistr/app/Application.java
+++ b/backend/src/main/java/com/magistr/app/Application.java
@@ -2,10 +2,11 @@ package com.magistr.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
-@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
@EnableScheduling
public class Application {
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
old mode 100644
new mode 100755
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
old mode 100644
new mode 100755
index fa673da..6cff252
--- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
@@ -120,46 +120,37 @@ public class TenantConfigWatcher {
}
/**
- * Инициализирует БД нового тенанта: проверяет наличие таблиц,
- * если нет — выполняет init.sql.
+ * Выполняет миграции Flyway для конкретного тенанта пи подключении.
+ * Если БД уже существует, но история Flyway пуста —
+ * делает baseline (считает V1_init.sql уже выполненным).
*/
public void initDatabaseForTenant(TenantConfig tenant) {
String domain = tenant.getDomain();
try {
TenantContext.setCurrentTenant(domain);
- if (needsInit()) {
- log.info("[{}] Tables not found — executing init.sql...", domain);
- executeInitSql();
- log.info("[{}] init.sql executed successfully", domain);
- } else {
- log.info("[{}] Tables already exist, skipping init", domain);
+ log.info("[{}] Starting Flyway migrations...", domain);
+
+ // Получаем DataSource конкретно для этого тенанта
+ javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
+ if (tenantDs == null) {
+ // Если ещё не resolve'нулся (первый запуск), берём обёртку
+ tenantDs = dataSource;
}
+
+ org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
+ .dataSource(tenantDs)
+ .baselineOnMigrate(true)
+ .baselineVersion("1")
+ .load();
+
+ flyway.migrate();
+ log.info("[{}] Flyway migrations completed successfully", domain);
+
} catch (Exception e) {
- log.error("[{}] DB init failed: {}", domain, e.getMessage());
+ log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
} finally {
TenantContext.clear();
}
}
-
- private boolean needsInit() {
- try (Connection conn = dataSource.getConnection();
- ResultSet rs = conn.getMetaData().getTables(null, null, "users", new String[]{"TABLE"})) {
- return !rs.next();
- } catch (Exception e) {
- return true;
- }
- }
-
- private void executeInitSql() throws Exception {
- String sql;
- try (InputStream is = new ClassPathResource("init.sql").getInputStream();
- BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
- sql = reader.lines().collect(Collectors.joining("\n"));
- }
- try (Connection conn = dataSource.getConnection();
- Statement stmt = conn.createStatement()) {
- stmt.execute(sql);
- }
- }
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
index 8a2af55..71ced9e 100755
--- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
@@ -101,12 +101,12 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
em.setPackagesToScan("com.magistr.app.model");
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
- vendorAdapter.setGenerateDdl(true);
+ vendorAdapter.setGenerateDdl(false);
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
em.setJpaVendorAdapter(vendorAdapter);
Map props = new HashMap<>();
- props.put("hibernate.hbm2ddl.auto", "update");
+ props.put("hibernate.hbm2ddl.auto", "none");
props.put("hibernate.show_sql", "false");
em.setJpaPropertyMap(props);
diff --git a/backend/src/main/resources/init.sql b/backend/src/main/resources/db/migration/V1__init.sql
similarity index 100%
rename from backend/src/main/resources/init.sql
rename to backend/src/main/resources/db/migration/V1__init.sql
diff --git a/backend/tenants.json b/backend/tenants.json
index 4641650..704573b 100755
--- a/backend/tenants.json
+++ b/backend/tenants.json
@@ -2,7 +2,7 @@
{
"name": "Default (dev)",
"domain": "default",
- "url": "jdbc:postgresql://192.168.1.87:5432/app_db",
+ "url": "jdbc:postgresql://db:5432/app_db",
"username": "myuser",
"password": "supersecretpassword"
}
diff --git a/compose.yaml b/compose.yaml
index a918b89..0e3481d 100755
--- a/compose.yaml
+++ b/compose.yaml
@@ -10,9 +10,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
networks:
- proxy
- depends_on:
- db:
- condition: service_healthy
+
frontend:
container_name: frontend
restart: always
@@ -23,6 +21,7 @@ services:
- proxy
depends_on:
- backend
+
db:
image: postgres:alpine3.23
container_name: db
@@ -30,21 +29,12 @@ services:
ports:
- "5432:5432"
environment:
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_USER: myuser
+ POSTGRES_PASSWORD: supersecretpassword
POSTGRES_DB: app_db
- volumes:
- - ./db/data:/var/lib/postgresql
- - ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- proxy
- healthcheck:
- test:
- - CMD-SHELL
- - pg_isready -U ${POSTGRES_USER} -d app_db
- interval: 10s
- timeout: 5s
- retries: 5
+
networks:
proxy:
external: true
diff --git a/db/init/init.sql b/db/init/init.sql
deleted file mode 100755
index 3c3339b..0000000
--- a/db/init/init.sql
+++ /dev/null
@@ -1,229 +0,0 @@
--- ==========================================
--- Инициализация расширений
--- ==========================================
-CREATE EXTENSION IF NOT EXISTS pgcrypto;
-
--- ==========================================
--- Пользователи и роли
--- ==========================================
-CREATE TABLE IF NOT EXISTS users (
- id BIGSERIAL PRIMARY KEY,
- username VARCHAR(50) UNIQUE NOT NULL,
- password VARCHAR(255) NOT NULL,
- role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-
--- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
-INSERT INTO users (username, password, role)
-VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN'),
- ('Тестовый преподаватель', '1234567890', 'TEACHER')
-ON CONFLICT (username) DO NOTHING;
-
--- ==========================================
--- Образовательные формы
--- ==========================================
-CREATE TABLE IF NOT EXISTS education_forms (
- id BIGSERIAL PRIMARY KEY,
- name VARCHAR(100) UNIQUE NOT NULL,
- description TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-
-INSERT INTO education_forms (name) VALUES
- ('Бакалавриат'),
- ('Магистратура'),
- ('Специалитет')
-ON CONFLICT (name) DO NOTHING;
-
--- ==========================================
--- Учебные группы
--- ==========================================
-CREATE TABLE IF NOT EXISTS student_groups (
- id BIGSERIAL PRIMARY KEY,
- name VARCHAR(100) UNIQUE NOT NULL,
- group_size BIGINT NOT NULL,
- education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
- course INT CHECK (course BETWEEN 1 AND 6),
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-
--- Тестовая базовая группа для работы
-INSERT INTO student_groups (name, group_size, education_form_id, course)
-VALUES ('ИВТ-21-1', 25, 1, 3),
- ('ИБ-41м', 15, 2, 2)
-ON CONFLICT (name) DO NOTHING;
-
--- ==========================================
--- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
--- ==========================================
-CREATE TABLE IF NOT EXISTS subgroups (
- id BIGSERIAL PRIMARY KEY,
- group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
- name VARCHAR(100) NOT NULL,
- student_capacity INT,
- UNIQUE(group_id, name)
-);
-
--- ==========================================
--- Справочники
--- ==========================================
-
--- Дисциплины
-CREATE TABLE IF NOT EXISTS subjects (
- id BIGSERIAL PRIMARY KEY,
- name VARCHAR(200) UNIQUE NOT NULL,
- code VARCHAR(20),
- description TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-
-INSERT INTO subjects (name) VALUES
- ('Высшая математика'),
- ('Философия'),
- ('Информатика'),
- ('Базы данных'),
- ('Английский язык')
-ON CONFLICT (name) DO NOTHING;
-
--- Типы занятий
-CREATE TABLE IF NOT EXISTS lesson_types (
- id BIGSERIAL PRIMARY KEY,
- name VARCHAR(50) UNIQUE NOT NULL,
- color_code VARCHAR(7) DEFAULT '#3788d8', -- для цветовой индикации в календаре
- duration_minutes INT DEFAULT 90
-);
-
-INSERT INTO lesson_types (name, color_code) VALUES
- ('Лекция', '#FF6B6B'),
- ('Практика', '#4ECDC4'),
- ('Лабораторная работа', '#45B7D1')
-ON CONFLICT (name) DO NOTHING;
-
--- Оборудование
-CREATE TABLE IF NOT EXISTS equipments (
- id BIGSERIAL PRIMARY KEY,
- name VARCHAR(50) UNIQUE NOT NULL,
- description TEXT,
- inventory_number VARCHAR(50)
-);
-
-INSERT INTO equipments (name) VALUES
- ('Проектор'),
- ('ПК'),
- ('Лаборатория'),
- ('Интерактивная доска'),
- ('Документ-камера'),
- ('Аудиосистема')
-ON CONFLICT (name) DO NOTHING;
-
--- Аудитории
-CREATE TABLE IF NOT EXISTS classrooms (
- id BIGSERIAL PRIMARY KEY,
- name VARCHAR(50) UNIQUE NOT NULL,
- capacity INT NOT NULL CHECK (capacity > 0),
- building VARCHAR(50),
- floor INT,
- is_available BOOLEAN DEFAULT TRUE,
- description TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-
-INSERT INTO classrooms (name, capacity, building, floor) VALUES
- ('101 Ленинская', 120, 'Главный корпус', 1),
- ('202 IT Lab', 20, 'Корпус IT', 2),
- ('303 Обычная', 30, 'Главный корпус', 3)
-ON CONFLICT (name) DO NOTHING;
-
--- Привязка оборудования к аудиториям (Many-to-Many)
-CREATE TABLE IF NOT EXISTS classroom_equipments (
- classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
- equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
- quantity INT DEFAULT 1 CHECK (quantity > 0),
- notes TEXT,
- PRIMARY KEY (classroom_id, equipment_id)
-);
-
--- Заполнение привязок оборудования с использованием подзапросов
-INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
-SELECT c.id, e.id,
- CASE
- WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
- WHEN e.name = 'ПК' THEN 1
- ELSE 1
- END
-FROM classrooms c, equipments e
-WHERE
- (c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
- OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
- OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
-ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
-
--- ==========================================
--- Связи для преподавателей
--- ==========================================
-
--- Привязка преподавателей к дисциплинам
-CREATE TABLE IF NOT EXISTS teacher_subjects (
- user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
- qualification_level VARCHAR(50),
- experience_years INT,
- PRIMARY KEY(user_id, subject_id)
-);
-
--- Какие типы занятий может вести преподаватель по дисциплине
-CREATE TABLE IF NOT EXISTS teacher_lesson_types (
- user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
- lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
- PRIMARY KEY (user_id, subject_id, lesson_type_id)
-);
-
--- ==========================================
--- Основная таблица Расписания (Lessons)
--- ==========================================
-CREATE TABLE IF NOT EXISTS lessons (
- id BIGSERIAL PRIMARY KEY,
- teacher_id BIGINT NOT NULL REFERENCES users(id),
- group_id BIGINT NOT NULL REFERENCES student_groups(id),
- subject_id BIGINT NOT NULL REFERENCES subjects(id),
- lesson_format VARCHAR(255) NOT NULL,
- type_lesson VARCHAR(255) NOT NULL,
- classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
- day VARCHAR(255) NOT NULL,
- week VARCHAR(255) NOT NULL,
- time VARCHAR(255) NOT NULL
-);
-
-INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES
- (2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'),
- (1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'),
- (2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'),
- (1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'),
- (2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'),
- (1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30');
-
--- ==========================================
--- Функция обновления timestamp
--- ==========================================
-CREATE OR REPLACE FUNCTION update_updated_at_column()
-RETURNS TRIGGER AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
--- Триггеры для обновления updated_at
-CREATE TRIGGER update_users_updated_at
- BEFORE UPDATE ON users
- FOR EACH ROW
- EXECUTE FUNCTION update_updated_at_column();
-
--- ==========================================
--- Комментарии к таблицам и полям (для документации)
--- ==========================================
-COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
-COMMENT ON TABLE lessons IS 'Основное расписание занятий';
\ No newline at end of file