исправление багов
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
275
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file → Normal file
275
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file → Normal 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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("Слишком много дисциплин для одного графика");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
public class ScheduleConflictException extends RuntimeException {
|
||||
|
||||
public ScheduleConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user