diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java new file mode 100644 index 0000000..2d7c46d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java @@ -0,0 +1,41 @@ +package com.magistr.app.config.tenant; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Модель конфигурации тенанта (университета). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class TenantConfig { + + private String name; // "ЮЗГУ", "МГУ" + private String domain; // "swsu", "mgu" (поддомен) + private String url; // "jdbc:postgresql://192.168.1.50:5432/magistr_db" + private String username; + private String password; + + public TenantConfig() {} + + public TenantConfig(String name, String domain, String url, String username, String password) { + this.name = name; + this.domain = domain; + this.url = url; + this.username = username; + this.password = password; + } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDomain() { return domain; } + public void setDomain(String domain) { this.domain = domain; } + + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java new file mode 100644 index 0000000..1822299 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java @@ -0,0 +1,22 @@ +package com.magistr.app.config.tenant; + +/** + * ThreadLocal хранилище текущего тенанта (домена). + * Устанавливается в TenantInterceptor на каждый HTTP-запрос. + */ +public class TenantContext { + + private static final ThreadLocal CURRENT_TENANT = new ThreadLocal<>(); + + public static String getCurrentTenant() { + return CURRENT_TENANT.get(); + } + + public static void setCurrentTenant(String tenant) { + CURRENT_TENANT.set(tenant); + } + + public static void clear() { + CURRENT_TENANT.remove(); + } +} 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 new file mode 100644 index 0000000..6208446 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -0,0 +1,118 @@ +package com.magistr.app.config.tenant; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.sql.DataSource; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Конфигурация мультитенантного DataSource. + * Загружает тенанты из JSON-файла и регистрирует TenantInterceptor. + */ +@Configuration +public class TenantDataSourceConfig implements WebMvcConfigurer { + + private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class); + + @Value("${app.tenants.config-path:tenants.json}") + private String tenantsConfigPath; + + @Value("${spring.datasource.url:}") + private String defaultDbUrl; + + @Value("${spring.datasource.username:}") + private String defaultDbUsername; + + @Value("${spring.datasource.password:}") + private String defaultDbPassword; + + @Bean + public TenantRoutingDataSource tenantRoutingDataSource() { + TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource(); + + // Загружаем тенантов из JSON + List tenants = loadTenantsFromFile(); + + // Если нет тенантов и есть дефолтный datasource — создаём "default" тенант + if (tenants.isEmpty() && !defaultDbUrl.isBlank()) { + TenantConfig defaultTenant = new TenantConfig( + "Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword + ); + tenants.add(defaultTenant); + log.info("No tenants config found, using default datasource: {}", defaultDbUrl); + } + + // Регистрируем тенантов + for (TenantConfig tenant : tenants) { + try { + routingDataSource.addTenant(tenant); + } catch (Exception e) { + log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage()); + } + } + + if (tenants.isEmpty()) { + log.warn("No tenants configured! Backend will fail on DB queries."); + } + + return routingDataSource; + } + + @Bean + public DataSource dataSource(TenantRoutingDataSource tenantRoutingDataSource) { + return tenantRoutingDataSource; + } + + @Bean + public TenantInterceptor tenantInterceptor() { + return new TenantInterceptor(); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**"); + } + + private List loadTenantsFromFile() { + File file = new File(tenantsConfigPath); + if (!file.exists()) { + log.info("Tenants config file not found: {}", tenantsConfigPath); + return new ArrayList<>(); + } + + try { + ObjectMapper mapper = new ObjectMapper(); + List list = mapper.readValue(file, new TypeReference<>() {}); + log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath); + return list; + } catch (IOException e) { + log.error("Failed to read tenants config: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Сохраняет текущую конфигурацию тенантов в JSON-файл. + */ + public void saveTenantsToFile(TenantRoutingDataSource routingDataSource) { + try { + ObjectMapper mapper = new ObjectMapper(); + List configs = new ArrayList<>(routingDataSource.getTenantConfigs().values()); + mapper.writerWithDefaultPrettyPrinter().writeValue(new File(tenantsConfigPath), configs); + log.info("Saved {} tenant(s) to {}", configs.size(), tenantsConfigPath); + } catch (IOException e) { + log.error("Failed to save tenants config: {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java new file mode 100644 index 0000000..9d88f2a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java @@ -0,0 +1,57 @@ +package com.magistr.app.config.tenant; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * Interceptor: извлекает поддомен из Host header и кладёт в TenantContext. + * + * Примеры: + * "swsu.zuev.company" → tenant = "swsu" + * "mgu.zuev.company" → tenant = "mgu" + * "localhost" → tenant = "default" + * "localhost:8080" → tenant = "default" + */ +public class TenantInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(TenantInterceptor.class); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String host = request.getHeader("Host"); + String tenant = resolveTenant(host); + TenantContext.setCurrentTenant(tenant); + log.debug("Resolved tenant '{}' from Host '{}'", tenant, host); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + TenantContext.clear(); + } + + private String resolveTenant(String host) { + if (host == null || host.isBlank()) { + return "default"; + } + + // Убираем порт (localhost:8080 → localhost) + String hostname = host.contains(":") ? host.substring(0, host.indexOf(':')) : host; + + // localhost или IP → default + if ("localhost".equalsIgnoreCase(hostname) || hostname.matches("\\d+\\.\\d+\\.\\d+\\.\\d+")) { + return "default"; + } + + // Извлекаем первый поддомен: swsu.zuev.company → swsu + int firstDot = hostname.indexOf('.'); + if (firstDot > 0) { + return hostname.substring(0, firstDot).toLowerCase(); + } + + return "default"; + } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java new file mode 100644 index 0000000..03d656a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java @@ -0,0 +1,124 @@ +package com.magistr.app.config.tenant; + +import com.zaxxer.hikari.HikariDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * DataSource, который переключается между БД разных тенантов. + * На каждый запрос determineCurrentLookupKey() возвращает текущий тенант из TenantContext. + */ +public class TenantRoutingDataSource extends AbstractRoutingDataSource { + + private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class); + + private final Map tenantConfigs = new ConcurrentHashMap<>(); + private final Map dataSources = new ConcurrentHashMap<>(); + + @Override + protected Object determineCurrentLookupKey() { + String tenant = TenantContext.getCurrentTenant(); + return tenant != null ? tenant : "default"; + } + + /** + * Добавляет тенант и создаёт для него HikariCP пул. + */ + public void addTenant(TenantConfig config) { + String domain = config.getDomain().toLowerCase(); + HikariDataSource ds = createDataSource(config); + + dataSources.put(domain, ds); + tenantConfigs.put(domain, config); + + // Обновляем target data sources + setTargetDataSources(dataSources); + afterPropertiesSet(); + + log.info("Added tenant '{}' -> {}", domain, config.getUrl()); + } + + /** + * Удаляет тенант и закрывает его пул соединений. + */ + public void removeTenant(String domain) { + domain = domain.toLowerCase(); + Object removed = dataSources.remove(domain); + tenantConfigs.remove(domain); + + if (removed instanceof HikariDataSource ds) { + ds.close(); + log.info("Removed and closed tenant '{}'", domain); + } + + setTargetDataSources(dataSources); + afterPropertiesSet(); + } + + /** + * Проверяет подключение к БД для указанного тенанта. + */ + public boolean testConnection(String domain) { + DataSource ds = (DataSource) dataSources.get(domain.toLowerCase()); + if (ds == null) return false; + + try (Connection conn = ds.getConnection()) { + return conn.isValid(5); + } catch (SQLException e) { + log.warn("Connection test failed for tenant '{}': {}", domain, e.getMessage()); + return false; + } + } + + /** + * Тестирует подключение по произвольным параметрам (без регистрации тенанта). + */ + public String testExternalConnection(String url, String username, String password) { + HikariDataSource ds = new HikariDataSource(); + ds.setJdbcUrl(url); + ds.setUsername(username); + ds.setPassword(password); + ds.setMaximumPoolSize(1); + ds.setConnectionTimeout(5000); + + try (Connection conn = ds.getConnection()) { + if (conn.isValid(5)) { + return "OK"; + } + return "Подключение не валидно"; + } catch (Exception e) { + return e.getMessage(); + } finally { + ds.close(); + } + } + + public Map getTenantConfigs() { + return tenantConfigs; + } + + public boolean hasTenant(String domain) { + return tenantConfigs.containsKey(domain.toLowerCase()); + } + + private HikariDataSource createDataSource(TenantConfig config) { + HikariDataSource ds = new HikariDataSource(); + ds.setJdbcUrl(config.getUrl()); + ds.setUsername(config.getUsername()); + ds.setPassword(config.getPassword()); + ds.setPoolName("tenant-" + config.getDomain()); + ds.setMaximumPoolSize(10); + ds.setMinimumIdle(2); + ds.setConnectionTimeout(10000); + ds.setIdleTimeout(300000); + ds.setMaxLifetime(600000); + return ds; + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java new file mode 100644 index 0000000..74cb460 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java @@ -0,0 +1,161 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.config.tenant.TenantDataSourceConfig; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * API управления подключениями к базам данных (тенантами). + * Доступно только для ADMIN. + */ +@RestController +@RequestMapping("/api/database") +public class DatabaseController { + + private final TenantRoutingDataSource routingDataSource; + private final TenantDataSourceConfig dataSourceConfig; + + public DatabaseController(TenantRoutingDataSource routingDataSource, + TenantDataSourceConfig dataSourceConfig) { + this.routingDataSource = routingDataSource; + this.dataSourceConfig = dataSourceConfig; + } + + /** + * Статус текущего подключения (по домену запроса). + */ + @GetMapping("/status") + public ResponseEntity> getStatus() { + String currentTenant = TenantContext.getCurrentTenant(); + boolean connected = routingDataSource.testConnection(currentTenant); + + TenantConfig config = routingDataSource.getTenantConfigs().get(currentTenant); + + Map result = new HashMap<>(); + result.put("tenant", currentTenant); + result.put("connected", connected); + result.put("configured", config != null); + if (config != null) { + result.put("name", config.getName()); + result.put("url", maskPassword(config.getUrl())); + } + return ResponseEntity.ok(result); + } + + /** + * Список всех тенантов. + */ + @GetMapping("/tenants") + public ResponseEntity>> getTenants() { + List> result = new ArrayList<>(); + + for (TenantConfig config : routingDataSource.getTenantConfigs().values()) { + Map tenant = new HashMap<>(); + tenant.put("name", config.getName()); + tenant.put("domain", config.getDomain()); + tenant.put("url", maskPassword(config.getUrl())); + tenant.put("username", config.getUsername()); + tenant.put("connected", routingDataSource.testConnection(config.getDomain())); + result.add(tenant); + } + + return ResponseEntity.ok(result); + } + + /** + * Добавить новый тенант. + */ + @PostMapping("/tenants") + public ResponseEntity> addTenant(@RequestBody TenantConfig config) { + Map result = new HashMap<>(); + + if (config.getDomain() == null || config.getDomain().isBlank()) { + result.put("success", false); + result.put("message", "Домен не может быть пустым"); + return ResponseEntity.badRequest().body(result); + } + + if (config.getUrl() == null || config.getUrl().isBlank()) { + result.put("success", false); + result.put("message", "URL базы данных не может быть пустым"); + return ResponseEntity.badRequest().body(result); + } + + if (routingDataSource.hasTenant(config.getDomain())) { + // Обновляем существующий + routingDataSource.removeTenant(config.getDomain()); + } + + try { + routingDataSource.addTenant(config); + dataSourceConfig.saveTenantsToFile(routingDataSource); + + result.put("success", true); + result.put("message", "Тенант '" + config.getDomain() + "' добавлен"); + return ResponseEntity.ok(result); + } catch (Exception e) { + result.put("success", false); + result.put("message", "Ошибка: " + e.getMessage()); + return ResponseEntity.internalServerError().body(result); + } + } + + /** + * Удалить тенант. + */ + @DeleteMapping("/tenants/{domain}") + public ResponseEntity> removeTenant(@PathVariable String domain) { + Map result = new HashMap<>(); + + if (!routingDataSource.hasTenant(domain)) { + result.put("success", false); + result.put("message", "Тенант '" + domain + "' не найден"); + return ResponseEntity.status(404).body(result); + } + + routingDataSource.removeTenant(domain); + dataSourceConfig.saveTenantsToFile(routingDataSource); + + result.put("success", true); + result.put("message", "Тенант '" + domain + "' удалён"); + return ResponseEntity.ok(result); + } + + /** + * Тест подключения к произвольной БД. + */ + @PostMapping("/test") + public ResponseEntity> testConnection(@RequestBody Map params) { + String url = params.get("url"); + String username = params.get("username"); + String password = params.get("password"); + + Map result = new HashMap<>(); + + if (url == null || url.isBlank()) { + result.put("success", false); + result.put("message", "URL не указан"); + return ResponseEntity.badRequest().body(result); + } + + String testResult = routingDataSource.testExternalConnection(url, username, password); + boolean success = "OK".equals(testResult); + + result.put("success", success); + result.put("message", success ? "Подключение успешно!" : testResult); + return ResponseEntity.ok(result); + } + + private String maskPassword(String url) { + // Маскируем пароль в JDBC URL, если нужно + return url; + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 05625aa..4d4e315 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,15 +1,18 @@ server.port=8080 -# PostgreSQL +# PostgreSQL (дефолтный — для локальной разработки через Docker Compose) spring.datasource.url=jdbc:postgresql://db:5432/app_db -spring.datasource.username=${POSTGRES_USER} -spring.datasource.password=${POSTGRES_PASSWORD} +spring.datasource.username=${POSTGRES_USER:myuser} +spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword} spring.datasource.driver-class-name=org.postgresql.Driver # JPA -spring.jpa.hibernate.ddl-auto=validate +spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.open-in-view=false -#Eta nastroyka otvechayet za vklyucheniye vidimosti logov urovnya DEBUG v logakh BE, poka vyklyuchil chtoby ne zasoryat'. Zapisi INFO otobrazhat'sya budut +# Мультитенантность +app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} + #logging.level.root=DEBUG + diff --git a/backend/tenants.json b/backend/tenants.json new file mode 100644 index 0000000..0fb6e00 --- /dev/null +++ b/backend/tenants.json @@ -0,0 +1,9 @@ +[ + { + "name": "Default (dev)", + "domain": "default", + "url": "jdbc:postgresql://db:5432/app_db", + "username": "myuser", + "password": "supersecretpassword" + } +] diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 860acd3..92dcb95 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -95,6 +95,14 @@ Расписание занятий + + + + + + + База данных +