refactor: Integrate Flyway for database migrations and simplify Docker Compose and tenant configurations.
This commit is contained in:
77
backend/README.md
Normal file
77
backend/README.md
Normal file
@@ -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 проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!
|
||||||
@@ -32,6 +32,12 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Flyway Database Migrations -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.magistr.app;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
|
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class Application {
|
public class Application {
|
||||||
|
|
||||||
|
|||||||
0
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Normal file → Executable file
0
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Normal file → Executable file
51
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Normal file → Executable file
51
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Normal file → Executable file
@@ -120,46 +120,37 @@ public class TenantConfigWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инициализирует БД нового тенанта: проверяет наличие таблиц,
|
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
|
||||||
* если нет — выполняет init.sql.
|
* Если БД уже существует, но история Flyway пуста —
|
||||||
|
* делает baseline (считает V1_init.sql уже выполненным).
|
||||||
*/
|
*/
|
||||||
public void initDatabaseForTenant(TenantConfig tenant) {
|
public void initDatabaseForTenant(TenantConfig tenant) {
|
||||||
String domain = tenant.getDomain();
|
String domain = tenant.getDomain();
|
||||||
try {
|
try {
|
||||||
TenantContext.setCurrentTenant(domain);
|
TenantContext.setCurrentTenant(domain);
|
||||||
|
|
||||||
if (needsInit()) {
|
log.info("[{}] Starting Flyway migrations...", domain);
|
||||||
log.info("[{}] Tables not found — executing init.sql...", domain);
|
|
||||||
executeInitSql();
|
// Получаем DataSource конкретно для этого тенанта
|
||||||
log.info("[{}] init.sql executed successfully", domain);
|
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||||
} else {
|
if (tenantDs == null) {
|
||||||
log.info("[{}] Tables already exist, skipping init", domain);
|
// Если ещё не 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) {
|
} catch (Exception e) {
|
||||||
log.error("[{}] DB init failed: {}", domain, e.getMessage());
|
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
TenantContext.clear();
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
em.setPackagesToScan("com.magistr.app.model");
|
em.setPackagesToScan("com.magistr.app.model");
|
||||||
|
|
||||||
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
||||||
vendorAdapter.setGenerateDdl(true);
|
vendorAdapter.setGenerateDdl(false);
|
||||||
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
|
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
|
||||||
em.setJpaVendorAdapter(vendorAdapter);
|
em.setJpaVendorAdapter(vendorAdapter);
|
||||||
|
|
||||||
Map<String, Object> props = new HashMap<>();
|
Map<String, Object> props = new HashMap<>();
|
||||||
props.put("hibernate.hbm2ddl.auto", "update");
|
props.put("hibernate.hbm2ddl.auto", "none");
|
||||||
props.put("hibernate.show_sql", "false");
|
props.put("hibernate.show_sql", "false");
|
||||||
em.setJpaPropertyMap(props);
|
em.setJpaPropertyMap(props);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Default (dev)",
|
"name": "Default (dev)",
|
||||||
"domain": "default",
|
"domain": "default",
|
||||||
"url": "jdbc:postgresql://192.168.1.87:5432/app_db",
|
"url": "jdbc:postgresql://db:5432/app_db",
|
||||||
"username": "myuser",
|
"username": "myuser",
|
||||||
"password": "supersecretpassword"
|
"password": "supersecretpassword"
|
||||||
}
|
}
|
||||||
|
|||||||
20
compose.yaml
20
compose.yaml
@@ -10,9 +10,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
frontend:
|
frontend:
|
||||||
container_name: frontend
|
container_name: frontend
|
||||||
restart: always
|
restart: always
|
||||||
@@ -23,6 +21,7 @@ services:
|
|||||||
- proxy
|
- proxy
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:alpine3.23
|
image: postgres:alpine3.23
|
||||||
container_name: db
|
container_name: db
|
||||||
@@ -30,21 +29,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: myuser
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: supersecretpassword
|
||||||
POSTGRES_DB: app_db
|
POSTGRES_DB: app_db
|
||||||
volumes:
|
|
||||||
- ./db/data:/var/lib/postgresql
|
|
||||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- pg_isready -U ${POSTGRES_USER} -d app_db
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
229
db/init/init.sql
229
db/init/init.sql
@@ -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 'Основное расписание занятий';
|
|
||||||
Reference in New Issue
Block a user