исправление багов

This commit is contained in:
Zuev
2026-07-13 03:28:18 +03:00
parent 39c58440cf
commit 85f61436b6
76 changed files with 9219 additions and 1258 deletions

View File

@@ -1,39 +0,0 @@
package com.magistr.app.config;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/**
* При запуске приложения инициализирует БД для каждого тенанта.
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
*/
@Component
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final TenantRoutingDataSource routingDataSource;
private final TenantConfigWatcher configWatcher;
public DataInitializer(TenantRoutingDataSource routingDataSource,
TenantConfigWatcher configWatcher) {
this.routingDataSource = routingDataSource;
this.configWatcher = configWatcher;
}
@Override
public void run(String... args) {
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
configWatcher.initDatabaseForTenant(tenant);
}
log.info("Database initialization complete");
}
}

View File

@@ -25,6 +25,8 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
AuthContext.clear();
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
@@ -42,13 +44,13 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
return false;
}
AuthContext.setCurrentUser(user);
RequireRoles roles = resolveRoles(handler);
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
return false;
}
AuthContext.setCurrentUser(user);
return true;
}

View File

@@ -4,13 +4,34 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Set;
@Component
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
private static final Set<String> LEGACY_INSECURE_SECRET_FINGERPRINTS = Set.of(
"884e52f1fbb3b0caf409bc76a14ba8e601d6117be5a9188319f2e84f9e19a853",
"54f5809ada311ba1e12146a14db39ede2082b6f73ca7749bae8cd8450726eaf9"
);
private static final Set<String> PLACEHOLDER_MARKERS = Set.of(
"change-me",
"change_this",
"change-this",
"dev-only",
"placeholder",
"replace-with",
"replace_this",
"replace-this"
);
private String secret;
private Duration accessTtl = Duration.ofMinutes(15);
private Duration refreshTtl = Duration.ofDays(7);
private String refreshCookieName = "magistr_refresh";
@@ -57,10 +78,37 @@ public class JwtProperties {
}
public byte[] secretBytes() {
byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("JWT_SECRET обязателен и не может быть пустым");
}
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
}
String normalized = secret.toLowerCase(Locale.ROOT);
if (PLACEHOLDER_MARKERS.stream().anyMatch(normalized::contains)
|| LEGACY_INSECURE_SECRET_FINGERPRINTS.contains(sha256(bytes))) {
throw new IllegalStateException("JWT_SECRET содержит известное небезопасное или шаблонное значение");
}
return bytes;
}
public void validateForEnvironment(boolean production) {
secretBytes();
if (production && !refreshCookieSecure) {
throw new IllegalStateException(
"В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true"
);
}
}
private String sha256(byte[] value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", e);
}
}
}

View File

@@ -0,0 +1,24 @@
package com.magistr.app.config.auth;
import jakarta.annotation.PostConstruct;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
@Component
public class JwtSecurityStartupValidator {
private final JwtProperties properties;
private final Environment environment;
public JwtSecurityStartupValidator(JwtProperties properties, Environment environment) {
this.properties = properties;
this.environment = environment;
}
@PostConstruct
void validate() {
boolean production = environment.acceptsProfiles(Profiles.of("prod", "production"));
properties.validateForEnvironment(production);
}
}

View File

@@ -55,7 +55,7 @@ public class RefreshTokenService {
LocalDateTime now = LocalDateTime.now();
String currentHash = hashToken(rawToken);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHash(currentHash);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
if (storedOpt.isEmpty()) {
return Optional.empty();
}

View File

@@ -1,125 +0,0 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
*
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
*/
@Service
public class ConfigMapUpdater {
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
private static final String CONFIGMAP_NAME = "tenants-config";
private final ObjectMapper objectMapper = new ObjectMapper();
private final boolean runningInK8s;
public ConfigMapUpdater() {
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
if (!runningInK8s) {
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
}
}
/**
* Обновляет ConfigMap tenants-config с новым списком тенантов.
* @return true если обновление успешно (или мы не в K8s)
*/
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInK8s) {
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
return true;
}
try {
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
// Формируем JSON для тенантов
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(tenants);
// Strategic merge patch для ConfigMap
String patchBody = objectMapper.writeValueAsString(Map.of(
"data", Map.of("tenants.json", tenantsJson)
));
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
K8S_API_BASE, namespace, CONFIGMAP_NAME);
// Создаём HttpClient с отключённой проверкой сертификатов
// (внутри кластера используется self-signed CA)
HttpClient client = createInsecureClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/strategic-merge-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
return true;
} else {
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
return false;
}
} catch (Exception e) {
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
return false;
}
}
/**
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
*/
private HttpClient createInsecureClient() {
try {
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder()
.sslContext(sslContext)
.build();
} catch (Exception e) {
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
return HttpClient.newHttpClient();
}
}
}

View File

@@ -0,0 +1,178 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManagerFactory;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.util.Base64;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* Обновляет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
*
* <p>Работает только внутри Kubernetes pod и использует service-account token и
* service-account CA. В локальной разработке персистенция пропускается.</p>
*/
@Service
public class KubernetesTenantSecretUpdater {
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
private static final Path DEFAULT_TOKEN_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/token");
private static final Path DEFAULT_NAMESPACE_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/namespace");
private static final Path DEFAULT_CA_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
private final ObjectMapper objectMapper;
private final Path tokenPath;
private final Path namespacePath;
private final Path caPath;
private final String apiBase;
private final String secretName;
private final boolean runningInKubernetes;
public KubernetesTenantSecretUpdater() {
this(
DEFAULT_TOKEN_PATH,
DEFAULT_NAMESPACE_PATH,
DEFAULT_CA_PATH,
DEFAULT_API_BASE,
DEFAULT_SECRET_NAME,
new ObjectMapper()
);
}
KubernetesTenantSecretUpdater(Path tokenPath,
Path namespacePath,
Path caPath,
String apiBase,
String secretName,
ObjectMapper objectMapper) {
this.tokenPath = tokenPath;
this.namespacePath = namespacePath;
this.caPath = caPath;
this.apiBase = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
this.secretName = secretName;
this.objectMapper = objectMapper;
this.runningInKubernetes = Files.exists(tokenPath);
if (!runningInKubernetes) {
log.info("Приложение запущено вне Kubernetes — обновление tenant Secret будет пропущено");
}
}
/**
* Сохраняет полный список тенантов в ключе {@code tenants.json} Kubernetes Secret.
*
* @return {@code true}, если Secret обновлён или приложение запущено вне Kubernetes
*/
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
if (!runningInKubernetes) {
log.warn("Приложение запущено вне Kubernetes, персистенция tenant Secret пропущена");
return true;
}
try {
String token = Files.readString(tokenPath).trim();
String namespace = Files.readString(namespacePath).trim();
if (token.isBlank() || namespace.isBlank()) {
throw new IllegalStateException("ServiceAccount token или namespace не настроены");
}
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
String encodedTenants = Base64.getEncoder()
.encodeToString(tenantsJson.getBytes(StandardCharsets.UTF_8));
String patchBody = objectMapper.writeValueAsString(Map.of(
"data", Map.of("tenants.json", encodedTenants)
));
URI uri = URI.create(String.format(
"%s/api/v1/namespaces/%s/secrets/%s",
apiBase,
namespace,
secretName
));
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/strategic-merge-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
.build();
HttpResponse<Void> response = createSecureClient(caPath)
.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() == 200) {
log.info("Tenant Secret успешно обновлён: tenantCount={}", tenants.size());
return true;
}
log.error("Kubernetes API отклонил обновление tenant Secret: httpStatus={}", response.statusCode());
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Обновление tenant Secret прервано");
log.debug("Технические детали прерывания tenant Secret", e);
return false;
} catch (Exception e) {
log.error("Не удалось безопасно обновить tenant Secret: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали ошибки tenant Secret", e);
return false;
}
}
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certificates;
try (InputStream input = Files.newInputStream(serviceAccountCaPath)) {
certificates = certificateFactory.generateCertificates(input);
}
if (certificates.isEmpty()) {
throw new IllegalStateException("ServiceAccount CA не содержит сертификатов");
}
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
int index = 0;
for (Certificate certificate : certificates) {
trustStore.setCertificateEntry("kubernetes-ca-" + index++, certificate);
}
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
);
trustManagerFactory.init(trustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
SSLParameters sslParameters = new SSLParameters();
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
return HttpClient.newBuilder()
.sslContext(sslContext)
.sslParameters(sslParameters)
.build();
}
}

View File

@@ -2,25 +2,23 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.service.TenantLifecycleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.*;
import java.util.stream.Collectors;
import java.util.List;
/**
* Периодически перечитывает tenants.json (mounted ConfigMap).
* Если ConfigMap был обновлён через K8s API, этот компонент
* Периодически перечитывает tenants.json (mounted Secret).
* Если Secret был обновлён через K8s API, этот компонент
* подхватит изменения и синхронизирует in-memory datasource'ы.
*
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
@@ -30,8 +28,7 @@ public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
private final TenantRoutingDataSource routingDataSource;
private final DataSource dataSource;
private final TenantLifecycleService tenantLifecycleService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${app.tenants.config-path:tenants.json}")
@@ -40,9 +37,8 @@ public class TenantConfigWatcher {
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
private String lastConfigHash = "";
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
this.routingDataSource = routingDataSource;
this.dataSource = dataSource;
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService) {
this.tenantLifecycleService = tenantLifecycleService;
}
/**
@@ -50,6 +46,10 @@ public class TenantConfigWatcher {
*/
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void watchForChanges() {
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
}
private void watchForChangesSerialized() {
try {
File file = new File(tenantsConfigPath);
if (!file.exists()) return;
@@ -62,20 +62,24 @@ public class TenantConfigWatcher {
}
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
lastConfigHash = hash;
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
lastConfigHash = hash;
} catch (Exception e) {
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
log.debug("Технические детали синхронизации конфига тенантов", e);
}
}
/**
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
* Обновляет хеш конфига после ручного обновления Secret с этого же пода.
*/
public void refreshHash() {
tenantLifecycleService.executeSerialized(this::refreshHashSerialized);
}
private void refreshHashSerialized() {
try {
File file = new File(tenantsConfigPath);
if (file.exists()) {
@@ -83,7 +87,9 @@ public class TenantConfigWatcher {
lastConfigHash = configHash(content);
}
} catch (Exception e) {
log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали обновления хеша тенантов", e);
}
}
@@ -100,63 +106,7 @@ public class TenantConfigWatcher {
* Синхронизирует in-memory тенантов с конфигом из файла.
*/
private void syncTenants(List<TenantConfig> newTenants) {
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
Set<String> newDomains = newTenants.stream()
.map(t -> t.getDomain().toLowerCase())
.collect(Collectors.toSet());
// Добавить новые тенанты
for (TenantConfig tenant : newTenants) {
String domain = tenant.getDomain().toLowerCase();
if (!current.containsKey(domain)) {
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
routingDataSource.addTenant(tenant);
// Инициализируем БД для нового тенанта
initDatabaseForTenant(tenant);
}
}
// Удалить тенанты, которых больше нет в конфиге
for (String existingDomain : new ArrayList<>(current.keySet())) {
if (!newDomains.contains(existingDomain)) {
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
routingDataSource.removeTenant(existingDomain);
}
}
tenantLifecycleService.synchronizeFromPersistedConfig(newTenants);
}
/**
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
* Если БД уже существует, но история Flyway пуста —
* делает baseline (считает V1_init.sql уже выполненным).
*/
public void initDatabaseForTenant(TenantConfig tenant) {
String domain = tenant.getDomain();
try {
TenantContext.setCurrentTenant(domain);
log.info("[{}] Запускаем миграции Flyway", 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 успешно выполнены", domain);
} catch (Exception e) {
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
} finally {
TenantContext.clear();
}
}
}

View File

@@ -2,6 +2,8 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.service.TenantDatabaseMigrationService;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -17,6 +19,7 @@ import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.io.File;
import java.io.IOException;
import java.sql.Connection;
import java.util.*;
/**
@@ -45,7 +48,7 @@ public class TenantDataSourceConfig {
@Bean
@Primary
public DataSource dataSource() {
public DataSource dataSource(TenantDatabaseMigrationService migrationService) {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
// Загружаем тенантов из JSON (read-only ConfigMap mount)
@@ -57,16 +60,12 @@ public class TenantDataSourceConfig {
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
);
tenants.add(defaultTenant);
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
}
// Регистрируем тенантов
for (TenantConfig tenant : tenants) {
try {
routingDataSource.addTenant(tenant);
} catch (Exception e) {
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
}
registerPreparedTenant(routingDataSource, migrationService, tenant);
}
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
@@ -80,7 +79,9 @@ public class TenantDataSourceConfig {
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
"sa", ""
);
routingDataSource.addTenant(h2Fallback);
if (!registerPreparedTenant(routingDataSource, migrationService, h2Fallback)) {
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
}
}
return routingDataSource;
@@ -120,18 +121,64 @@ public class TenantDataSourceConfig {
private List<TenantConfig> loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
log.info("Tenants config file not found: {}", tenantsConfigPath);
log.info("Файл конфигурации тенантов не найден");
return new ArrayList<>();
}
try {
ObjectMapper mapper = new ObjectMapper();
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
log.info("Загружено конфигураций тенантов: {}", list.size());
return list;
} catch (IOException e) {
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали чтения конфигурации тенантов", e);
return new ArrayList<>();
}
}
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
TenantDatabaseMigrationService migrationService,
TenantConfig tenant) {
HikariDataSource candidate = null;
boolean activated = false;
try {
candidate = routingDataSource.prepareTenantDataSource(tenant);
try (Connection connection = candidate.getConnection()) {
if (!connection.isValid(5)) {
throw new IllegalStateException("База данных не подтвердила готовность подключения");
}
}
if (!tenant.getUrl().trim().startsWith("jdbc:h2:")) {
migrationService.migrate(candidate);
}
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
activated = true;
closeDataSource(previous == null ? null : previous.dataSource());
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
return true;
} catch (Exception startupFailure) {
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
tenant.getDomain(), startupFailure.getClass().getSimpleName());
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
return false;
} finally {
if (!activated) {
closeDataSource(candidate);
}
}
}
private void closeDataSource(DataSource dataSource) {
if (dataSource instanceof HikariDataSource hikariDataSource) {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
closeFailure.getClass().getSimpleName());
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
}
}
}
}

View File

@@ -8,143 +8,252 @@ import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Objects;
import java.util.Optional;
/**
* DataSource, который переключается между БД разных тенантов.
* На каждый запрос determineCurrentLookupKey() возвращает текущий тенант из TenantContext.
* Runtime-состояние публикуется одним неизменяемым snapshot, поэтому запрос
* никогда не видит конфигурацию и DataSource из разных версий.
*/
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class);
private final Map<String, TenantConfig> tenantConfigs = new ConcurrentHashMap<>();
private final Map<Object, Object> dataSources = new ConcurrentHashMap<>();
private boolean initialized = false;
private final Object lifecycleMonitor = new Object();
private volatile Map<String, TenantState> tenantStates = Map.of();
public TenantRoutingDataSource() {
// Устанавливаем пустой map чтобы afterPropertiesSet не падал
setTargetDataSources(new HashMap<>());
// Inherited lifecycle Spring требует initial target map. Runtime routing
// выполняется переопределённым determineTargetDataSource().
setTargetDataSources(Map.of());
setLenientFallback(false);
}
@Override
protected Object determineCurrentLookupKey() {
String tenant = TenantContext.getCurrentTenant();
return determineLookupKey(tenantStates);
}
if (tenant == null) {
// Нет HTTP контекста (JPA init, background tasks) — берём первый доступный
if (!dataSources.isEmpty()) {
return dataSources.keySet().iterator().next().toString();
@Override
protected DataSource determineTargetDataSource() {
Map<String, TenantState> snapshot = tenantStates;
String lookupKey = determineLookupKey(snapshot);
TenantState state = snapshot.get(lookupKey);
if (state == null || state.dataSource() == null) {
throw new IllegalStateException("Подключение для выбранного тенанта не настроено");
}
return state.dataSource();
}
/**
* Создаёт Hikari pool, но не включает его в маршрутизацию.
*/
public HikariDataSource prepareTenantDataSource(TenantConfig config) {
TenantConfig normalized = normalizeConfig(config);
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(normalized.getUrl());
dataSource.setUsername(normalized.getUsername());
dataSource.setPassword(normalized.getPassword());
dataSource.setPoolName("tenant-" + normalized.getDomain() + "-candidate-"
+ Integer.toUnsignedString(System.identityHashCode(dataSource)));
dataSource.setMaximumPoolSize(10);
dataSource.setMinimumIdle(2);
dataSource.setConnectionTimeout(10_000);
dataSource.setValidationTimeout(5_000);
dataSource.setIdleTimeout(300_000);
dataSource.setMaxLifetime(600_000);
dataSource.setInitializationFailTimeout(10_000);
return dataSource;
}
/**
* Атомарно публикует подготовленный DataSource и возвращает прежнее состояние.
*/
public TenantState swapTenant(TenantConfig config, DataSource candidate) {
Objects.requireNonNull(candidate, "Подготовленный DataSource обязателен");
TenantConfig normalized = normalizeConfig(config);
String domain = normalized.getDomain();
synchronized (lifecycleMonitor) {
Map<String, TenantState> current = tenantStates;
TenantState previous = copyState(current.get(domain));
Map<String, TenantState> updated = new LinkedHashMap<>(current);
updated.put(domain, new TenantState(domain, normalized, candidate));
tenantStates = immutableStates(updated);
return previous;
}
}
/**
* Атомарно исключает тенанта из маршрутизации, но не закрывает возвращённый DataSource.
*/
public TenantState removeTenantAtomically(String domain) {
String normalizedDomain = normalizeDomain(domain);
synchronized (lifecycleMonitor) {
Map<String, TenantState> current = tenantStates;
TenantState previous = current.get(normalizedDomain);
if (previous == null) {
return null;
}
return "default";
Map<String, TenantState> updated = new LinkedHashMap<>(current);
updated.remove(normalizedDomain);
tenantStates = immutableStates(updated);
return copyState(previous);
}
// HTTP запрос — возвращаем точный ключ тенанта
// Если тенанта нет — TenantInterceptor уже вернул 404
return tenant;
}
/**
* Добавляет тенант и создаёт для него 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();
initialized = true;
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);
public Optional<TenantState> snapshotTenant(String domain) {
if (domain == null || domain.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(copyState(tenantStates.get(normalizeDomain(domain))));
}
setTargetDataSources(dataSources);
afterPropertiesSet();
public Map<String, TenantConfig> snapshotTenantConfigs() {
Map<String, TenantState> snapshot = tenantStates;
Map<String, TenantConfig> configs = new LinkedHashMap<>();
snapshot.forEach((domain, state) -> configs.put(domain, copyConfig(state.config())));
return Collections.unmodifiableMap(configs);
}
public Optional<DataSource> getTenantDataSource(String domain) {
if (domain == null || domain.isBlank()) {
return Optional.empty();
}
TenantState state = tenantStates.get(normalizeDomain(domain));
return state == null ? Optional.empty() : Optional.of(state.dataSource());
}
/**
* Проверяет подключение к БД для указанного тенанта.
* Проверяет подключение к БД для зарегистрированного тенанта.
*/
public boolean testConnection(String domain) {
DataSource ds = (DataSource) dataSources.get(domain.toLowerCase());
if (ds == null) return false;
Optional<DataSource> tenantDataSource = getTenantDataSource(domain);
if (tenantDataSource.isEmpty()) {
return false;
}
try (Connection conn = ds.getConnection()) {
return conn.isValid(5);
try (Connection connection = tenantDataSource.get().getConnection()) {
return connection.isValid(5);
} catch (SQLException e) {
log.warn("Connection test failed for tenant '{}': {}", domain, e.getMessage());
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
normalizeDomain(domain), e.getClass().getSimpleName());
log.debug("Технические детали проверки подключения тенанта", e);
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);
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setMaximumPoolSize(1);
dataSource.setConnectionTimeout(5_000);
try (Connection conn = ds.getConnection()) {
if (conn.isValid(5)) {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(5)) {
return "OK";
}
return "Подключение не валидно";
} catch (Exception e) {
return e.getMessage();
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали проверки внешнего подключения", e);
return "Не удалось подключиться к базе данных";
} finally {
ds.close();
dataSource.close();
}
}
/**
* Возвращает неизменяемый снимок конфигураций для совместимости существующих consumers.
*/
public Map<String, TenantConfig> getTenantConfigs() {
return tenantConfigs;
return snapshotTenantConfigs();
}
public boolean hasTenant(String domain) {
return tenantConfigs.containsKey(domain.toLowerCase());
return domain != null && !domain.isBlank() && tenantStates.containsKey(normalizeDomain(domain));
}
public boolean isInitialized() {
return initialized && !dataSources.isEmpty();
return !tenantStates.isEmpty();
}
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);
// Не падать при инициализации если БД недоступна
ds.setInitializationFailTimeout(-1);
return ds;
private String determineLookupKey(Map<String, TenantState> snapshot) {
String tenant = TenantContext.getCurrentTenant();
if (tenant != null) {
return tenant;
}
return snapshot.isEmpty() ? "default" : snapshot.keySet().iterator().next();
}
private Map<String, TenantState> immutableStates(Map<String, TenantState> source) {
Map<String, TenantState> copy = new LinkedHashMap<>();
source.forEach((domain, state) -> copy.put(domain, new TenantState(
domain,
copyConfig(state.config()),
state.dataSource()
)));
return Collections.unmodifiableMap(copy);
}
private TenantState copyState(TenantState state) {
if (state == null) {
return null;
}
return new TenantState(state.domain(), copyConfig(state.config()), state.dataSource());
}
private TenantConfig normalizeConfig(TenantConfig config) {
if (config == null) {
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
}
String domain = normalizeDomain(config.getDomain());
if (config.getUrl() == null || config.getUrl().isBlank()) {
throw new IllegalArgumentException("URL базы данных не может быть пустым");
}
String name = config.getName() == null || config.getName().isBlank()
? domain
: config.getName().trim();
return new TenantConfig(
name,
domain,
config.getUrl().trim(),
config.getUsername(),
config.getPassword()
);
}
private TenantConfig copyConfig(TenantConfig config) {
if (config == null) {
return null;
}
return new TenantConfig(
config.getName(),
config.getDomain(),
config.getUrl(),
config.getUsername(),
config.getPassword()
);
}
private String normalizeDomain(String domain) {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Домен не может быть пустым");
}
return domain.trim().toLowerCase(Locale.ROOT);
}
public record TenantState(String domain, TenantConfig config, DataSource dataSource) {
}
}

View File

@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -23,34 +24,34 @@ public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.activityTypeRepository = activityTypeRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@@ -123,27 +124,10 @@ public class AcademicCalendarController {
}
@PutMapping("/{id}/grid")
@Transactional
public ResponseEntity<?> saveGrid(@PathVariable Long id, @RequestBody List<AcademicCalendarGridDayDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
if (rows == null || rows.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика"));
}
try {
calendarDayRepository.deleteByCalendarId(id);
for (AcademicCalendarGridDayDto row : rows) {
AcademicCalendarDay day = buildGridDay(calendar, row);
calendarDayRepository.save(day);
}
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
public ResponseEntity<?> saveGrid(@PathVariable("id") Long id,
@RequestBody List<AcademicCalendarGridDayDto> rows) {
calendarGridService.replaceGrid(id, rows);
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
}
@GetMapping("/{id}/subjects")
@@ -213,44 +197,6 @@ public class AcademicCalendarController {
calendar.setCourseCount(request.courseCount());
}
private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null || row.courseNumber() <= 0
|| row.date() == null
|| row.weekNumber() == null || row.weekNumber() <= 0
|| row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) {
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
}
if (row.date().isBefore(calendar.getAcademicYear().getStartDate())
|| row.date().isAfter(calendar.getAcademicYear().getEndDate())) {
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
}
if (row.courseNumber() > calendar.getCourseCount()) {
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
}
AcademicCalendarActivityType activityType = resolveActivityType(row);
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(row.courseNumber());
day.setDate(row.date());
day.setWeekNumber(row.weekNumber());
day.setDayOfWeek(row.dayOfWeek());
day.setActivityType(activityType);
return day;
}
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
if (row.activityTypeId() != null) {
return activityTypeRepository.findById(row.activityTypeId())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
if (row.activityCode() != null && !row.activityCode().isBlank()) {
return activityTypeRepository.findByCode(row.activityCode().trim())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
throw new IllegalArgumentException("Код активности обязателен");
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");

View File

@@ -1,12 +1,14 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.config.tenant.ConfigMapUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -14,15 +16,14 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
/**
* API управления подключениями к базам данных (тенантами).
* Доступно только для ADMIN.
*
* При добавлении/удалении тенанта:
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
* Изменения проходят безопасный lifecycle: подготовка и миграция временного
* пула, сохранение desired-конфигурации и только затем локальная активация.
*/
@RestController
@RequestMapping("/api/database")
@@ -30,15 +31,15 @@ import java.util.Map;
public class DatabaseController {
private final TenantRoutingDataSource routingDataSource;
private final ConfigMapUpdater configMapUpdater;
private final TenantConfigWatcher configWatcher;
private final TenantLifecycleService tenantLifecycleService;
private final TenantConfigWatcher tenantConfigWatcher;
public DatabaseController(TenantRoutingDataSource routingDataSource,
ConfigMapUpdater configMapUpdater,
TenantConfigWatcher configWatcher) {
TenantLifecycleService tenantLifecycleService,
TenantConfigWatcher tenantConfigWatcher) {
this.routingDataSource = routingDataSource;
this.configMapUpdater = configMapUpdater;
this.configWatcher = configWatcher;
this.tenantLifecycleService = tenantLifecycleService;
this.tenantConfigWatcher = tenantConfigWatcher;
}
/**
@@ -87,41 +88,19 @@ public class DatabaseController {
*/
@PostMapping("/tenants")
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
Map<String, Object> 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 {
// 1. Добавить в in-memory (мгновенно на этом поде)
routingDataSource.addTenant(config);
// 2. Инициализировать БД (init.sql) если нужно
configWatcher.initDatabaseForTenant(config);
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
persistToConfigMap();
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);
return tenantLifecycleService.executeSerialized(() -> {
TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config);
tenantConfigWatcher.refreshHash();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
return ResponseEntity.ok(result);
});
} catch (IllegalArgumentException validationFailure) {
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
} catch (TenantLifecycleException lifecycleFailure) {
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
}
}
@@ -129,21 +108,23 @@ public class DatabaseController {
* Удалить тенант.
*/
@DeleteMapping("/tenants/{domain}")
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable String domain) {
Map<String, Object> result = new HashMap<>();
if (!routingDataSource.hasTenant(domain)) {
result.put("success", false);
result.put("message", "Тенант '" + domain + "' не найден");
return ResponseEntity.status(404).body(result);
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
try {
return tenantLifecycleService.executeSerialized(() -> {
TenantConfig removed = tenantLifecycleService.removeTenant(domain);
tenantConfigWatcher.refreshHash();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
return ResponseEntity.ok(result);
});
} catch (NoSuchElementException notFound) {
return lifecycleError(HttpStatus.NOT_FOUND, notFound.getMessage());
} catch (IllegalArgumentException validationFailure) {
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
} catch (TenantLifecycleException lifecycleFailure) {
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
}
routingDataSource.removeTenant(domain);
persistToConfigMap();
result.put("success", true);
result.put("message", "Тенант '" + domain + "' удалён");
return ResponseEntity.ok(result);
}
/**
@@ -171,14 +152,10 @@ public class DatabaseController {
return ResponseEntity.ok(result);
}
/**
* Сохраняет текущий список тенантов в K8s ConfigMap.
*/
private void persistToConfigMap() {
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
if (ok) {
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
}
private ResponseEntity<Map<String, Object>> lifecycleError(HttpStatus status, String message) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", message);
return ResponseEntity.status(status).body(result);
}
}

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.service.ScheduleConflictException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -20,6 +21,15 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ScheduleConflictException.class)
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Изменение создаёт конфликт расписания"
: exception.getMessage();
return error(HttpStatus.CONFLICT, message, request);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
HttpServletRequest request) {
@@ -68,6 +78,7 @@ public class GlobalExceptionHandler {
private String reason(HttpStatus status) {
return switch (status) {
case BAD_REQUEST -> "Некорректный запрос";
case CONFLICT -> "Конфликт";
case NOT_FOUND -> "Не найдено";
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
default -> status.name();

View File

@@ -1,11 +1,9 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleOverrideService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -17,132 +15,30 @@ import java.util.Map;
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleOverrideController {
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final TimeSlotRepository timeSlotRepository;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleOverrideService scheduleOverrideService;
public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
public ScheduleOverrideController(ScheduleOverrideService scheduleOverrideService) {
this.scheduleOverrideService = scheduleOverrideService;
}
@GetMapping
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
.toList();
return scheduleOverrideService.getAll();
}
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
return save(null, request);
return ResponseEntity.ok(scheduleOverrideService.create(request));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return save(id, request);
return ResponseEntity.ok(scheduleOverrideService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!scheduleOverrideRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
scheduleOverrideRepository.deleteById(id);
scheduleGeneratorService.clearCache();
scheduleOverrideService.delete(id);
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
}
private ResponseEntity<?> save(Long id, ScheduleOverrideDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
ScheduleOverride override = id == null
? scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate())
.orElseGet(ScheduleOverride::new)
: scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new);
override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow());
override.setLessonDate(request.lessonDate());
override.setAction(request.action());
override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null));
override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null));
override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null));
override.setNewLessonFormat(clean(request.newLessonFormat()));
override.setComment(clean(request.comment()));
override.setCreatedBy(AuthContext.currentUserId());
ScheduleOverride saved = scheduleOverrideRepository.save(override);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
private String validate(ScheduleOverrideDto request) {
if (request == null) {
return "Передайте данные изменения расписания";
}
if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) {
return "Базовый слот расписания не найден";
}
if (request.lessonDate() == null) {
return "Дата пары обязательна";
}
if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) {
return "Недопустимое действие изменения расписания";
}
if (request.newClassroomId() != null) {
Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null);
if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
return "Активная аудитория не найдена";
}
}
if (request.newTeacherId() != null) {
User teacher = userRepository.findById(request.newTeacherId()).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return "Активный преподаватель не найден";
}
}
if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) {
return "Временной слот не найден";
}
return null;
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
override.getLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -2,663 +2,79 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/schedule-rules")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleService scheduleRuleService;
private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository;
private final SemesterRepository semesterRepository;
private final GroupRepository groupRepository;
private final TimeSlotRepository timeSlotRepository;
private final UserRepository userRepository;
private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
SemesterRepository semesterRepository,
GroupRepository groupRepository,
TimeSlotRepository timeSlotRepository,
UserRepository userRepository,
ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository;
this.semesterRepository = semesterRepository;
this.groupRepository = groupRepository;
this.timeSlotRepository = timeSlotRepository;
this.userRepository = userRepository;
this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleService scheduleRuleService) {
this.scheduleRuleService = scheduleRuleService;
}
@GetMapping
public List<ScheduleRuleDto> getAll(@RequestParam(required = false) Long semesterId,
@RequestParam(required = false) Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId)))
.map(this::toDto)
.toList();
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
@RequestParam(name = "groupId", required = false) Long groupId) {
return scheduleRuleService.getAll(semesterId, groupId);
}
@GetMapping("/{id}")
public ResponseEntity<?> getById(@PathVariable Long id) {
return scheduleRuleRepository.findByIdWithDetails(id)
.<ResponseEntity<?>>map(rule -> ResponseEntity.ok(toDto(rule)))
.orElseGet(() -> ResponseEntity.notFound().build());
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
return ResponseEntity.ok(scheduleRuleService.getById(id));
}
@PostMapping
@Transactional
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
String validationError = validateRule(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
try {
ScheduleRule rule = new ScheduleRule();
applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
return ResponseEntity.ok(scheduleRuleService.create(request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
}
}
@PutMapping("/{id}")
@Transactional
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) {
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateRule(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
try {
ScheduleRule candidate = new ScheduleRule();
applyRule(candidate, request);
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
if (conflict != null) {
return conflictResponse(conflict);
}
applyRule(rule, request);
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
return ResponseEntity.ok(scheduleRuleService.update(id, request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
public ResponseEntity<Map<String, String>> delete(@PathVariable("id") Long id) {
scheduleRuleService.archive(id);
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
}
private String validateRule(ScheduleRuleDto request) {
if (request.subjectId() == null) {
return "Дисциплина обязательна";
private ResponseEntity<Map<String, Object>> conflictResponse(ScheduleRuleConflictException exception) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", exception.getMessage());
if (exception.getConflictRule() != null) {
body.put("conflictRule", exception.getConflictRule());
}
if (request.semesterId() == null) {
return "Семестр обязателен";
}
String hoursValidationError = validateTypeHours(request);
if (hoursValidationError != null) {
return hoursValidationError;
}
if (request.groupIds() == null || request.groupIds().isEmpty()) {
return "Нужно выбрать хотя бы одну группу";
}
if (request.slots() == null || request.slots().isEmpty()) {
return "Добавьте хотя бы один слот занятия";
}
return null;
}
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены");
}
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
rule.setSubject(subject);
rule.setSemester(semester);
rule.setLectureAcademicHours(request.lectureAcademicHours());
rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
rule.setPracticeAcademicHours(request.practiceAcademicHours());
rule.setLectureStartWeek(request.lectureStartWeek());
rule.setLaboratoryStartWeek(request.laboratoryStartWeek());
rule.setPracticeStartWeek(request.practiceStartWeek());
rule.getGroups().clear();
rule.getGroups().addAll(groups);
List<ScheduleRuleSlot> slots = new ArrayList<>();
for (ScheduleRuleSlotDto slotDto : request.slots()) {
slots.add(buildSlot(rule, slotDto));
}
validateTypeCoverage(rule, slots);
rule.getSlots().clear();
rule.getSlots().addAll(slots);
}
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"message", "Невозможно сохранить правило: слот занят",
"conflictRule", toDto(conflict.rule()),
"conflictFields", conflict.fields().stream().toList(),
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
));
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
default -> field;
};
}
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
.orElse(null);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> newSlots,
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot newSlot : newSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(newSlot, existingSlot)
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
fields.addAll(conflictFields(newSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> firstGroupIds = groupIds(first);
Set<Long> secondGroupIds = groupIds(second);
firstGroupIds.retainAll(secondGroupIds);
for (Long groupId : firstGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
firstSubgroupIds.retainAll(secondSubgroupIds);
if (!firstSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
Set<Long> ids = new HashSet<>();
if (slot.getScheduleRule() == null) {
return ids;
}
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
ids.add(group.getId());
}
return ids;
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return subgroupIdsForGroup(slot, groupId).isEmpty();
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
Set<Long> ids = new HashSet<>();
for (Subgroup subgroup : slot.getSubgroups()) {
if (subgroup.getStudentGroup() != null
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
ids.add(subgroup.getId());
}
}
return ids;
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
}
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
}
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (timeSlot.getTimeSlotScope() == null
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroups(subgroups);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
slot.setLessonFormat(slotDto.lessonFormat().trim());
return slot;
}
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
if (slotDto.subgroupIds() != null) {
slotDto.subgroupIds().stream()
.filter(Objects::nonNull)
.forEach(subgroupIds::add);
}
if (slotDto.subgroupId() != null) {
subgroupIds.add(slotDto.subgroupId());
}
if (subgroupIds.isEmpty()) {
return new LinkedHashSet<>();
}
if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
}
Set<Long> ruleGroupIds = rule.getGroups().stream()
.map(StudentGroup::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> subgroupGroupIds = new HashSet<>();
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
for (Long subgroupId : subgroupIds) {
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
Long subgroupGroupId = subgroup.getStudentGroup().getId();
if (!ruleGroupIds.contains(subgroupGroupId)) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
if (!subgroupGroupIds.add(subgroupGroupId)) {
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
}
subgroups.add(subgroup);
}
return subgroups;
}
private String validateTypeHours(ScheduleRuleDto request) {
if (isMissing(request.lectureAcademicHours())
|| isMissing(request.laboratoryAcademicHours())
|| isMissing(request.practiceAcademicHours())) {
return "Укажите количество часов для всех типов занятий";
}
if (isNegative(request.lectureAcademicHours())
|| isNegative(request.laboratoryAcademicHours())
|| isNegative(request.practiceAcademicHours())) {
return "Количество часов по типам занятий не может быть отрицательным";
}
if (isInvalidStartWeek(request.lectureStartWeek())
|| isInvalidStartWeek(request.laboratoryStartWeek())
|| isInvalidStartWeek(request.practiceStartWeek())) {
return "Неделя начала занятий должна быть больше нуля";
}
int totalHours = valueOrZero(request.lectureAcademicHours())
+ valueOrZero(request.laboratoryAcademicHours())
+ valueOrZero(request.practiceAcademicHours());
if (totalHours <= 0) {
return "Укажите часы хотя бы для одного типа занятий";
}
return null;
}
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
for (ScheduleRuleSlot slot : slots) {
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
}
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int hours = rule.academicHoursFor(category);
boolean hasSlots = categoriesWithSlots.contains(category);
if (hours > 0 && !hasSlots) {
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот");
}
if (hours == 0 && hasSlots) {
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа");
}
}
}
private boolean isMissing(Integer value) {
return value == null;
}
private boolean isNegative(Integer value) {
return value < 0;
}
private boolean isInvalidStartWeek(Integer value) {
return value == null || value <= 0;
}
private int valueOrZero(Integer value) {
return value == null ? 0 : value;
}
private ScheduleRuleDto toDto(ScheduleRule rule) {
List<StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
.thenComparing(ScheduleRuleSlot::getId))
.map(this::toSlotDto)
.toList();
return new ScheduleRuleDto(
rule.getId(),
rule.getSubject().getId(),
rule.getSubject().getName(),
rule.getSemester().getId(),
rule.getSemester().getAcademicYear().getTitle(),
rule.getSemester().getSemesterType(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto(
slot.getId(),
slot.getDayOfWeek(),
dayName(slot.getDayOfWeek()),
slot.getParity(),
timeSlot.getId(),
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),
slot.getClassroom().getName(),
slot.getLessonType().getId(),
slot.getLessonType().getLessonType(),
slot.getLessonFormat()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
return "Неизвестно";
}
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
}
private String formatTimeSlot(TimeSlot timeSlot) {
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
}
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
return slot.getSubgroups().stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
}
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
body.put("conflictFields", exception.getConflictFields());
body.put("conflictReasons", exception.getConflictReasons());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
}

View File

@@ -1,11 +1,24 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthRefreshToken;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
Optional<AuthRefreshToken> findByTokenHash(String tokenHash);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT token
FROM AuthRefreshToken token
JOIN FETCH token.user
WHERE token.tokenHash = :tokenHash
""")
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
}

View File

@@ -1,7 +1,9 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleOverride;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -26,4 +28,23 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
);
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
@Query("select o.lessonDate from ScheduleOverride o where o.id = :id")
Optional<LocalDate> findLessonDateById(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from ScheduleOverride o where o.id = :id")
Optional<ScheduleOverride> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select o
from ScheduleOverride o
where o.baseRuleSlot.id = :baseRuleSlotId
and o.lessonDate = :lessonDate
""")
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDateForUpdate(
@Param("baseRuleSlotId") Long baseRuleSlotId,
@Param("lessonDate") LocalDate lessonDate
);
}

View File

@@ -1,11 +1,14 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRule;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
@@ -76,6 +79,34 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
""")
List<ScheduleRule> findAllWithDetails();
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select rule from ScheduleRule rule where rule.id = :id")
Optional<ScheduleRule> findByIdForUpdate(@Param("id") Long id);
@Query("""
select distinct rule
from ScheduleRule rule
left join fetch rule.subject
left join fetch rule.semester semester
left join fetch semester.academicYear
left join fetch rule.groups groups
left join fetch rule.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where semester.id = :semesterId
and rule.status <> 'ARCHIVED'
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
order by rule.id
""")
List<ScheduleRule> findActiveBySemesterIdWithDetails(
@Param("semesterId") Long semesterId,
@Param("excludeRuleId") Long excludeRuleId
);
@Query("""
select distinct r
from ScheduleRule r
@@ -92,5 +123,5 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where r.id = :id
""")
java.util.Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
}

View File

@@ -2,9 +2,14 @@ package com.magistr.app.repository;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -15,4 +20,17 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select semester from Semester semester where semester.id = :id")
Optional<Semester> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select semester
from Semester semester
where semester.id in :ids
order by semester.id
""")
List<Semester> findAllByIdForUpdateOrderById(@Param("ids") Collection<Long> ids);
}

View File

@@ -10,6 +10,12 @@ import java.util.Optional;
public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
interface SubgroupGroupReference {
Long getSubgroupId();
Long getGroupId();
}
@Query("""
select subgroup
from Subgroup subgroup
@@ -74,4 +80,11 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
""")
long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
@Query("""
select subgroup.id as subgroupId, subgroup.studentGroup.id as groupId
from Subgroup subgroup
where subgroup.id in :subgroupIds
""")
List<SubgroupGroupReference> findGroupReferencesByIdIn(@Param("subgroupIds") List<Long> subgroupIds);
}

View File

@@ -0,0 +1,157 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarDay;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
@Service
public class AcademicCalendarGridService {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.activityTypeRepository = activityTypeRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public void replaceGrid(Long id, List<AcademicCalendarGridDayDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id)
.orElseThrow(() -> new NoSuchElementException("Календарный график не найден"));
if (rows == null || rows.isEmpty()) {
throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика");
}
List<AcademicCalendarDay> replacement = buildReplacement(calendar, rows);
calendarDayRepository.deleteByCalendarId(id);
calendarDayRepository.flush();
calendarDayRepository.saveAll(replacement);
calendarDayRepository.flush();
clearScheduleCacheAfterCommit();
}
private List<AcademicCalendarDay> buildReplacement(AcademicCalendar calendar,
List<AcademicCalendarGridDayDto> rows) {
List<AcademicCalendarDay> replacement = new ArrayList<>(rows.size());
Set<GridBusinessKey> uniqueRows = new HashSet<>();
for (AcademicCalendarGridDayDto row : rows) {
if (row == null) {
throw new IllegalArgumentException("Строка календарного графика не может быть пустой");
}
validateRequiredFields(row);
validateCourse(calendar, row.courseNumber());
validateDate(calendar.getAcademicYear(), row.date());
validateWeek(calendar.getAcademicYear(), row.date(), row.weekNumber());
validateDayOfWeek(row.date(), row.dayOfWeek());
GridBusinessKey businessKey = new GridBusinessKey(row.courseNumber(), row.date());
if (!uniqueRows.add(businessKey)) {
throw new IllegalArgumentException("Для курса и даты можно указать только одну активность");
}
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(row.courseNumber());
day.setDate(row.date());
day.setWeekNumber(row.weekNumber());
day.setDayOfWeek(row.dayOfWeek());
day.setActivityType(resolveActivityType(row));
replacement.add(day);
}
return replacement;
}
private void validateRequiredFields(AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null
|| row.date() == null
|| row.weekNumber() == null
|| row.weekNumber() <= 0
|| row.dayOfWeek() == null
|| row.dayOfWeek() < 1
|| row.dayOfWeek() > 7) {
throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны");
}
}
private void validateCourse(AcademicCalendar calendar, Integer courseNumber) {
if (courseNumber < 1 || courseNumber > calendar.getCourseCount()) {
throw new IllegalArgumentException("Номер курса выходит за пределы графика");
}
}
private void validateDate(AcademicYear academicYear, LocalDate date) {
if (date.isBefore(academicYear.getStartDate()) || date.isAfter(academicYear.getEndDate())) {
throw new IllegalArgumentException("Дата выходит за пределы учебного года");
}
}
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
int expectedWeek = Math.toIntExact(daysFromYearStart / 7 + 1);
if (weekNumber != expectedWeek) {
throw new IllegalArgumentException("Номер недели не соответствует дате учебного года");
}
}
private void validateDayOfWeek(LocalDate date, Integer dayOfWeek) {
if (dayOfWeek != date.getDayOfWeek().getValue()) {
throw new IllegalArgumentException("День недели не соответствует дате");
}
}
private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) {
if (row.activityTypeId() != null) {
return activityTypeRepository.findById(row.activityTypeId())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
if (row.activityCode() != null && !row.activityCode().isBlank()) {
return activityTypeRepository.findByCode(row.activityCode().trim())
.orElseThrow(() -> new IllegalArgumentException("Код активности не найден"));
}
throw new IllegalArgumentException("Код активности обязателен");
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
scheduleGeneratorService.clearCache();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
scheduleGeneratorService.clearCache();
}
});
}
private record GridBusinessKey(Integer courseNumber, LocalDate date) {
}
}

View File

@@ -0,0 +1,44 @@
package com.magistr.app.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
public class PostgreSqlAdvisoryLock {
private static final int SCHEDULE_OVERRIDE_DATE_NAMESPACE = 0x4D414744;
private static final long SCHEDULE_OVERRIDE_ID_NAMESPACE_MASK = Long.MIN_VALUE;
@PersistenceContext
private EntityManager entityManager;
public void lockScheduleOverrideDate(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Дата блокировки расписания обязательна");
}
int exactEpochDay;
try {
exactEpochDay = Math.toIntExact(date.toEpochDay());
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("Дата находится за пределами диапазона PostgreSQL", exception);
}
entityManager.createNativeQuery("select pg_advisory_xact_lock(?1, ?2)")
.setParameter(1, SCHEDULE_OVERRIDE_DATE_NAMESPACE)
.setParameter(2, exactEpochDay)
.getSingleResult();
}
public void lockScheduleOverrideId(Long overrideId) {
if (overrideId == null || overrideId <= 0) {
throw new IllegalArgumentException("Идентификатор точечного изменения должен быть положительным");
}
// Одноаргументное пространство advisory-lock не пересекается с двухаргументным пространством дат.
long lockKey = overrideId ^ SCHEDULE_OVERRIDE_ID_NAMESPACE_MASK;
entityManager.createNativeQuery("select pg_advisory_xact_lock(?1)")
.setParameter(1, lockKey)
.getSingleResult();
}
}

View File

@@ -0,0 +1,148 @@
package com.magistr.app.service;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Отложенно закрывает пулы, исключённые из актуального снимка тенантов.
* Это даёт уже начатым запросам ограниченное время на завершение.
*/
@Service
public class RetiredTenantPoolService {
private static final Logger log = LoggerFactory.getLogger(RetiredTenantPoolService.class);
private final long graceTimeoutNanos;
private final long pollIntervalMillis;
private final ScheduledExecutorService scheduler;
private final Map<HikariDataSource, RetiredPool> retiredPools = new ConcurrentHashMap<>();
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
public RetiredTenantPoolService(
@Value("${app.tenants.pool-retirement.grace-timeout:30s}") Duration graceTimeout,
@Value("${app.tenants.pool-retirement.poll-interval:250ms}") Duration pollInterval) {
if (graceTimeout.isNegative()) {
throw new IllegalArgumentException("Период ожидания закрытия пула не может быть отрицательным");
}
if (pollInterval.isZero() || pollInterval.isNegative()) {
throw new IllegalArgumentException("Интервал проверки закрытия пула должен быть положительным");
}
this.graceTimeoutNanos = graceTimeout.toNanos();
this.pollIntervalMillis = Math.max(1L, pollInterval.toMillis());
this.scheduler = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "retired-tenant-pool-drain");
thread.setDaemon(true);
return thread;
});
this.scheduler.scheduleWithFixedDelay(
this::pollRetiredPools,
this.pollIntervalMillis,
this.pollIntervalMillis,
TimeUnit.MILLISECONDS
);
}
/**
* Передаёт выведенный из эксплуатации Hikari-пул на асинхронное закрытие.
*
* @param dataSource источник данных, ранее удалённый из активного снимка маршрутизации
*/
public void retire(DataSource dataSource) {
if (!(dataSource instanceof HikariDataSource hikariDataSource)) {
log.warn("Источник данных нельзя передать на отложенное закрытие: поддерживаются только Hikari-пулы");
return;
}
if (hikariDataSource.isClosed()) {
return;
}
if (shuttingDown.get()) {
closePool(hikariDataSource, "остановка приложения");
return;
}
long deadlineNanos = System.nanoTime() + graceTimeoutNanos;
RetiredPool previous = retiredPools.putIfAbsent(
hikariDataSource,
new RetiredPool(deadlineNanos)
);
if (previous == null) {
log.info("Пул подключений передан на отложенное закрытие: pool={}",
hikariDataSource.getPoolName());
}
}
private void pollRetiredPools() {
if (shuttingDown.get()) {
return;
}
long nowNanos = System.nanoTime();
retiredPools.forEach((pool, retiredPool) -> pollPool(pool, retiredPool, nowNanos));
}
private void pollPool(HikariDataSource pool, RetiredPool retiredPool, long nowNanos) {
if (pool.isClosed()) {
retiredPools.remove(pool, retiredPool);
return;
}
boolean graceExpired = nowNanos - retiredPool.deadlineNanos() >= 0;
boolean drained = hasNoActiveConnections(pool);
if ((drained || graceExpired) && retiredPools.remove(pool, retiredPool)) {
closePool(pool, drained ? "активные подключения завершены" : "истёк период ожидания");
}
}
private boolean hasNoActiveConnections(HikariDataSource pool) {
try {
HikariPoolMXBean poolMxBean = pool.getHikariPoolMXBean();
return poolMxBean != null && poolMxBean.getActiveConnections() == 0;
} catch (RuntimeException exception) {
log.warn("Не удалось проверить активные подключения выведенного пула: pool={}, errorType={}",
pool.getPoolName(), exception.getClass().getSimpleName());
return false;
}
}
private void closePool(HikariDataSource pool, String reason) {
try {
pool.close();
log.info("Выведенный пул подключений закрыт: pool={}, reason={}", pool.getPoolName(), reason);
} catch (RuntimeException exception) {
log.error("Не удалось закрыть выведенный пул подключений: pool={}, errorType={}",
pool.getPoolName(), exception.getClass().getSimpleName());
}
}
@PreDestroy
public void shutdown() {
if (!shuttingDown.compareAndSet(false, true)) {
return;
}
scheduler.shutdownNow();
retiredPools.forEach((pool, retiredPool) -> {
if (retiredPools.remove(pool, retiredPool)) {
closePool(pool, "остановка приложения");
}
});
}
private record RetiredPool(long deadlineNanos) {
}
}

View File

@@ -0,0 +1,8 @@
package com.magistr.app.service;
public class ScheduleConflictException extends RuntimeException {
public ScheduleConflictException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,532 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ScheduleOverrideService {
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final TimeSlotRepository timeSlotRepository;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleQueryService scheduleQueryService;
private final ScheduleGeneratorService scheduleGeneratorService;
private final PostgreSqlAdvisoryLock advisoryLock;
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
SubgroupRepository subgroupRepository,
ScheduleQueryService scheduleQueryService,
ScheduleGeneratorService scheduleGeneratorService,
PostgreSqlAdvisoryLock advisoryLock) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleQueryService = scheduleQueryService;
this.scheduleGeneratorService = scheduleGeneratorService;
this.advisoryLock = advisoryLock;
}
@Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
.toList();
}
@Transactional
public ScheduleOverrideDto create(ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideDate(command.lessonDate());
ScheduleOverride current = scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
.orElse(null);
return validateAndPersist(current, command);
}
@Transactional
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideId(id);
LocalDate observedOldDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
lockDatesInStableOrder(observedOldDate, command.lessonDate());
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
if (!Objects.equals(observedOldDate, current.getLessonDate())) {
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
.filter(other -> !Objects.equals(other.getId(), current.getId()))
.ifPresent(other -> {
throw new ScheduleConflictException(
"Для выбранной базовой пары и даты уже существует точечное изменение"
);
});
return validateAndPersist(current, command);
}
@Transactional
public void delete(Long id) {
advisoryLock.lockScheduleOverrideId(id);
LocalDate observedDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
advisoryLock.lockScheduleOverrideDate(observedDate);
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
if (!Objects.equals(observedDate, current.getLessonDate())) {
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
scheduleOverrideRepository.delete(current);
scheduleOverrideRepository.flush();
scheduleGeneratorService.clearCache();
}
private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) {
ResolvedReferences references = resolveReferences(command);
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
.toList();
if (sourceOccurrences.isEmpty()) {
throw new IllegalArgumentException(
"На выбранную дату базовая пара не формируется по правилам расписания"
);
}
ScheduleOverride candidate = buildCandidate(command, references);
if (!"CANCEL".equals(command.action())) {
validateEffectiveResult(current, command, candidate, baseDay, sourceOccurrences);
}
ScheduleOverride target = current == null ? new ScheduleOverride() : current;
copyCandidate(candidate, target);
target.setComment(command.comment());
target.setCreatedBy(AuthContext.currentUserId());
ScheduleOverride saved = scheduleOverrideRepository.saveAndFlush(target);
scheduleGeneratorService.clearCache();
return toDto(saved);
}
private void validateEffectiveResult(ScheduleOverride current,
OverrideCommand command,
ScheduleOverride candidate,
List<RenderedLessonDto> baseDay,
List<RenderedLessonDto> sourceOccurrences) {
Long currentId = current == null ? null : current.getId();
List<ScheduleOverride> effectiveOverrides = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate())
.stream()
.filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
.filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
.collect(Collectors.toCollection(ArrayList::new));
effectiveOverrides.add(candidate);
List<RenderedLessonDto> effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups(
baseDay,
command.lessonDate(),
effectiveOverrides
);
List<RenderedLessonDto> candidateOccurrences = effectiveDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
.toList();
if (candidateOccurrences.isEmpty()) {
throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару");
}
Set<EffectiveSourceTuple> sourceTuples = sourceOccurrences.stream()
.map(EffectiveSourceTuple::from)
.collect(Collectors.toSet());
Set<EffectiveSourceTuple> candidateTuples = candidateOccurrences.stream()
.map(EffectiveSourceTuple::from)
.collect(Collectors.toSet());
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
if (sourceTuples.equals(candidateTuples)) {
throw new IllegalArgumentException(
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
);
}
List<RenderedLessonDto> otherOccurrences = effectiveDay.stream()
.filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.toList();
Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
}
private void validateActionSpecificChange(String action,
List<RenderedLessonDto> sourceOccurrences,
List<RenderedLessonDto> candidateOccurrences) {
if ("MOVE".equals(action)) {
Set<MoveTuple> source = sourceOccurrences.stream()
.map(MoveTuple::from)
.collect(Collectors.toSet());
Set<MoveTuple> candidate = candidateOccurrences.stream()
.map(MoveTuple::from)
.collect(Collectors.toSet());
if (source.equals(candidate)) {
throw new IllegalArgumentException(
"Перенос должен фактически менять время пары или аудиторию"
);
}
}
if ("REPLACE".equals(action)) {
Set<ReplacementTuple> source = sourceOccurrences.stream()
.map(ReplacementTuple::from)
.collect(Collectors.toSet());
Set<ReplacementTuple> candidate = candidateOccurrences.stream()
.map(ReplacementTuple::from)
.collect(Collectors.toSet());
if (source.equals(candidate)) {
throw new IllegalArgumentException(
"Замена должна фактически менять преподавателя, аудиторию или формат пары"
);
}
}
}
private void validateResourceConflicts(List<RenderedLessonDto> candidates,
List<RenderedLessonDto> others,
Map<Long, Long> subgroupToGroup) {
for (RenderedLessonDto candidate : candidates) {
for (RenderedLessonDto other : others) {
if (!timeOverlaps(candidate, other)) {
continue;
}
if (candidate.teacherId() != null && Objects.equals(candidate.teacherId(), other.teacherId())) {
throw new ScheduleConflictException(
"Конфликт расписания: преподаватель уже занят в указанное время"
);
}
if (candidate.classroomId() != null && Objects.equals(candidate.classroomId(), other.classroomId())) {
throw new ScheduleConflictException(
"Конфликт расписания: аудитория уже занята в указанное время"
);
}
if (audiencesOverlap(candidate, other, subgroupToGroup)) {
throw new ScheduleConflictException(
"Конфликт расписания: группа или подгруппа уже занята в указанное время"
);
}
}
}
}
private boolean timeOverlaps(RenderedLessonDto first, RenderedLessonDto second) {
LocalTime firstStart = first.startTime();
LocalTime firstEnd = first.endTime();
LocalTime secondStart = second.startTime();
LocalTime secondEnd = second.endTime();
if (firstStart == null || firstEnd == null || secondStart == null || secondEnd == null) {
throw new IllegalStateException("Невозможно проверить конфликт: у пары не задано время");
}
if (!firstStart.isBefore(firstEnd) || !secondStart.isBefore(secondEnd)) {
throw new IllegalStateException("Невозможно проверить конфликт: границы времени пары некорректны");
}
return firstStart.isBefore(secondEnd) && secondStart.isBefore(firstEnd);
}
private boolean audiencesOverlap(RenderedLessonDto first,
RenderedLessonDto second,
Map<Long, Long> subgroupToGroup) {
Set<Long> commonGroupIds = new HashSet<>(safeIds(first.groupIds()));
commonGroupIds.retainAll(safeIds(second.groupIds()));
for (Long groupId : commonGroupIds) {
Set<Long> firstSubgroups = subgroupsForGroup(first, groupId, subgroupToGroup);
Set<Long> secondSubgroups = subgroupsForGroup(second, groupId, subgroupToGroup);
if (firstSubgroups.isEmpty() || secondSubgroups.isEmpty()) {
return true;
}
if (!java.util.Collections.disjoint(firstSubgroups, secondSubgroups)) {
return true;
}
}
return false;
}
private Set<Long> subgroupsForGroup(RenderedLessonDto lesson,
Long groupId,
Map<Long, Long> subgroupToGroup) {
return safeIds(lesson.subgroupIds()).stream()
.filter(subgroupId -> Objects.equals(subgroupToGroup.get(subgroupId), groupId))
.collect(Collectors.toSet());
}
private Map<Long, Long> loadSubgroupGroupMap(Collection<RenderedLessonDto> first,
Collection<RenderedLessonDto> second) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
first.forEach(lesson -> subgroupIds.addAll(safeIds(lesson.subgroupIds())));
second.forEach(lesson -> subgroupIds.addAll(safeIds(lesson.subgroupIds())));
if (subgroupIds.isEmpty()) {
return Map.of();
}
Map<Long, Long> result = new HashMap<>();
subgroupRepository.findGroupReferencesByIdIn(List.copyOf(subgroupIds))
.forEach(reference -> result.put(reference.getSubgroupId(), reference.getGroupId()));
return result;
}
private Set<Long> safeIds(List<Long> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
return values.stream()
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
private ResolvedReferences resolveReferences(OverrideCommand command) {
ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
TimeSlot newTimeSlot = command.newTimeSlotId() == null
? null
: timeSlotRepository.findById(command.newTimeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
Classroom newClassroom = command.newClassroomId() == null
? null
: classroomRepository.findById(command.newClassroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
User newTeacher = command.newTeacherId() == null
? null
: userRepository.findById(command.newTeacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (newTeacher != null
&& (newTeacher.getRole() != Role.TEACHER || !newTeacher.isActiveOn(command.lessonDate()))) {
throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
}
if (newClassroom != null
&& (!newClassroom.isActiveOn(command.lessonDate())
|| !Boolean.TRUE.equals(newClassroom.getIsAvailable()))) {
throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
}
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
}
private ScheduleOverride buildCandidate(OverrideCommand command, ResolvedReferences references) {
ScheduleOverride candidate = new ScheduleOverride();
candidate.setBaseRuleSlot(references.baseRuleSlot());
candidate.setLessonDate(command.lessonDate());
candidate.setAction(command.action());
candidate.setNewTimeSlot(references.newTimeSlot());
candidate.setNewClassroom(references.newClassroom());
candidate.setNewTeacher(references.newTeacher());
candidate.setNewLessonFormat(command.newLessonFormat());
return candidate;
}
private void copyCandidate(ScheduleOverride source, ScheduleOverride target) {
target.setBaseRuleSlot(source.getBaseRuleSlot());
target.setLessonDate(source.getLessonDate());
target.setAction(source.getAction());
target.setNewTimeSlot(source.getNewTimeSlot());
target.setNewClassroom(source.getNewClassroom());
target.setNewTeacher(source.getNewTeacher());
target.setNewLessonFormat(source.getNewLessonFormat());
}
private OverrideCommand validateAndNormalize(ScheduleOverrideDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные изменения расписания");
}
if (request.baseRuleSlotId() == null) {
throw new IllegalArgumentException("Базовый слот расписания обязателен");
}
if (request.lessonDate() == null) {
throw new IllegalArgumentException("Дата пары обязательна");
}
String action = clean(request.action());
action = action == null ? null : action.toUpperCase(Locale.ROOT);
if (action == null || !ACTIONS.contains(action)) {
throw new IllegalArgumentException("Недопустимое действие изменения расписания");
}
String lessonFormat = clean(request.newLessonFormat());
if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) {
throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»");
}
if ("CANCEL".equals(action)
&& (request.newTimeSlotId() != null
|| request.newClassroomId() != null
|| request.newTeacherId() != null
|| lessonFormat != null)) {
throw new IllegalArgumentException("Для отмены пары новые параметры должны быть пустыми");
}
if ("MOVE".equals(action)
&& request.newTimeSlotId() == null
&& request.newClassroomId() == null) {
throw new IllegalArgumentException("Для переноса укажите новое время или аудиторию");
}
if ("REPLACE".equals(action)
&& request.newTeacherId() == null
&& request.newClassroomId() == null
&& lessonFormat == null) {
throw new IllegalArgumentException(
"Для замены укажите нового преподавателя, аудиторию или формат пары"
);
}
return new OverrideCommand(
request.baseRuleSlotId(),
request.lessonDate(),
action,
request.newTimeSlotId(),
request.newClassroomId(),
request.newTeacherId(),
lessonFormat,
clean(request.comment())
);
}
private void lockDatesInStableOrder(LocalDate first, LocalDate second) {
List.of(first, second).stream()
.distinct()
.sorted(Comparator.naturalOrder())
.forEach(advisoryLock::lockScheduleOverrideDate);
}
private boolean sameKey(ScheduleOverride override, Long baseRuleSlotId, LocalDate lessonDate) {
return override.getBaseRuleSlot() != null
&& Objects.equals(override.getBaseRuleSlot().getId(), baseRuleSlotId)
&& Objects.equals(override.getLessonDate(), lessonDate);
}
private NoSuchElementException notFound(Long id) {
return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено");
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
override.getLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private record OverrideCommand(
Long baseRuleSlotId,
LocalDate lessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String newLessonFormat,
String comment
) {
}
private record ResolvedReferences(
ScheduleRuleSlot baseRuleSlot,
TimeSlot newTimeSlot,
Classroom newClassroom,
User newTeacher
) {
}
private record EffectiveSourceTuple(
Long timeSlotId,
LocalTime startTime,
LocalTime endTime,
Long teacherId,
Long classroomId,
String lessonFormat
) {
private static EffectiveSourceTuple from(RenderedLessonDto lesson) {
return new EffectiveSourceTuple(
lesson.timeSlotId(),
lesson.startTime(),
lesson.endTime(),
lesson.teacherId(),
lesson.classroomId(),
lesson.lessonFormat()
);
}
}
private record MoveTuple(
LocalTime startTime,
LocalTime endTime,
Long classroomId
) {
private static MoveTuple from(RenderedLessonDto lesson) {
return new MoveTuple(lesson.startTime(), lesson.endTime(), lesson.classroomId());
}
}
private record ReplacementTuple(
Long teacherId,
Long classroomId,
String lessonFormat
) {
private static ReplacementTuple from(RenderedLessonDto lesson) {
return new ReplacementTuple(lesson.teacherId(), lesson.classroomId(), lesson.lessonFormat());
}
}
}

View File

@@ -33,6 +33,48 @@ public class ScheduleQueryService {
this.groupLifecycleService = groupLifecycleService;
}
/**
* Строит фактическое базовое расписание на дату для всех групп без UI-ограничения
* количества групп, применяемого к широким пользовательским поисковым запросам.
*/
public List<RenderedLessonDto> buildBaseDayForAllGroups(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Дата расписания обязательна");
}
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
.toList();
return deduplicate(generatedLessons);
}
/**
* Строит результирующее расписание дня с переданным снимком точечных изменений.
* Репозиторий точечных изменений здесь намеренно не читается: вызывающий код может атомарно
* проверить ещё не сохранённое изменение внутри своей транзакции.
*/
public List<RenderedLessonDto> buildEffectiveDayForAllGroups(
LocalDate date,
Collection<ScheduleOverride> overrides
) {
return buildEffectiveDayForAllGroups(buildBaseDayForAllGroups(date), date, overrides);
}
/**
* Вариант без повторной генерации базового дня для транзакционных проверок.
*/
public List<RenderedLessonDto> buildEffectiveDayForAllGroups(
List<RenderedLessonDto> baseDay,
LocalDate date,
Collection<ScheduleOverride> overrides
) {
if (date == null) {
throw new IllegalArgumentException("Дата расписания обязательна");
}
List<RenderedLessonDto> safeBaseDay = baseDay == null ? List.of() : baseDay;
return deduplicate(applyOverrides(safeBaseDay, date, overrides));
}
public List<RenderedLessonDto> search(Long groupId,
Long teacherId,
Long classroomId,
@@ -45,7 +87,8 @@ public class ScheduleQueryService {
LocalDate endDate) {
validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons;
if (groupId == null && departmentId == null && teacherId != null) {
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
if (teacherOnlySearch) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else {
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
@@ -53,7 +96,16 @@ public class ScheduleQueryService {
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
}
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate);
if (teacherOnlySearch) {
generatedLessons = addOverrideSourceOccurrencesForTeacher(
generatedLessons,
teacherId,
overrideSnapshot
);
}
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
@@ -64,10 +116,50 @@ public class ScheduleQueryService {
return deduplicate(lessons);
}
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons, LocalDate startDate, LocalDate endDate) {
Map<String, ScheduleOverride> overrides = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate)
private List<RenderedLessonDto> addOverrideSourceOccurrencesForTeacher(
List<RenderedLessonDto> generatedLessons,
Long teacherId,
Collection<ScheduleOverride> overrideSnapshot
) {
Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.filter(override -> override.getLessonDate() != null)
.filter(override -> override.getBaseRuleSlot() != null)
.filter(override -> override.getBaseRuleSlot().getId() != null)
.filter(override -> override.getNewTeacher() != null)
.filter(override -> Objects.equals(override.getNewTeacher().getId(), teacherId))
.collect(Collectors.groupingBy(
ScheduleOverride::getLessonDate,
TreeMap::new,
Collectors.mapping(
override -> override.getBaseRuleSlot().getId(),
Collectors.toCollection(LinkedHashSet::new)
)
));
if (relevantSlotsByDate.isEmpty()) {
return generatedLessons;
}
List<RenderedLessonDto> supplementedLessons = new ArrayList<>(generatedLessons);
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> buildBaseDayForAllGroups(date).stream()
.filter(lesson -> date.equals(lesson.date()))
.filter(lesson -> baseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
.forEach(supplementedLessons::add));
return supplementedLessons;
}
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons,
LocalDate exactDate,
Collection<ScheduleOverride> suppliedOverrides) {
Map<String, ScheduleOverride> overrides = Optional.ofNullable(suppliedOverrides)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.filter(override -> override.getBaseRuleSlot() != null)
.filter(override -> override.getLessonDate() != null)
.filter(override -> exactDate == null || exactDate.equals(override.getLessonDate()))
.collect(Collectors.toMap(
override -> overrideKey(override.getBaseRuleSlot().getId(), override.getLessonDate()),
override -> override,

View File

@@ -0,0 +1,34 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import java.util.List;
public class ScheduleRuleConflictException extends RuntimeException {
private final ScheduleRuleDto conflictRule;
private final List<String> conflictFields;
private final List<String> conflictReasons;
public ScheduleRuleConflictException(String message,
ScheduleRuleDto conflictRule,
List<String> conflictFields,
List<String> conflictReasons) {
super(message);
this.conflictRule = conflictRule;
this.conflictFields = List.copyOf(conflictFields);
this.conflictReasons = List.copyOf(conflictReasons);
}
public ScheduleRuleDto getConflictRule() {
return conflictRule;
}
public List<String> getConflictFields() {
return conflictFields;
}
public List<String> getConflictReasons() {
return conflictReasons;
}
}

View File

@@ -0,0 +1,850 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleLessonCategory;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.LessonTypesRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ScheduleRuleService {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<String> VALID_LESSON_FORMATS = Set.of("Очно", "Онлайн");
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository;
private final SemesterRepository semesterRepository;
private final GroupRepository groupRepository;
private final TimeSlotRepository timeSlotRepository;
private final UserRepository userRepository;
private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
SemesterRepository semesterRepository,
GroupRepository groupRepository,
TimeSlotRepository timeSlotRepository,
UserRepository userRepository,
ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository;
this.semesterRepository = semesterRepository;
this.groupRepository = groupRepository;
this.timeSlotRepository = timeSlotRepository;
this.userRepository = userRepository;
this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream()
.anyMatch(group -> Objects.equals(group.getId(), groupId)))
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public ScheduleRuleDto getById(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
return toDto(rule);
}
@Transactional
public ScheduleRuleDto create(ScheduleRuleDto request) {
validateBasic(request);
// Это должна быть первая операция с БД: блокировка сериализует все записи
// правил одного семестра, включая запросы из разных экземпляров backend.
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
ScheduleRule candidate = buildCandidate(request, semester);
validateCandidate(candidate);
validateAgainstSavedRules(candidate, null);
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
scheduleGeneratorService.clearCache();
return toDto(saved);
}
@Transactional
public ScheduleRuleDto update(Long id, ScheduleRuleDto request) {
validateBasic(request);
// Сначала блокируется сама строка правила. Это гарантирует, что old semester
// определён по актуальному состоянию и два update одного правила не расходятся.
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
Long oldSemesterId = managedRule.getSemester().getId();
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
List.of(oldSemesterId, request.semesterId())
);
Semester targetSemester = lockedSemesters.get(request.semesterId());
if (targetSemester == null) {
throw new IllegalArgumentException("Семестр не найден");
}
ScheduleRule candidate = buildCandidate(request, targetSemester);
validateCandidate(candidate);
validateAgainstSavedRules(candidate, managedRule.getId());
applyCandidate(managedRule, candidate);
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
scheduleGeneratorService.clearCache();
return toDto(saved);
}
@Transactional
public void archive(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
Long semesterId = rule.getSemester().getId();
semesterRepository.findByIdForUpdate(semesterId)
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
rule.archive();
scheduleRuleRepository.saveAndFlush(rule);
scheduleGeneratorService.clearCache();
}
private Map<Long, Semester> lockSemestersInStableOrder(Collection<Long> semesterIds) {
List<Long> orderedIds = new ArrayList<>(new TreeSet<>(semesterIds));
return semesterRepository.findAllByIdForUpdateOrderById(orderedIds).stream()
.collect(Collectors.toMap(
Semester::getId,
Function.identity(),
(first, ignored) -> first,
LinkedHashMap::new
));
}
private void validateBasic(ScheduleRuleDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные правила обязательны");
}
if (request.subjectId() == null) {
throw new IllegalArgumentException("Дисциплина обязательна");
}
if (request.semesterId() == null) {
throw new IllegalArgumentException("Семестр обязателен");
}
validateHours("лекций", request.lectureAcademicHours());
validateHours("лабораторных занятий", request.laboratoryAcademicHours());
validateHours("практических занятий", request.practiceAcademicHours());
long totalHours = (long) request.lectureAcademicHours()
+ request.laboratoryAcademicHours()
+ request.practiceAcademicHours();
if (totalHours <= 0) {
throw new IllegalArgumentException("Укажите часы хотя бы для одного типа занятий");
}
validateStartWeek("лекций", request.lectureStartWeek());
validateStartWeek("лабораторных занятий", request.laboratoryStartWeek());
validateStartWeek("практических занятий", request.practiceStartWeek());
if (request.groupIds() == null || request.groupIds().isEmpty()) {
throw new IllegalArgumentException("Нужно выбрать хотя бы одну группу");
}
if (request.groupIds().stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("Идентификаторы групп не могут быть пустыми");
}
if (new HashSet<>(request.groupIds()).size() != request.groupIds().size()) {
throw new IllegalArgumentException("Группы в правиле не должны повторяться");
}
if (request.slots() == null || request.slots().isEmpty()) {
throw new IllegalArgumentException("Добавьте хотя бы один слот занятия");
}
for (ScheduleRuleSlotDto slot : request.slots()) {
validateBasicSlot(slot);
}
}
private void validateHours(String typeName, Integer hours) {
if (hours == null) {
throw new IllegalArgumentException("Укажите количество часов для " + typeName);
}
if (hours < 0) {
throw new IllegalArgumentException("Количество часов для " + typeName + " не может быть отрицательным");
}
if (hours % ACADEMIC_HOURS_PER_SLOT != 0) {
throw new IllegalArgumentException("Количество часов для " + typeName + " должно быть чётным");
}
}
private void validateStartWeek(String typeName, Integer startWeek) {
if (startWeek == null || startWeek <= 0) {
throw new IllegalArgumentException("Неделя начала для " + typeName + " должна быть больше нуля");
}
}
private void validateBasicSlot(ScheduleRuleSlotDto slot) {
if (slot == null) {
throw new IllegalArgumentException("Данные слота занятия обязательны");
}
if (slot.dayOfWeek() == null || slot.dayOfWeek() < 1 || slot.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
}
if (slot.parity() == null || !VALID_SLOT_PARITIES.contains(slot.parity())) {
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
}
if (slot.timeSlotId() == null) {
throw new IllegalArgumentException("Временной слот обязателен");
}
if (slot.teacherId() == null) {
throw new IllegalArgumentException("Преподаватель обязателен");
}
if (slot.classroomId() == null) {
throw new IllegalArgumentException("Аудитория обязательна");
}
if (slot.lessonTypeId() == null) {
throw new IllegalArgumentException("Тип занятия обязателен");
}
normalizeLessonFormat(slot.lessonFormat());
}
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
List<StudentGroup> groups = new ArrayList<>(groupRepository.findAllById(request.groupIds()));
if (groups.size() != request.groupIds().size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены");
}
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
groups.sort(Comparator.comparing(StudentGroup::getId));
ScheduleRule candidate = new ScheduleRule();
candidate.setSubject(subject);
candidate.setSemester(semester);
candidate.setLectureAcademicHours(request.lectureAcademicHours());
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
candidate.setLectureStartWeek(request.lectureStartWeek());
candidate.setLaboratoryStartWeek(request.laboratoryStartWeek());
candidate.setPracticeStartWeek(request.practiceStartWeek());
candidate.setGroups(new LinkedHashSet<>(groups));
List<ScheduleRuleSlot> slots = new ArrayList<>();
for (ScheduleRuleSlotDto slotDto : request.slots()) {
slots.add(buildSlot(candidate, slotDto));
}
candidate.setSlots(new LinkedHashSet<>(slots));
validateTypeCoverage(candidate, slots);
return candidate;
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (timeSlot.getTimeSlotScope() == null
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
if (teacher.getRole() != Role.TEACHER) {
throw new IllegalArgumentException("В слот расписания можно назначить только пользователя с ролью преподавателя");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroups(subgroups);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
slot.setLessonFormat(normalizeLessonFormat(slotDto.lessonFormat()));
return slot;
}
private Set<Subgroup> resolveSubgroups(ScheduleRule rule,
ScheduleRuleSlotDto slotDto,
ScheduleLessonCategory category) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
if (slotDto.subgroupIds() != null) {
slotDto.subgroupIds().stream()
.filter(Objects::nonNull)
.forEach(subgroupIds::add);
}
if (slotDto.subgroupId() != null) {
subgroupIds.add(slotDto.subgroupId());
}
if (subgroupIds.isEmpty()) {
return new LinkedHashSet<>();
}
if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
}
Set<Long> ruleGroupIds = rule.getGroups().stream()
.map(StudentGroup::getId)
.collect(Collectors.toSet());
Set<Long> subgroupGroupIds = new HashSet<>();
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
for (Long subgroupId : subgroupIds) {
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
if (subgroup.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную подгруппу нельзя использовать в расписании");
}
Long subgroupGroupId = subgroup.getStudentGroup().getId();
if (!ruleGroupIds.contains(subgroupGroupId)) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
if (!subgroupGroupIds.add(subgroupGroupId)) {
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
}
subgroups.add(subgroup);
}
return subgroups;
}
private String normalizeLessonFormat(String lessonFormat) {
if (lessonFormat == null || lessonFormat.isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
String normalized = lessonFormat.trim();
if (!VALID_LESSON_FORMATS.contains(normalized)) {
throw new IllegalArgumentException("Формат занятия должен быть «Очно» или «Онлайн»");
}
return normalized;
}
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
for (ScheduleRuleSlot slot : slots) {
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
}
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int hours = rule.academicHoursFor(category);
boolean hasSlots = categoriesWithSlots.contains(category);
if (hours > 0 && !hasSlots) {
throw new IllegalArgumentException(
"Для типа «" + category.getDisplayName() + "» добавьте хотя бы один слот"
);
}
if (hours == 0 && hasSlots) {
throw new IllegalArgumentException(
"Для типа «" + category.getDisplayName() + "» укажите часы или удалите слоты этого типа"
);
}
}
}
private void validateCandidate(ScheduleRule candidate) {
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
validateExactDuplicates(slots);
validateInternalConflicts(slots);
}
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
Set<SlotIdentity> seen = new HashSet<>();
for (ScheduleRuleSlot slot : slots) {
if (!seen.add(slotIdentity(slot))) {
throw new ScheduleRuleConflictException(
"Невозможно сохранить правило: в списке есть дублирующиеся слоты",
null,
List.of("slot"),
List.of("Дублирующийся слот")
);
}
}
}
private SlotIdentity slotIdentity(ScheduleRuleSlot slot) {
List<Long> subgroupIds = slot.getSubgroups().stream()
.map(Subgroup::getId)
.sorted()
.toList();
return new SlotIdentity(
slot.getDayOfWeek(),
slot.getParity(),
timeSlotId(slot),
subgroupIds,
teacherId(slot),
classroomId(slot),
slot.getLessonType().getId(),
slot.getLessonFormat()
);
}
private void validateInternalConflicts(List<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
for (int firstIndex = 0; firstIndex < slots.size(); firstIndex++) {
for (int secondIndex = firstIndex + 1; secondIndex < slots.size(); secondIndex++) {
ScheduleRuleSlot first = slots.get(firstIndex);
ScheduleRuleSlot second = slots.get(secondIndex);
if (!sameTimeSlot(first, second)
|| !activeWeeksOverlap(activeWeeks.get(first), activeWeeks.get(second))) {
continue;
}
List<String> fields = new ArrayList<>(conflictFields(first, second));
if (!fields.isEmpty()) {
throw new ScheduleRuleConflictException(
"Невозможно сохранить правило: слоты внутри правила конфликтуют",
null,
fields,
conflictReasons(fields)
);
}
}
}
}
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
ScheduleRuleConflict conflict = scheduleRuleRepository
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
.stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null
&& Objects.equals(rule.getSemester().getId(), candidate.getSemester().getId()))
.map(rule -> toConflict(rule, candidate.getSlots(), candidateActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflictResult -> conflictResult.rule().getId()))
.orElse(null);
if (conflict == null) {
return;
}
List<String> fields = new ArrayList<>(conflict.fields());
throw new ScheduleRuleConflictException(
"Невозможно сохранить правило: слот занят",
toDto(conflict.rule()),
fields,
conflictReasons(fields)
);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> candidateSlots,
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot candidateSlot : candidateSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(candidateSlot, existingSlot)
&& activeWeeksOverlap(
candidateActiveWeeks.get(candidateSlot),
existingActiveWeeks.get(existingSlot)
)) {
fields.addAll(conflictFields(candidateSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private List<String> conflictReasons(Collection<String> fields) {
return fields.stream().map(this::conflictFieldLabel).toList();
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
case "slot" -> "Дублирующийся слот";
default -> field;
};
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> commonGroupIds = groupIds(first);
commonGroupIds.retainAll(groupIds(second));
for (Long groupId : commonGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> commonSubgroupIds = subgroupIdsForGroup(first, groupId);
commonSubgroupIds.retainAll(subgroupIdsForGroup(second, groupId));
if (!commonSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
if (!slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.filter(subgroup -> subgroup.getStudentGroup() != null)
.map(subgroup -> subgroup.getStudentGroup().getId())
.collect(Collectors.toCollection(HashSet::new));
}
if (slot.getScheduleRule() == null) {
return new HashSet<>();
}
return slot.getScheduleRule().getGroups().stream()
.map(StudentGroup::getId)
.collect(Collectors.toCollection(HashSet::new));
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return slot.getSubgroups().isEmpty() && groupIds(slot).contains(groupId);
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
return slot.getSubgroups().stream()
.filter(subgroup -> subgroup.getStudentGroup() != null)
.filter(subgroup -> Objects.equals(subgroup.getStudentGroup().getId(), groupId))
.map(Subgroup::getId)
.collect(Collectors.toCollection(HashSet::new));
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null
? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null
? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(
first.getId() == null ? 0L : first.getId(),
second.getId() == null ? 0L : second.getId()
);
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
managedRule.setSubject(candidate.getSubject());
managedRule.setSemester(candidate.getSemester());
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
managedRule.setLaboratoryAcademicHours(candidate.getLaboratoryAcademicHours());
managedRule.setPracticeAcademicHours(candidate.getPracticeAcademicHours());
managedRule.setLectureStartWeek(candidate.getLectureStartWeek());
managedRule.setLaboratoryStartWeek(candidate.getLaboratoryStartWeek());
managedRule.setPracticeStartWeek(candidate.getPracticeStartWeek());
managedRule.getGroups().clear();
managedRule.getGroups().addAll(candidate.getGroups());
managedRule.getSlots().clear();
for (ScheduleRuleSlot slot : candidate.getSlots()) {
slot.setScheduleRule(managedRule);
managedRule.getSlots().add(slot);
}
}
private ScheduleRuleDto toDto(ScheduleRule rule) {
List<StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
.thenComparing(ScheduleRuleSlot::getId, Comparator.nullsLast(Comparator.naturalOrder())))
.map(this::toSlotDto)
.toList();
return new ScheduleRuleDto(
rule.getId(),
rule.getSubject().getId(),
rule.getSubject().getName(),
rule.getSemester().getId(),
rule.getSemester().getAcademicYear().getTitle(),
rule.getSemester().getSemesterType(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto(
slot.getId(),
slot.getDayOfWeek(),
dayName(slot.getDayOfWeek()),
slot.getParity(),
timeSlot.getId(),
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),
slot.getClassroom().getName(),
slot.getLessonType().getId(),
slot.getLessonType().getLessonType(),
slot.getLessonFormat()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
return "Неизвестно";
}
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
}
private String formatTimeSlot(TimeSlot timeSlot) {
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
}
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
return slot.getSubgroups().stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
}
private record SlotIdentity(Integer dayOfWeek,
ScheduleParity parity,
Long timeSlotId,
List<Long> subgroupIds,
Long teacherId,
Long classroomId,
Long lessonTypeId,
String lessonFormat) {
}
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
}

View File

@@ -0,0 +1,25 @@
package com.magistr.app.service;
import org.flywaydb.core.Flyway;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.util.Objects;
@Service
public class TenantDatabaseMigrationService {
/**
* Выполняет миграции указанной tenant-БД. Ошибка намеренно не перехватывается:
* вызывающий lifecycle обязан отменить активацию неподготовленного пула.
*/
public void migrate(DataSource dataSource) {
Objects.requireNonNull(dataSource, "DataSource для миграции обязателен");
Flyway.configure()
.dataSource(dataSource)
.baselineOnMigrate(true)
.baselineVersion("1")
.load()
.migrate();
}
}

View File

@@ -0,0 +1,15 @@
package com.magistr.app.service;
/**
* Безопасная для HTTP-ответа ошибка изменения lifecycle тенанта.
*/
public class TenantLifecycleException extends RuntimeException {
public TenantLifecycleException(String message) {
super(message);
}
public TenantLifecycleException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,339 @@
package com.magistr.app.service;
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@Service
public class TenantLifecycleService {
private static final Logger log = LoggerFactory.getLogger(TenantLifecycleService.class);
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
);
private final TenantRoutingDataSource routingDataSource;
private final KubernetesTenantSecretUpdater tenantSecretUpdater;
private final TenantDatabaseMigrationService migrationService;
private final RetiredTenantPoolService retiredPoolService;
public TenantLifecycleService(TenantRoutingDataSource routingDataSource,
KubernetesTenantSecretUpdater tenantSecretUpdater,
TenantDatabaseMigrationService migrationService,
RetiredTenantPoolService retiredPoolService) {
this.routingDataSource = routingDataSource;
this.tenantSecretUpdater = tenantSecretUpdater;
this.migrationService = migrationService;
this.retiredPoolService = retiredPoolService;
}
/**
* Выполняет составную lifecycle-операцию под общим reentrant monitor.
* Используется для сериализации внешних read/compare/apply последовательностей
* с add/update/remove, чтобы watcher не применял устаревший снимок.
*/
public synchronized <T> T executeSerialized(Supplier<T> action) {
return action.get();
}
/**
* Выполняет составную lifecycle-операцию без возвращаемого значения.
*/
public synchronized void executeSerialized(Runnable action) {
action.run();
}
/**
* Добавляет или обновляет tenant без разрыва действующего подключения.
*/
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
TenantConfig config = validateAndNormalize(requestedConfig);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
desiredConfigs.put(config.getDomain(), copyConfig(config));
HikariDataSource candidate = null;
boolean activated = false;
try {
candidate = prepareCandidate(config);
verifyConnection(candidate);
migrateCandidate(candidate);
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
TenantRoutingDataSource.TenantState previous;
try {
previous = routingDataSource.swapTenant(config, candidate);
} catch (RuntimeException activationFailure) {
throw activationFailureAfterCompensation(oldConfigs, activationFailure);
}
activated = true;
retireDataSource(previous == null ? null : previous.dataSource());
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
return copyConfig(config);
} finally {
if (!activated) {
closeCandidateDataSource(candidate);
}
}
}
/**
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
*/
public synchronized TenantConfig removeTenant(String requestedDomain) {
String domain = validateAndNormalizeDomain(requestedDomain);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
TenantConfig existing = oldConfigs.get(domain);
if (existing == null) {
throw new NoSuchElementException("Тенант '" + domain + "' не найден");
}
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
desiredConfigs.remove(domain);
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
TenantRoutingDataSource.TenantState removed;
try {
removed = routingDataSource.removeTenantAtomically(domain);
if (removed == null) {
throw new IllegalStateException("Локальное состояние тенанта отсутствует");
}
} catch (RuntimeException removalFailure) {
throw removalFailureAfterCompensation(oldConfigs, removalFailure);
}
retireDataSource(removed.dataSource());
log.info("Lifecycle тенанта '{}' удалён", domain);
return copyConfig(existing);
}
/**
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
* Secret повторно и использует тот же monitor, что ручные API add/update/remove.
* Обновление параметров существующего domain остаётся отдельной задачей watcher №13.
*/
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
if (requestedConfigs == null) {
throw new IllegalArgumentException("Список конфигураций тенантов обязателен");
}
List<TenantConfig> configs = requestedConfigs.stream()
.map(this::validateAndNormalize)
.toList();
Set<String> desiredDomains = configs.stream()
.map(TenantConfig::getDomain)
.collect(Collectors.toSet());
for (TenantConfig config : configs) {
if (!routingDataSource.hasTenant(config.getDomain())) {
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config);
}
}
for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) {
if (!desiredDomains.contains(existingDomain)) {
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
TenantRoutingDataSource.TenantState removed =
routingDataSource.removeTenantAtomically(existingDomain);
retireDataSource(removed == null ? null : removed.dataSource());
}
}
}
private void activatePersistedConfig(TenantConfig config) {
HikariDataSource candidate = null;
boolean activated = false;
try {
candidate = prepareCandidate(config);
verifyConnection(candidate);
migrateCandidate(candidate);
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
activated = true;
retireDataSource(previous == null ? null : previous.dataSource());
} finally {
if (!activated) {
closeCandidateDataSource(candidate);
}
}
}
private HikariDataSource prepareCandidate(TenantConfig config) {
try {
return routingDataSource.prepareTenantDataSource(config);
} catch (RuntimeException preparationFailure) {
logTechnicalFailure("Не удалось создать временный pool тенанта", preparationFailure);
throw new TenantLifecycleException("Не удалось подготовить подключение к базе данных тенанта",
preparationFailure);
}
}
private void verifyConnection(HikariDataSource candidate) {
try (Connection connection = candidate.getConnection()) {
if (!connection.isValid(5)) {
throw new TenantLifecycleException("База данных тенанта не подтвердила готовность подключения");
}
} catch (TenantLifecycleException e) {
throw e;
} catch (Exception connectionFailure) {
logTechnicalFailure("Не удалось проверить временное подключение тенанта", connectionFailure);
throw new TenantLifecycleException("Не удалось подключиться к базе данных тенанта", connectionFailure);
}
}
private void migrateCandidate(DataSource candidate) {
try {
migrationService.migrate(candidate);
} catch (RuntimeException migrationFailure) {
logTechnicalFailure("Миграции временной tenant-БД завершились ошибкой", migrationFailure);
throw new TenantLifecycleException("Не удалось выполнить миграции базы данных тенанта", migrationFailure);
}
}
private void persistDesired(Map<String, TenantConfig> desiredConfigs) {
boolean persisted;
try {
persisted = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(desiredConfigs));
} catch (RuntimeException persistenceFailure) {
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
}
if (!persisted) {
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов");
}
}
private void persistDesiredWithCompensation(Map<String, TenantConfig> desiredConfigs,
Map<String, TenantConfig> oldConfigs) {
try {
persistDesired(desiredConfigs);
} catch (TenantLifecycleException persistenceFailure) {
boolean restored = restorePersistedSnapshot(oldConfigs);
String message = restored
? "Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"
: "Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret";
throw new TenantLifecycleException(message, persistenceFailure);
}
}
private TenantLifecycleException activationFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
RuntimeException activationFailure) {
boolean restored = restorePersistedSnapshot(oldConfigs);
logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure);
String message = restored
? "Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена"
: "Не удалось активировать подключение тенанта; требуется проверить tenant Secret";
return new TenantLifecycleException(message, activationFailure);
}
private TenantLifecycleException removalFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
RuntimeException removalFailure) {
boolean restored = restorePersistedSnapshot(oldConfigs);
logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure);
String message = restored
? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена"
: "Не удалось отключить тенанта; требуется проверить tenant Secret";
return new TenantLifecycleException(message, removalFailure);
}
private boolean restorePersistedSnapshot(Map<String, TenantConfig> oldConfigs) {
try {
boolean restored = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(oldConfigs));
if (!restored) {
log.error("Не удалось компенсирующе восстановить tenant Secret");
}
return restored;
} catch (RuntimeException compensationFailure) {
logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой",
compensationFailure);
return false;
}
}
private List<TenantConfig> sortedConfigs(Map<String, TenantConfig> configs) {
List<TenantConfig> sorted = new ArrayList<>();
configs.values().stream()
.map(this::copyConfig)
.sorted(Comparator.comparing(TenantConfig::getDomain))
.forEach(sorted::add);
return sorted;
}
private TenantConfig validateAndNormalize(TenantConfig config) {
if (config == null) {
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
}
String domain = validateAndNormalizeDomain(config.getDomain());
if (config.getUrl() == null || config.getUrl().isBlank()) {
throw new IllegalArgumentException("URL базы данных не может быть пустым");
}
String url = config.getUrl().trim();
if (!url.startsWith("jdbc:")) {
throw new IllegalArgumentException("URL базы данных должен начинаться с jdbc:");
}
String name = config.getName() == null || config.getName().isBlank()
? domain
: config.getName().trim();
return new TenantConfig(name, domain, url, config.getUsername(), config.getPassword());
}
private String validateAndNormalizeDomain(String domain) {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Домен не может быть пустым");
}
String normalized = domain.trim().toLowerCase(Locale.ROOT);
if (!DOMAIN_PATTERN.matcher(normalized).matches()) {
throw new IllegalArgumentException("Домен содержит недопустимые символы");
}
return normalized;
}
private TenantConfig copyConfig(TenantConfig config) {
return new TenantConfig(
config.getName(),
config.getDomain(),
config.getUrl(),
config.getUsername(),
config.getPassword()
);
}
private void retireDataSource(DataSource dataSource) {
if (dataSource != null) {
retiredPoolService.retire(dataSource);
}
}
private void closeCandidateDataSource(DataSource dataSource) {
if (dataSource instanceof HikariDataSource hikariDataSource) {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
logTechnicalFailure("Не удалось штатно закрыть Hikari pool", closeFailure);
}
}
}
private void logTechnicalFailure(String message, Throwable failure) {
log.error("{}: errorType={}", message, failure.getClass().getSimpleName());
log.debug("Технические детали tenant lifecycle", failure);
}
}