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