исправление багов
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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ spring.jpa.open-in-view=false
|
||||
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
||||
|
||||
# JWT авторизация
|
||||
app.jwt.secret=${JWT_SECRET:dev-only-change-this-jwt-secret-32-bytes-minimum}
|
||||
app.jwt.secret=${JWT_SECRET:}
|
||||
app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m}
|
||||
app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d}
|
||||
app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
cancel_violations BIGINT;
|
||||
move_violations BIGINT;
|
||||
replace_violations BIGINT;
|
||||
format_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO cancel_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'CANCEL'
|
||||
AND (new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL);
|
||||
|
||||
SELECT count(*)
|
||||
INTO move_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'MOVE'
|
||||
AND new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL;
|
||||
|
||||
SELECT count(*)
|
||||
INTO replace_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'REPLACE'
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_lesson_format IS NULL;
|
||||
|
||||
SELECT count(*)
|
||||
INTO format_violations
|
||||
FROM schedule_overrides
|
||||
WHERE new_lesson_format IS NOT NULL
|
||||
AND new_lesson_format NOT IN ('Очно', 'Онлайн');
|
||||
|
||||
IF cancel_violations > 0
|
||||
OR move_violations > 0
|
||||
OR replace_violations > 0
|
||||
OR format_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно применить инварианты точечных изменений: CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте данные tenant-БД и повторите миграцию.',
|
||||
cancel_violations,
|
||||
move_violations,
|
||||
replace_violations,
|
||||
format_violations
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
ADD CONSTRAINT chk_schedule_overrides_cancel_payload
|
||||
CHECK (
|
||||
action <> 'CANCEL'
|
||||
OR (
|
||||
new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_lesson_format IS NULL
|
||||
)
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_move_payload
|
||||
CHECK (
|
||||
action <> 'MOVE'
|
||||
OR new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_replace_payload
|
||||
CHECK (
|
||||
action <> 'REPLACE'
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_format
|
||||
CHECK (
|
||||
new_lesson_format IS NULL
|
||||
OR new_lesson_format IN ('Очно', 'Онлайн')
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_cancel_payload;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_move_payload;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_replace_payload;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_format;
|
||||
@@ -0,0 +1,97 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
lecture_violations BIGINT;
|
||||
laboratory_violations BIGINT;
|
||||
practice_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO lecture_violations
|
||||
FROM schedule_rules
|
||||
WHERE mod(lecture_academic_hours, 2) <> 0;
|
||||
|
||||
SELECT count(*)
|
||||
INTO laboratory_violations
|
||||
FROM schedule_rules
|
||||
WHERE mod(laboratory_academic_hours, 2) <> 0;
|
||||
|
||||
SELECT count(*)
|
||||
INTO practice_violations
|
||||
FROM schedule_rules
|
||||
WHERE mod(practice_academic_hours, 2) <> 0;
|
||||
|
||||
IF lecture_violations > 0
|
||||
OR laboratory_violations > 0
|
||||
OR practice_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно применить инвариант чётности академических часов правил расписания: лекции=%s, лабораторные=%s, практики=%s. Исправьте данные tenant-БД и повторите миграцию.',
|
||||
lecture_violations,
|
||||
laboratory_violations,
|
||||
practice_violations
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
duplicate_slot_groups BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO duplicate_slot_groups
|
||||
FROM (
|
||||
SELECT
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
FROM schedule_rule_slots
|
||||
GROUP BY
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
HAVING count(*) > 1
|
||||
) duplicates;
|
||||
|
||||
IF duplicate_slot_groups > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23505',
|
||||
MESSAGE = format(
|
||||
'Невозможно применить инвариант уникальности слотов правил расписания: найдено групп точных дублей=%s. Исправьте данные tenant-БД и повторите миграцию.',
|
||||
duplicate_slot_groups
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ADD CONSTRAINT chk_schedule_rules_academic_hours_even
|
||||
CHECK (
|
||||
mod(lecture_academic_hours, 2) = 0
|
||||
AND mod(laboratory_academic_hours, 2) = 0
|
||||
AND mod(practice_academic_hours, 2) = 0
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
VALIDATE CONSTRAINT chk_schedule_rules_academic_hours_even;
|
||||
|
||||
ALTER TABLE schedule_rule_slots
|
||||
ADD CONSTRAINT uq_schedule_rule_slots_exact_payload
|
||||
UNIQUE (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.model.Role;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class AuthorizationInterceptorTest {
|
||||
|
||||
private static final String TENANT = "tenant-a";
|
||||
private static final String TEACHER_TOKEN = "teacher-token";
|
||||
|
||||
private JwtTokenService jwtTokenService;
|
||||
private ContextProbeController controller;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
AuthContext.clear();
|
||||
TenantContext.clear();
|
||||
TenantContext.setCurrentTenant(TENANT);
|
||||
|
||||
jwtTokenService = mock(JwtTokenService.class);
|
||||
controller = new ContextProbeController();
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(controller)
|
||||
.addInterceptors(new AuthorizationInterceptor(jwtTokenService, new ObjectMapper()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearThreadLocalContexts() {
|
||||
AuthContext.clear();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Отказ по роли не оставляет пользователя в следующих запросах того же потока")
|
||||
void forbiddenRequestDoesNotLeakAuthContextToFollowingRequestsOnSameThread() throws Exception {
|
||||
AuthenticatedUser teacher = new AuthenticatedUser(7L, "преподаватель", Role.TEACHER, 3L);
|
||||
when(jwtTokenService.authenticate(TEACHER_TOKEN, TENANT)).thenReturn(Optional.of(teacher));
|
||||
long junitThreadId = Thread.currentThread().getId();
|
||||
|
||||
mockMvc.perform(get("/api/test/admin")
|
||||
.header("Authorization", "Bearer " + TEACHER_TOKEN))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.message").value("Недостаточно прав для выполнения операции"));
|
||||
|
||||
assertThat(AuthContext.getCurrentUser())
|
||||
.as("Контекст должен быть пуст после отказа по роли")
|
||||
.isNull();
|
||||
|
||||
mockMvc.perform(post("/api/auth/refresh"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(controller.publicHandlerCalled)
|
||||
.as("Публичный обработчик должен быть вызван")
|
||||
.isTrue();
|
||||
assertThat(controller.publicHandlerUser)
|
||||
.as("Публичный обработчик не должен видеть пользователя предыдущего запроса")
|
||||
.isNull();
|
||||
assertThat(controller.publicHandlerThreadId)
|
||||
.as("Публичный запрос должен выполняться в потоке JUnit")
|
||||
.isEqualTo(junitThreadId);
|
||||
|
||||
mockMvc.perform(get("/api/test/teacher")
|
||||
.header("Authorization", "Bearer " + TEACHER_TOKEN))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(controller.protectedHandlerUser)
|
||||
.as("Разрешённый обработчик должен видеть аутентифицированного пользователя")
|
||||
.isEqualTo(teacher);
|
||||
assertThat(controller.protectedHandlerThreadId)
|
||||
.as("Разрешённый запрос должен выполняться в том же потоке JUnit")
|
||||
.isEqualTo(junitThreadId);
|
||||
assertThat(controller.protectedHandlerThreadId)
|
||||
.as("Последовательные обработчики должны выполняться в одном потоке")
|
||||
.isEqualTo(controller.publicHandlerThreadId);
|
||||
assertThat(AuthContext.getCurrentUser())
|
||||
.as("После завершения разрешённого запроса контекст должен быть очищен")
|
||||
.isNull();
|
||||
|
||||
verify(jwtTokenService, times(2)).authenticate(TEACHER_TOKEN, TENANT);
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class ContextProbeController {
|
||||
|
||||
private boolean publicHandlerCalled;
|
||||
private AuthenticatedUser publicHandlerUser;
|
||||
private long publicHandlerThreadId;
|
||||
private AuthenticatedUser protectedHandlerUser;
|
||||
private long protectedHandlerThreadId;
|
||||
|
||||
@PostMapping("/api/auth/refresh")
|
||||
String publicRefresh() {
|
||||
publicHandlerCalled = true;
|
||||
publicHandlerUser = AuthContext.getCurrentUser();
|
||||
publicHandlerThreadId = Thread.currentThread().getId();
|
||||
return publicHandlerUser == null ? "Контекст пуст" : "Контекст содержит пользователя";
|
||||
}
|
||||
|
||||
@GetMapping("/api/test/admin")
|
||||
@RequireRoles(Role.ADMIN)
|
||||
String adminOnly() {
|
||||
return "Администратор";
|
||||
}
|
||||
|
||||
@GetMapping("/api/test/teacher")
|
||||
@RequireRoles(Role.TEACHER)
|
||||
String teacherOnly() {
|
||||
protectedHandlerUser = AuthContext.getCurrentUser();
|
||||
protectedHandlerThreadId = Thread.currentThread().getId();
|
||||
return protectedHandlerUser.username();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class JwtPropertiesTest {
|
||||
|
||||
@Test
|
||||
void rejectsMissingSecret() {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
|
||||
assertThatThrownBy(properties::secretBytes)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("JWT_SECRET обязателен и не может быть пустым");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsShortSecret() {
|
||||
JwtProperties properties = properties("short-value", true);
|
||||
|
||||
assertThatThrownBy(properties::secretBytes)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("JWT_SECRET должен быть не короче 32 байт");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlaceholderSecret() {
|
||||
String placeholder = String.join("-", "replace", "with", "production", "value") + "x".repeat(32);
|
||||
JwtProperties properties = properties(placeholder, true);
|
||||
|
||||
assertThatThrownBy(properties::secretBytes)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("JWT_SECRET содержит известное небезопасное или шаблонное значение");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInsecureRefreshCookieInProduction() {
|
||||
JwtProperties properties = properties(testSecret(), false);
|
||||
|
||||
assertThatThrownBy(() -> properties.validateForEnvironment(true))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsInsecureRefreshCookieOutsideProduction() {
|
||||
JwtProperties properties = properties(testSecret(), false);
|
||||
|
||||
assertThatCode(() -> properties.validateForEnvironment(false)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsSecureProductionConfiguration() {
|
||||
JwtProperties properties = properties(testSecret(), true);
|
||||
|
||||
assertThatCode(() -> properties.validateForEnvironment(true)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private JwtProperties properties(String secret, boolean secureCookie) {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
properties.setSecret(secret);
|
||||
properties.setRefreshCookieSecure(secureCookie);
|
||||
return properties;
|
||||
}
|
||||
|
||||
private String testSecret() {
|
||||
return "A1b2C3d4".repeat(6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class JwtSecurityStartupValidatorTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withUserConfiguration(JwtValidationTestConfiguration.class)
|
||||
.withPropertyValues("spring.profiles.active=production");
|
||||
|
||||
@Test
|
||||
void productionContextDoesNotStartWithoutSecret() {
|
||||
contextRunner
|
||||
.withPropertyValues("app.jwt.refresh-cookie-secure=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure())
|
||||
.hasRootCauseMessage("JWT_SECRET обязателен и не может быть пустым");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionContextDoesNotStartWithPlaceholderSecret() {
|
||||
String placeholder = String.join("-", "change", "this", "production", "value") + "x".repeat(32);
|
||||
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"app.jwt.secret=" + placeholder,
|
||||
"app.jwt.refresh-cookie-secure=true"
|
||||
)
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure())
|
||||
.hasRootCauseMessage(
|
||||
"JWT_SECRET содержит известное небезопасное или шаблонное значение"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionContextStartsWithSecureConfiguration() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"app.jwt.secret=" + "A1b2C3d4".repeat(6),
|
||||
"app.jwt.refresh-cookie-secure=true"
|
||||
)
|
||||
.run(context -> assertThat(context).hasNotFailed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopsStartupForInsecureProductionCookie() {
|
||||
JwtProperties properties = properties(false);
|
||||
MockEnvironment environment = new MockEnvironment();
|
||||
environment.setActiveProfiles("production");
|
||||
|
||||
JwtSecurityStartupValidator validator = new JwtSecurityStartupValidator(properties, environment);
|
||||
|
||||
assertThatThrownBy(validator::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsSecureProductionConfiguration() {
|
||||
JwtProperties properties = properties(true);
|
||||
MockEnvironment environment = new MockEnvironment();
|
||||
environment.setActiveProfiles("production");
|
||||
|
||||
JwtSecurityStartupValidator validator = new JwtSecurityStartupValidator(properties, environment);
|
||||
|
||||
assertThatCode(validator::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private JwtProperties properties(boolean secureCookie) {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
properties.setSecret("A1b2C3d4".repeat(6));
|
||||
properties.setRefreshCookieSecure(secureCookie);
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(JwtProperties.class)
|
||||
static class JwtValidationTestConfiguration {
|
||||
|
||||
@Bean
|
||||
JwtSecurityStartupValidator jwtSecurityStartupValidator(JwtProperties properties,
|
||||
Environment environment) {
|
||||
return new JwtSecurityStartupValidator(properties, environment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthRefreshToken;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = RefreshTokenServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||
properties = "app.jwt.refresh-ttl=7d"
|
||||
)
|
||||
class RefreshTokenServiceConcurrencyIntegrationTest {
|
||||
|
||||
private static final String TENANT = "test-tenant";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private RefreshTokenService service;
|
||||
|
||||
@Autowired
|
||||
private AuthRefreshTokenRepository tokenRepository;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
private ExecutorService executor;
|
||||
|
||||
@AfterEach
|
||||
void stopExecutor() throws InterruptedException {
|
||||
if (executor == null) {
|
||||
return;
|
||||
}
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyOneConcurrentRotationCreatesNextSession() throws Exception {
|
||||
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
||||
|
||||
User user = createTestUser();
|
||||
String rawToken = service.createSession(user, TENANT, "Исходный браузер", "127.0.0.1");
|
||||
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<Optional<RefreshTokenRotation>>> futures = new ArrayList<>();
|
||||
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
Long lockedTokenId = jdbcTemplate.queryForObject(
|
||||
"SELECT id FROM auth_refresh_tokens WHERE token_hash = ? FOR UPDATE",
|
||||
Long.class,
|
||||
service.hashToken(rawToken)
|
||||
);
|
||||
assertThat(lockedTokenId).isNotNull();
|
||||
jdbcTemplate.execute("LOCK TABLE auth_refresh_tokens IN ACCESS EXCLUSIVE MODE");
|
||||
|
||||
futures.add(executor.submit(
|
||||
() -> rotateAfterSignal(rawToken, ready, start, "Первый браузер")
|
||||
));
|
||||
futures.add(executor.submit(
|
||||
() -> rotateAfterSignal(rawToken, ready, start, "Второй браузер")
|
||||
));
|
||||
|
||||
awaitReady(ready);
|
||||
start.countDown();
|
||||
awaitBlockedRotations();
|
||||
});
|
||||
|
||||
List<Optional<RefreshTokenRotation>> results = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
|
||||
assertThat(results).filteredOn(Optional::isPresent).hasSize(1);
|
||||
assertThat(results).filteredOn(Optional::isEmpty).hasSize(1);
|
||||
|
||||
List<AuthRefreshToken> storedTokens = tokenRepository.findAll();
|
||||
assertThat(storedTokens).hasSize(2);
|
||||
assertThat(storedTokens).filteredOn(token -> token.getRevokedAt() == null).hasSize(1);
|
||||
assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1);
|
||||
}
|
||||
|
||||
private Optional<RefreshTokenRotation> rotateAfterSignal(
|
||||
String rawToken,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start,
|
||||
String userAgent
|
||||
) throws InterruptedException {
|
||||
ready.countDown();
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Не удалось синхронно запустить конкурентную ротацию");
|
||||
}
|
||||
return service.rotate(rawToken, TENANT, userAgent, "127.0.0.1");
|
||||
}
|
||||
|
||||
private void awaitReady(CountDownLatch ready) {
|
||||
try {
|
||||
if (!ready.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Конкурентные потоки не успели подготовиться к ротации");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание конкурентных потоков прервано", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitBlockedRotations() {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
do {
|
||||
Integer blockedQueries = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid()
|
||||
AND wait_event_type = 'Lock'
|
||||
""", Integer.class);
|
||||
if (blockedQueries != null && blockedQueries >= 2) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(25);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание блокировки ротации прервано", e);
|
||||
}
|
||||
} while (System.nanoTime() < deadline);
|
||||
|
||||
throw new IllegalStateException("Оба запроса ротации не заблокировались на исходном токене");
|
||||
}
|
||||
|
||||
private User createTestUser() {
|
||||
Long departmentId = jdbcTemplate.queryForObject(
|
||||
"INSERT INTO departments (name, code) VALUES (?, ?) RETURNING id",
|
||||
Long.class,
|
||||
"Тестовая кафедра ротации " + UUID.randomUUID(),
|
||||
System.nanoTime()
|
||||
);
|
||||
|
||||
User user = new User();
|
||||
user.setUsername("rt-" + UUID.randomUUID());
|
||||
user.setPassword("тестовый-хэш");
|
||||
user.setRole(Role.ADMIN);
|
||||
user.setFullName("Тест конкурентной ротации");
|
||||
user.setJobTitle("Тестовый пользователь");
|
||||
user.setDepartmentId(departmentId);
|
||||
return userRepository.saveAndFlush(user);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@EntityScan(basePackageClasses = AuthRefreshToken.class)
|
||||
@EnableJpaRepositories(basePackageClasses = AuthRefreshTokenRepository.class)
|
||||
@EnableConfigurationProperties(JwtProperties.class)
|
||||
@Import(RefreshTokenService.class)
|
||||
static class TestApplication {
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class RefreshTokenServiceTest {
|
||||
RefreshTokenService service = new RefreshTokenService(repository, properties());
|
||||
String rawToken = "raw-refresh-token";
|
||||
AuthRefreshToken stored = activeToken(service.hashToken(rawToken));
|
||||
when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
|
||||
when(repository.findByTokenHashForUpdate(service.hashToken(rawToken))).thenReturn(Optional.of(stored));
|
||||
|
||||
Optional<RefreshTokenRotation> rotation = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1");
|
||||
|
||||
@@ -64,7 +64,7 @@ class RefreshTokenServiceTest {
|
||||
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
|
||||
RefreshTokenService service = new RefreshTokenService(repository, properties());
|
||||
String rawToken = "raw-refresh-token";
|
||||
when(repository.findByTokenHash(service.hashToken(rawToken)))
|
||||
when(repository.findByTokenHashForUpdate(service.hashToken(rawToken)))
|
||||
.thenReturn(Optional.of(activeToken(service.hashToken(rawToken))));
|
||||
|
||||
assertThat(service.rotate(rawToken, "n8n", "Browser", "127.0.0.1")).isEmpty();
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import okhttp3.tls.HandshakeCertificates;
|
||||
import okhttp3.tls.HeldCertificate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class KubernetesTenantSecretUpdaterTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@TempDir
|
||||
Path tempDirectory;
|
||||
|
||||
@Test
|
||||
void updatesSecretThroughTrustedCaWithoutSendingPlainTenantJson() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
HeldCertificate serverCertificate = serverCertificate(ca, "localhost");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||
server.enqueue(new MockResponse().setResponseCode(200));
|
||||
TestFiles files = testFiles(ca);
|
||||
String rawPassword = UUID.randomUUID().toString();
|
||||
TenantConfig tenant = new TenantConfig("Университет", "test", "jdbc:test", "user", rawPassword);
|
||||
KubernetesTenantSecretUpdater updater = updater(files, server);
|
||||
|
||||
boolean updated = updater.updateTenantsConfig(List.of(tenant));
|
||||
|
||||
assertThat(updated).isTrue();
|
||||
RecordedRequest request = server.takeRequest(2, TimeUnit.SECONDS);
|
||||
assertThat(request).isNotNull();
|
||||
assertThat(request.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret");
|
||||
String body = request.getBody().readUtf8();
|
||||
assertThat(body).doesNotContain(rawPassword);
|
||||
|
||||
JsonNode patch = objectMapper.readTree(body);
|
||||
String decoded = new String(
|
||||
Base64.getDecoder().decode(patch.path("data").path("tenants.json").asText()),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
assertThat(decoded).contains("\"domain\" : \"test\"");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsServerSignedByDifferentCa() throws Exception {
|
||||
HeldCertificate trustedCa = certificateAuthority("Доверенный тестовый CA");
|
||||
HeldCertificate untrustedCa = certificateAuthority("Недоверенный тестовый CA");
|
||||
HeldCertificate serverCertificate = serverCertificate(untrustedCa, "localhost");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||
server.enqueue(new MockResponse().setResponseCode(200));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server);
|
||||
|
||||
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCertificateForDifferentHostname() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
HeldCertificate serverCertificate = serverCertificate(ca, "not-localhost.invalid");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||
server.enqueue(new MockResponse().setResponseCode(200));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
||||
|
||||
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenServiceAccountCaIsMissing() throws Exception {
|
||||
TestFiles files = testFiles(certificateAuthority("Доверенный тестовый CA"));
|
||||
Files.delete(files.caPath());
|
||||
KubernetesTenantSecretUpdater updater = new KubernetesTenantSecretUpdater(
|
||||
files.tokenPath(),
|
||||
files.namespacePath(),
|
||||
files.caPath(),
|
||||
"https://localhost:1",
|
||||
"tenants-secret",
|
||||
objectMapper
|
||||
);
|
||||
|
||||
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
|
||||
}
|
||||
|
||||
private KubernetesTenantSecretUpdater updater(TestFiles files, MockWebServer server) {
|
||||
String apiBase = server.url("/").toString();
|
||||
return new KubernetesTenantSecretUpdater(
|
||||
files.tokenPath(),
|
||||
files.namespacePath(),
|
||||
files.caPath(),
|
||||
apiBase,
|
||||
"tenants-secret",
|
||||
objectMapper
|
||||
);
|
||||
}
|
||||
|
||||
private TestFiles testFiles(HeldCertificate ca) throws Exception {
|
||||
Path tokenPath = tempDirectory.resolve(UUID.randomUUID() + "-token");
|
||||
Path namespacePath = tempDirectory.resolve(UUID.randomUUID() + "-namespace");
|
||||
Path caPath = tempDirectory.resolve(UUID.randomUUID() + "-ca.crt");
|
||||
Files.writeString(tokenPath, "test-service-account-token");
|
||||
Files.writeString(namespacePath, "magistr");
|
||||
Files.writeString(caPath, ca.certificatePem());
|
||||
return new TestFiles(tokenPath, namespacePath, caPath);
|
||||
}
|
||||
|
||||
private HeldCertificate certificateAuthority(String commonName) {
|
||||
return new HeldCertificate.Builder()
|
||||
.certificateAuthority(1)
|
||||
.commonName(commonName)
|
||||
.duration(1, TimeUnit.DAYS)
|
||||
.build();
|
||||
}
|
||||
|
||||
private HeldCertificate serverCertificate(HeldCertificate ca, String hostname) {
|
||||
return new HeldCertificate.Builder()
|
||||
.commonName(hostname)
|
||||
.addSubjectAlternativeName(hostname)
|
||||
.duration(1, TimeUnit.DAYS)
|
||||
.signedBy(ca)
|
||||
.build();
|
||||
}
|
||||
|
||||
private MockWebServer httpsServer(HeldCertificate serverCertificate) throws Exception {
|
||||
HandshakeCertificates certificates = new HandshakeCertificates.Builder()
|
||||
.heldCertificate(serverCertificate)
|
||||
.build();
|
||||
MockWebServer server = new MockWebServer();
|
||||
server.useHttps(certificates.sslSocketFactory(), false);
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private record TestFiles(Path tokenPath, Path namespacePath, Path caPath) {
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,37 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.magistr.app.service.RetiredTenantPoolService;
|
||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
class TenantConfigWatcherTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDirectory;
|
||||
|
||||
@Test
|
||||
void configHashUsesSha256Hex() {
|
||||
String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
|
||||
@@ -20,4 +46,138 @@ class TenantConfigWatcherTest {
|
||||
assertThat(TenantConfigWatcher.configHash("tenant-a"))
|
||||
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception {
|
||||
Path configPath = tempDirectory.resolve("tenants.json");
|
||||
String content = """
|
||||
[{
|
||||
"name": "Тестовый tenant",
|
||||
"domain": "alpha",
|
||||
"url": "jdbc:postgresql://db/alpha",
|
||||
"username": "alpha",
|
||||
"password": "тестовый-пароль"
|
||||
}]
|
||||
""";
|
||||
Files.writeString(configPath, content);
|
||||
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||
doThrow(new IllegalStateException("тестовая ошибка синхронизации"))
|
||||
.doNothing()
|
||||
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||
|
||||
watcher.watchForChanges();
|
||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
|
||||
|
||||
watcher.watchForChanges();
|
||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||
.isEqualTo(TenantConfigWatcher.configHash(content));
|
||||
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
||||
|
||||
watcher.watchForChanges();
|
||||
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulHashPreventsRepeatedSynchronizationOfSameContent() throws Exception {
|
||||
Path configPath = tempDirectory.resolve("tenants.json");
|
||||
String content = "[]";
|
||||
Files.writeString(configPath, content);
|
||||
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||
doNothing().when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||
|
||||
watcher.watchForChanges();
|
||||
watcher.watchForChanges();
|
||||
|
||||
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||
.isEqualTo(TenantConfigWatcher.configHash(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void watcherCannotReadBetweenSerializedApiMutationAndRefreshHash() throws Exception {
|
||||
Path configPath = tempDirectory.resolve("tenants.json");
|
||||
String content = "[]";
|
||||
Files.writeString(configPath, content);
|
||||
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
|
||||
TenantLifecycleService lifecycleService = new TenantLifecycleService(
|
||||
routingDataSource,
|
||||
mock(KubernetesTenantSecretUpdater.class),
|
||||
mock(TenantDatabaseMigrationService.class),
|
||||
mock(RetiredTenantPoolService.class)
|
||||
);
|
||||
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService);
|
||||
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
|
||||
CountDownLatch allowApiRefresh = new CountDownLatch(1);
|
||||
AtomicReference<Thread> watcherThread = new AtomicReference<>();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
|
||||
try {
|
||||
Future<?> api = executor.submit(() -> lifecycleService.executeSerialized(() -> {
|
||||
apiMutationReachedRefreshWindow.countDown();
|
||||
await(allowApiRefresh, "Ожидание разрешения refreshHash прервано");
|
||||
watcher.refreshHash();
|
||||
}));
|
||||
assertThat(apiMutationReachedRefreshWindow.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
Future<?> concurrentWatcher = executor.submit(() -> {
|
||||
watcherThread.set(Thread.currentThread());
|
||||
watcher.watchForChanges();
|
||||
});
|
||||
awaitBlocked(watcherThread);
|
||||
assertThat(concurrentWatcher).isNotDone();
|
||||
|
||||
allowApiRefresh.countDown();
|
||||
api.get(5, TimeUnit.SECONDS);
|
||||
concurrentWatcher.get(5, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||
.isEqualTo(TenantConfigWatcher.configHash(content));
|
||||
verify(routingDataSource, never()).snapshotTenantConfigs();
|
||||
} finally {
|
||||
allowApiRefresh.countDown();
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, Path configPath) {
|
||||
doAnswer(invocation -> {
|
||||
invocation.<Runnable>getArgument(0).run();
|
||||
return null;
|
||||
}).when(lifecycleService).executeSerialized(any(Runnable.class));
|
||||
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService);
|
||||
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||
return watcher;
|
||||
}
|
||||
|
||||
private void await(CountDownLatch latch, String message) {
|
||||
try {
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(message, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitBlocked(AtomicReference<Thread> threadReference) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (System.nanoTime() < deadline) {
|
||||
Thread thread = threadReference.get();
|
||||
if (thread != null && thread.getState() == Thread.State.BLOCKED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(10);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание блокировки watcher прервано", exception);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Watcher не заблокировался на общем lifecycle coordinator");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TenantRoutingDataSourceTest {
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallyPublishesOneImmutableStateAndKeepsObtainedConnectionUsable() throws Exception {
|
||||
TenantRoutingDataSource routing = new TenantRoutingDataSource();
|
||||
DataSource oldDataSource = mock(DataSource.class);
|
||||
DataSource newDataSource = mock(DataSource.class);
|
||||
Connection oldConnection = mock(Connection.class);
|
||||
Connection newConnection = mock(Connection.class);
|
||||
when(oldDataSource.getConnection()).thenReturn(oldConnection);
|
||||
when(newDataSource.getConnection()).thenReturn(newConnection);
|
||||
when(oldConnection.isValid(1)).thenReturn(true);
|
||||
TenantConfig oldConfig = config("Старый", "ALPHA", "jdbc:old");
|
||||
TenantConfig newConfig = config("Новый", "alpha", "jdbc:new");
|
||||
|
||||
assertThat(routing.swapTenant(oldConfig, oldDataSource)).isNull();
|
||||
oldConfig.setUrl("jdbc:mutated-request");
|
||||
TenantContext.setCurrentTenant("alpha");
|
||||
Connection inFlight = routing.getConnection();
|
||||
assertThat(inFlight).isSameAs(oldConnection);
|
||||
|
||||
TenantRoutingDataSource.TenantState previous = routing.swapTenant(newConfig, newDataSource);
|
||||
|
||||
assertThat(previous).isNotNull();
|
||||
assertThat(previous.dataSource()).isSameAs(oldDataSource);
|
||||
assertThat(previous.config().getUrl()).isEqualTo("jdbc:old");
|
||||
assertThat(routing.getConnection()).isSameAs(newConnection);
|
||||
assertThat(inFlight.isValid(1)).isTrue();
|
||||
verify(oldConnection).isValid(1);
|
||||
assertThat(routing.snapshotTenantConfigs().get("alpha").getUrl()).isEqualTo("jdbc:new");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsAreDetachedAndUnmodifiableAndRemovalReturnsRetiredState() {
|
||||
TenantRoutingDataSource routing = new TenantRoutingDataSource();
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
TenantConfig config = config("Alpha", "alpha", "jdbc:alpha");
|
||||
routing.swapTenant(config, dataSource);
|
||||
|
||||
Map<String, TenantConfig> snapshot = routing.snapshotTenantConfigs();
|
||||
snapshot.get("alpha").setUrl("jdbc:changed-copy");
|
||||
|
||||
assertThat(routing.snapshotTenantConfigs().get("alpha").getUrl()).isEqualTo("jdbc:alpha");
|
||||
assertThatThrownBy(() -> snapshot.put("beta", config("Beta", "beta", "jdbc:beta")))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
TenantRoutingDataSource.TenantState removed = routing.removeTenantAtomically(" ALPHA ");
|
||||
assertThat(removed).isNotNull();
|
||||
assertThat(removed.dataSource()).isSameAs(dataSource);
|
||||
assertThat(routing.snapshotTenant("alpha")).isEmpty();
|
||||
assertThat(routing.getTenantDataSource("alpha")).isEmpty();
|
||||
}
|
||||
|
||||
private TenantConfig config(String name, String domain, String url) {
|
||||
return new TenantConfig(name, domain, url, "user", "password");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.service.ScheduleConflictException;
|
||||
import com.magistr.app.service.ScheduleOverrideService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
class ScheduleOverrideControllerTest {
|
||||
|
||||
private ScheduleOverrideService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(ScheduleOverrideService.class);
|
||||
mockMvc = standaloneSetup(new ScheduleOverrideController(service))
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsConflictWithRussianMessageForScheduleConflict() throws Exception {
|
||||
when(service.create(any(ScheduleOverrideDto.class))).thenThrow(new ScheduleConflictException(
|
||||
"Конфликт расписания: преподаватель уже занят в указанное время"
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/edu-office/schedule/overrides")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"baseRuleSlotId": 101,
|
||||
"lessonDate": "2026-09-01",
|
||||
"action": "MOVE",
|
||||
"newTimeSlotId": 2
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.status").value(409))
|
||||
.andExpect(jsonPath("$.error").value("Конфликт"))
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Конфликт расписания: преподаватель уже занят в указанное время"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||
import com.magistr.app.service.ScheduleRuleService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
class ScheduleRuleAdminControllerTest {
|
||||
|
||||
private ScheduleRuleService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(ScheduleRuleService.class);
|
||||
mockMvc = standaloneSetup(new ScheduleRuleAdminController(service))
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsBadRequestWithRussianValidationMessage() throws Exception {
|
||||
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
|
||||
"Количество часов для лекций должно быть чётным"
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/admin/schedule-rules")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.status").value(400))
|
||||
.andExpect(jsonPath("$.error").value("Некорректный запрос"))
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Количество часов для лекций должно быть чётным"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsInternalConflictWithoutArtificialConflictRule() throws Exception {
|
||||
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new ScheduleRuleConflictException(
|
||||
"Невозможно сохранить правило: слоты внутри правила конфликтуют",
|
||||
null,
|
||||
List.of("teacher", "group"),
|
||||
List.of("Преподаватель", "Группа")
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/admin/schedule-rules")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson()))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Невозможно сохранить правило: слоты внутри правила конфликтуют"))
|
||||
.andExpect(jsonPath("$.conflictRule").doesNotExist())
|
||||
.andExpect(jsonPath("$.conflictFields[0]").value("teacher"))
|
||||
.andExpect(jsonPath("$.conflictFields[1]").value("group"))
|
||||
.andExpect(jsonPath("$.conflictReasons[0]").value("Преподаватель"))
|
||||
.andExpect(jsonPath("$.conflictReasons[1]").value("Группа"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsPersistedConflictWithConflictingRuleDto() throws Exception {
|
||||
ScheduleRuleDto conflictingRule = dto(321L);
|
||||
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new ScheduleRuleConflictException(
|
||||
"Невозможно сохранить правило: слот занят",
|
||||
conflictingRule,
|
||||
List.of("classroom"),
|
||||
List.of("Аудитория")
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/admin/schedule-rules")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson()))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").value("Невозможно сохранить правило: слот занят"))
|
||||
.andExpect(jsonPath("$.conflictRule.id").value(321))
|
||||
.andExpect(jsonPath("$.conflictFields[0]").value("classroom"))
|
||||
.andExpect(jsonPath("$.conflictReasons[0]").value("Аудитория"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNotFoundWhenRuleDoesNotExist() throws Exception {
|
||||
when(service.getById(404L)).thenThrow(new NoSuchElementException("Правило расписания не найдено"));
|
||||
|
||||
mockMvc.perform(get("/api/admin/schedule-rules/404"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.status").value(404))
|
||||
.andExpect(jsonPath("$.error").value("Не найдено"))
|
||||
.andExpect(jsonPath("$.message").value("Правило расписания не найдено"));
|
||||
}
|
||||
|
||||
private ScheduleRuleDto dto(Long id) {
|
||||
return new ScheduleRuleDto(
|
||||
id,
|
||||
1L,
|
||||
"Дисциплина",
|
||||
2L,
|
||||
"2026-2027",
|
||||
null,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
List.of(3L),
|
||||
List.of("Группа"),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private String validRequestJson() {
|
||||
return """
|
||||
{
|
||||
"subjectId": 1,
|
||||
"semesterId": 2,
|
||||
"lectureAcademicHours": 2,
|
||||
"laboratoryAcademicHours": 0,
|
||||
"practiceAcademicHours": 0,
|
||||
"lectureStartWeek": 1,
|
||||
"laboratoryStartWeek": 1,
|
||||
"practiceStartWeek": 1,
|
||||
"groupIds": [3],
|
||||
"slots": [
|
||||
{
|
||||
"dayOfWeek": 1,
|
||||
"parity": "BOTH",
|
||||
"timeSlotId": 4,
|
||||
"teacherId": 5,
|
||||
"classroomId": 6,
|
||||
"lessonTypeId": 7,
|
||||
"lessonFormat": "Очно"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Date;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Миграция инвариантов точечных изменений расписания")
|
||||
class ScheduleOverrideMigrationIntegrationTest {
|
||||
|
||||
private static final String[] OVERRIDE_CONSTRAINTS = {
|
||||
"chk_schedule_overrides_cancel_payload",
|
||||
"chk_schedule_overrides_move_payload",
|
||||
"chk_schedule_overrides_replace_payload",
|
||||
"chk_schedule_overrides_format"
|
||||
};
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные")
|
||||
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
|
||||
Flyway flyway = targetThreeFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current())
|
||||
.as("После полной миграции должна существовать текущая версия")
|
||||
.isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна быть V3")
|
||||
.isEqualTo("3");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
LocalDate validDate = LocalDate.of(2026, 3, 2);
|
||||
|
||||
insertOverride(connection, seedIds, validDate, "CANCEL", null, null, null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(1), "MOVE", seedIds.classroomId(), null, null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(2), "REPLACE", null, seedIds.teacherId(), null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(3), "REPLACE", null, null, "Онлайн");
|
||||
|
||||
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_overrides"))
|
||||
.as("Все допустимые варианты точечного изменения должны сохраниться")
|
||||
.isEqualTo(4L);
|
||||
|
||||
LocalDate invalidDate = LocalDate.of(2026, 4, 6);
|
||||
assertCheckViolation(
|
||||
connection,
|
||||
seedIds,
|
||||
invalidDate,
|
||||
"CANCEL",
|
||||
seedIds.classroomId(),
|
||||
null,
|
||||
null,
|
||||
"chk_schedule_overrides_cancel_payload"
|
||||
);
|
||||
assertCheckViolation(
|
||||
connection,
|
||||
seedIds,
|
||||
invalidDate.plusDays(1),
|
||||
"MOVE",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"chk_schedule_overrides_move_payload"
|
||||
);
|
||||
assertCheckViolation(
|
||||
connection,
|
||||
seedIds,
|
||||
invalidDate.plusDays(2),
|
||||
"REPLACE",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"chk_schedule_overrides_replace_payload"
|
||||
);
|
||||
assertCheckViolation(
|
||||
connection,
|
||||
seedIds,
|
||||
invalidDate.plusDays(3),
|
||||
"REPLACE",
|
||||
null,
|
||||
seedIds.teacherId(),
|
||||
"Смешанный",
|
||||
"chk_schedule_overrides_format"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V3 останавливается на legacy-данных до добавления ограничений")
|
||||
void migrationFailsPreflightWithoutPartialConstraints() throws SQLException {
|
||||
Flyway targetTwo = targetTwoFlyway();
|
||||
targetTwo.clean();
|
||||
targetTwo.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
LocalDate.of(2026, 5, 4),
|
||||
"REPLACE",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
Flyway targetThree = targetThreeFlyway();
|
||||
Throwable migrationFailure = catchThrowable(targetThree::migrate);
|
||||
|
||||
assertThat(migrationFailure)
|
||||
.as("V3 должна остановить миграцию при пустом legacy-изменении REPLACE")
|
||||
.isNotNull();
|
||||
assertThat(collectMessages(migrationFailure))
|
||||
.as("Ошибка preflight должна объяснять проблему на русском языке")
|
||||
.contains("Невозможно применить инварианты точечных изменений")
|
||||
.contains("REPLACE=1")
|
||||
.contains("Исправьте данные tenant-БД и повторите миграцию");
|
||||
|
||||
assertThat(targetThree.info().current())
|
||||
.as("После отката V3 должна остаться текущая успешная версия")
|
||||
.isNotNull();
|
||||
assertThat(targetThree.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна остаться V2")
|
||||
.isEqualTo("2");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '3'"
|
||||
))
|
||||
.as("V3 не должна отмечаться как успешно применённая")
|
||||
.isZero();
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'schedule_overrides'::regclass
|
||||
AND conname::text = ANY (?)
|
||||
""",
|
||||
connection.createArrayOf("text", OVERRIDE_CONSTRAINTS)
|
||||
))
|
||||
.as("Ни одно ограничение V3 не должно остаться после неуспешного preflight")
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway targetThreeFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("3"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private Flyway targetTwoFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private SeedIds loadSeedIds(Connection connection) throws SQLException {
|
||||
return new SeedIds(
|
||||
queryLong(connection, "SELECT id FROM schedule_rule_slots ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM classrooms ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1")
|
||||
);
|
||||
}
|
||||
|
||||
private void insertOverride(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
LocalDate lessonDate,
|
||||
String action,
|
||||
Long classroomId,
|
||||
Long teacherId,
|
||||
String lessonFormat
|
||||
) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_overrides (
|
||||
base_rule_slot_id,
|
||||
lesson_date,
|
||||
action,
|
||||
new_classroom_id,
|
||||
new_teacher_id,
|
||||
new_lesson_format
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""")) {
|
||||
statement.setLong(1, seedIds.baseRuleSlotId());
|
||||
statement.setDate(2, Date.valueOf(lessonDate));
|
||||
statement.setString(3, action);
|
||||
setNullableLong(statement, 4, classroomId);
|
||||
setNullableLong(statement, 5, teacherId);
|
||||
statement.setString(6, lessonFormat);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertCheckViolation(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
LocalDate lessonDate,
|
||||
String action,
|
||||
Long classroomId,
|
||||
Long teacherId,
|
||||
String lessonFormat,
|
||||
String expectedConstraint
|
||||
) {
|
||||
Throwable failure = catchThrowable(() -> insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
lessonDate,
|
||||
action,
|
||||
classroomId,
|
||||
teacherId,
|
||||
lessonFormat
|
||||
));
|
||||
|
||||
assertThat(failure)
|
||||
.as("Недопустимые данные должны быть отклонены ограничением %s", expectedConstraint)
|
||||
.isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState())
|
||||
.as("Ограничение %s должно вернуть SQLSTATE нарушения CHECK", expectedConstraint)
|
||||
.isEqualTo("23514");
|
||||
assertThat(sqlFailure.getMessage())
|
||||
.as("Ошибка должна содержать имя сработавшего ограничения")
|
||||
.contains(expectedConstraint);
|
||||
}
|
||||
|
||||
private void setNullableLong(PreparedStatement statement, int index, Long value) throws SQLException {
|
||||
if (value == null) {
|
||||
statement.setObject(index, null);
|
||||
} else {
|
||||
statement.setLong(index, value);
|
||||
}
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next())
|
||||
.as("SQL-запрос должен вернуть одну строку: %s", sql)
|
||||
.isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String collectMessages(Throwable failure) {
|
||||
StringBuilder messages = new StringBuilder();
|
||||
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Throwable current = failure;
|
||||
while (current != null && visited.add(current)) {
|
||||
if (current.getMessage() != null) {
|
||||
messages.append(current.getMessage()).append('\n');
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return messages.toString();
|
||||
}
|
||||
|
||||
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.magistr.app.migration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Миграция чётности академических часов правил расписания")
|
||||
class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
|
||||
private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
|
||||
private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@Test
|
||||
@DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
||||
void migrationEnforcesEvenAcademicHours() throws SQLException {
|
||||
Flyway flyway = targetFourFlyway();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current())
|
||||
.as("После полной миграции должна существовать текущая версия")
|
||||
.isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна быть V4")
|
||||
.isEqualTo("4");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");
|
||||
|
||||
insertScheduleRule(connection, seedIds, 2, 4, 6);
|
||||
|
||||
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_rules"))
|
||||
.as("Правило с чётными академическими часами должно сохраниться")
|
||||
.isEqualTo(rulesBeforeInsert + 1);
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'schedule_rules'::regclass
|
||||
AND conname = ?
|
||||
AND convalidated
|
||||
""",
|
||||
HOURS_CONSTRAINT
|
||||
))
|
||||
.as("Ограничение чётности V4 должно быть провалидировано")
|
||||
.isOne();
|
||||
|
||||
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
|
||||
assertOddHoursRejected(connection, seedIds, 2, 1, 2, "лабораторных работ");
|
||||
assertOddHoursRejected(connection, seedIds, 2, 2, 1, "практик");
|
||||
assertExactSlotDuplicateRejected(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V4 останавливается на legacy-правиле с нечётными часами до добавления ограничения")
|
||||
void migrationFailsPreflightWithoutPartialConstraint() throws SQLException {
|
||||
Flyway targetThree = targetThreeFlyway();
|
||||
targetThree.clean();
|
||||
targetThree.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
insertScheduleRule(connection, seedIds, 1, 3, 5);
|
||||
}
|
||||
|
||||
Flyway targetFour = targetFourFlyway();
|
||||
Throwable migrationFailure = catchThrowable(targetFour::migrate);
|
||||
|
||||
assertThat(migrationFailure)
|
||||
.as("V4 должна остановить миграцию при нечётных часах legacy-правила")
|
||||
.isNotNull();
|
||||
assertThat(collectMessages(migrationFailure))
|
||||
.as("Ошибка preflight должна объяснять проблему на русском языке")
|
||||
.contains("Невозможно применить инвариант чётности академических часов правил расписания")
|
||||
.contains("лекции=1")
|
||||
.contains("лабораторные=1")
|
||||
.contains("практики=1")
|
||||
.contains("Исправьте данные tenant-БД и повторите миграцию");
|
||||
|
||||
assertThat(targetFour.info().current())
|
||||
.as("После отката V4 должна остаться текущая успешная версия")
|
||||
.isNotNull();
|
||||
assertThat(targetFour.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна остаться V3")
|
||||
.isEqualTo("3");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
|
||||
))
|
||||
.as("V4 не должна отмечаться как успешно применённая")
|
||||
.isZero();
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'schedule_rules'::regclass
|
||||
AND conname = ?
|
||||
""",
|
||||
HOURS_CONSTRAINT
|
||||
))
|
||||
.as("Ограничение V4 не должно частично остаться после неуспешного preflight")
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V4 останавливается на legacy-дубле слота до добавления ограничений")
|
||||
void migrationFailsOnLegacyExactSlotDuplicate() throws SQLException {
|
||||
Flyway targetThree = targetThreeFlyway();
|
||||
targetThree.clean();
|
||||
targetThree.migrate();
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
duplicateExistingSlot(connection);
|
||||
}
|
||||
|
||||
Flyway targetFour = targetFourFlyway();
|
||||
Throwable migrationFailure = catchThrowable(targetFour::migrate);
|
||||
|
||||
assertThat(migrationFailure)
|
||||
.as("V4 должна остановить миграцию при точном legacy-дубле слота")
|
||||
.isNotNull();
|
||||
assertThat(collectMessages(migrationFailure))
|
||||
.as("Ошибка preflight должна объяснять проблему уникальности на русском языке")
|
||||
.contains("Невозможно применить инвариант уникальности слотов правил расписания")
|
||||
.contains("групп точных дублей=1")
|
||||
.contains("Исправьте данные tenant-БД и повторите миграцию");
|
||||
|
||||
assertThat(targetFour.info().current())
|
||||
.as("После отката V4 должна остаться текущая успешная версия")
|
||||
.isNotNull();
|
||||
assertThat(targetFour.info().current().getVersion().getVersion()).isEqualTo("3");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
|
||||
)).isZero();
|
||||
assertConstraintAbsent(connection, HOURS_CONSTRAINT);
|
||||
assertConstraintAbsent(connection, EXACT_SLOT_CONSTRAINT);
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway targetFourFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("4"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private Flyway targetThreeFlyway() {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("3"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
private SeedIds loadSeedIds(Connection connection) throws SQLException {
|
||||
return new SeedIds(
|
||||
queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1")
|
||||
);
|
||||
}
|
||||
|
||||
private void insertScheduleRule(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
int lectureHours,
|
||||
int laboratoryHours,
|
||||
int practiceHours
|
||||
) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_rules (
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
|
||||
""")) {
|
||||
statement.setLong(1, seedIds.subjectId());
|
||||
statement.setLong(2, seedIds.semesterId());
|
||||
statement.setInt(3, lectureHours);
|
||||
statement.setInt(4, laboratoryHours);
|
||||
statement.setInt(5, practiceHours);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOddHoursRejected(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
int lectureHours,
|
||||
int laboratoryHours,
|
||||
int practiceHours,
|
||||
String lessonKind
|
||||
) {
|
||||
Throwable failure = catchThrowable(() -> insertScheduleRule(
|
||||
connection,
|
||||
seedIds,
|
||||
lectureHours,
|
||||
laboratoryHours,
|
||||
practiceHours
|
||||
));
|
||||
|
||||
assertThat(failure)
|
||||
.as("Нечётное количество часов %s должно быть отклонено", lessonKind)
|
||||
.isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState())
|
||||
.as("Ограничение чётности для %s должно вернуть SQLSTATE нарушения CHECK", lessonKind)
|
||||
.isEqualTo("23514");
|
||||
assertThat(sqlFailure.getMessage())
|
||||
.as("Ошибка должна содержать имя ограничения чётности")
|
||||
.contains(HOURS_CONSTRAINT);
|
||||
}
|
||||
|
||||
private void assertExactSlotDuplicateRejected(Connection connection) {
|
||||
Throwable failure = catchThrowable(() -> duplicateExistingSlot(connection));
|
||||
|
||||
assertThat(failure)
|
||||
.as("Точный дубль слота одного правила должен быть отклонён")
|
||||
.isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState())
|
||||
.as("Ограничение уникальности слота должно вернуть SQLSTATE unique violation")
|
||||
.isEqualTo("23505");
|
||||
assertThat(sqlFailure.getMessage()).contains(EXACT_SLOT_CONSTRAINT);
|
||||
}
|
||||
|
||||
private void duplicateExistingSlot(Connection connection) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_rule_slots (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
)
|
||||
SELECT
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
FROM schedule_rule_slots
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""")) {
|
||||
assertThat(statement.executeUpdate())
|
||||
.as("Для проверки должен быть скопирован один seed-слот")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertConstraintAbsent(Connection connection, String constraintName) throws SQLException {
|
||||
assertThat(queryLong(
|
||||
connection,
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid IN ('schedule_rules'::regclass, 'schedule_rule_slots'::regclass)
|
||||
AND conname = ?
|
||||
""",
|
||||
constraintName
|
||||
)).as("Ограничение %s не должно частично остаться после неуспешной V4", constraintName)
|
||||
.isZero();
|
||||
}
|
||||
|
||||
private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next())
|
||||
.as("SQL-запрос должен вернуть одну строку: %s", sql)
|
||||
.isTrue();
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String collectMessages(Throwable failure) {
|
||||
StringBuilder messages = new StringBuilder();
|
||||
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Throwable current = failure;
|
||||
while (current != null && visited.add(current)) {
|
||||
if (current.getMessage() != null) {
|
||||
messages.append(current.getMessage()).append('\n');
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return messages.toString();
|
||||
}
|
||||
|
||||
private record SeedIds(long subjectId, long semesterId) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarGridDayDto;
|
||||
import com.magistr.app.model.AcademicCalendarDay;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = AcademicCalendarGridServiceIntegrationTest.TestApplication.class,
|
||||
properties = "spring.jpa.open-in-view=false"
|
||||
)
|
||||
class AcademicCalendarGridServiceIntegrationTest {
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private AcademicCalendarGridService service;
|
||||
|
||||
@Autowired
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
@Test
|
||||
void invalidSecondRowKeepsOldGridAndSuccessfulReplacementIsAtomic() {
|
||||
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
||||
|
||||
long calendarId = queryLong("""
|
||||
SELECT calendar.id
|
||||
FROM academic_calendars calendar
|
||||
JOIN academic_years year ON year.id = calendar.academic_year_id
|
||||
WHERE year.title = '2025-2026'
|
||||
ORDER BY calendar.id
|
||||
LIMIT 1
|
||||
""");
|
||||
long theoryActivityId = queryLong(
|
||||
"SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'"
|
||||
);
|
||||
long holidayActivityId = queryLong(
|
||||
"SELECT id FROM academic_calendar_activity_types WHERE code = 'К'"
|
||||
);
|
||||
LocalDate yearStart = jdbcTemplate.queryForObject("""
|
||||
SELECT year.start_date
|
||||
FROM academic_calendars calendar
|
||||
JOIN academic_years year ON year.id = calendar.academic_year_id
|
||||
WHERE calendar.id = ?
|
||||
""", LocalDate.class, calendarId);
|
||||
assertThat(yearStart).isEqualTo(LocalDate.of(2025, 9, 1));
|
||||
|
||||
GridFingerprint before = fingerprint(calendarId);
|
||||
assertThat(before.rowCount()).isPositive();
|
||||
AcademicCalendarGridDayDto firstValid = row(
|
||||
calendarId, 1, yearStart, 1, 1, holidayActivityId, "К"
|
||||
);
|
||||
AcademicCalendarGridDayDto secondInvalid = row(
|
||||
calendarId, 1, yearStart.plusDays(1), 1, 7, theoryActivityId, "Т"
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(
|
||||
calendarId,
|
||||
List.of(firstValid, secondInvalid)
|
||||
))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("День недели не соответствует дате");
|
||||
|
||||
assertThat(fingerprint(calendarId)).isEqualTo(before);
|
||||
assertThat(queryLong("""
|
||||
SELECT count(*)
|
||||
FROM academic_calendar_days day
|
||||
WHERE day.calendar_id = ?
|
||||
AND day.course_number = 1
|
||||
AND day.date = ?
|
||||
AND day.activity_type_id = ?
|
||||
""", calendarId, yearStart, holidayActivityId)).isZero();
|
||||
verify(scheduleGeneratorService, never()).clearCache();
|
||||
|
||||
AcademicCalendarGridDayDto secondValid = row(
|
||||
calendarId, 1, yearStart.plusDays(1), 1, 2, theoryActivityId, "Т"
|
||||
);
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
service.replaceGrid(calendarId, List.of(firstValid, secondValid));
|
||||
verify(scheduleGeneratorService, never()).clearCache();
|
||||
});
|
||||
|
||||
GridFingerprint after = fingerprint(calendarId);
|
||||
assertThat(after.rowCount()).isEqualTo(2L);
|
||||
assertThat(after).isNotEqualTo(before);
|
||||
assertThat(queryLong("""
|
||||
SELECT count(*)
|
||||
FROM academic_calendar_days day
|
||||
WHERE day.calendar_id = ?
|
||||
AND (
|
||||
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
|
||||
OR
|
||||
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
|
||||
)
|
||||
""", calendarId, yearStart, holidayActivityId,
|
||||
yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
|
||||
verify(scheduleGeneratorService, times(1)).clearCache();
|
||||
}
|
||||
|
||||
private GridFingerprint fingerprint(long calendarId) {
|
||||
GridFingerprint fingerprint = jdbcTemplate.queryForObject("""
|
||||
SELECT
|
||||
count(*) AS row_count,
|
||||
md5(string_agg(
|
||||
concat_ws('|',
|
||||
day.course_number::text,
|
||||
day.date::text,
|
||||
day.week_number::text,
|
||||
day.day_of_week::text,
|
||||
day.activity_type_id::text
|
||||
),
|
||||
E'\\n' ORDER BY day.course_number, day.date, day.id
|
||||
)) AS content_hash
|
||||
FROM academic_calendar_days day
|
||||
WHERE day.calendar_id = ?
|
||||
""", (result, rowNumber) -> new GridFingerprint(
|
||||
result.getLong("row_count"),
|
||||
result.getString("content_hash")
|
||||
), calendarId);
|
||||
assertThat(fingerprint).isNotNull();
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
private long queryLong(String sql, Object... arguments) {
|
||||
Long value = jdbcTemplate.queryForObject(sql, Long.class, arguments);
|
||||
assertThat(value).isNotNull();
|
||||
return value;
|
||||
}
|
||||
|
||||
private AcademicCalendarGridDayDto row(long calendarId,
|
||||
int course,
|
||||
LocalDate date,
|
||||
int week,
|
||||
int dayOfWeek,
|
||||
long activityTypeId,
|
||||
String activityCode) {
|
||||
return new AcademicCalendarGridDayDto(
|
||||
null,
|
||||
calendarId,
|
||||
course,
|
||||
date,
|
||||
week,
|
||||
dayOfWeek,
|
||||
activityTypeId,
|
||||
activityCode,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@EntityScan(basePackageClasses = AcademicCalendarDay.class)
|
||||
@EnableJpaRepositories(basePackageClasses = AcademicCalendarDayRepository.class)
|
||||
@Import({AcademicCalendarGridService.class, TestDependencies.class})
|
||||
static class TestApplication {
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class TestDependencies {
|
||||
|
||||
@Bean
|
||||
ScheduleGeneratorService scheduleGeneratorService() {
|
||||
return mock(ScheduleGeneratorService.class);
|
||||
}
|
||||
}
|
||||
|
||||
private record GridFingerprint(long rowCount, String contentHash) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AcademicCalendarGridServiceTest {
|
||||
|
||||
private static final long CALENDAR_ID = 10L;
|
||||
private static final long ACTIVITY_TYPE_ID = 20L;
|
||||
private static final LocalDate YEAR_START = LocalDate.of(2025, 9, 1);
|
||||
|
||||
@Mock
|
||||
private AcademicCalendarRepository calendarRepository;
|
||||
@Mock
|
||||
private AcademicCalendarDayRepository calendarDayRepository;
|
||||
@Mock
|
||||
private AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
@Mock
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
private AcademicCalendarGridService service;
|
||||
private AcademicCalendarActivityType activityType;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
AcademicYear academicYear = new AcademicYear();
|
||||
academicYear.setId(1L);
|
||||
academicYear.setTitle("2025-2026");
|
||||
academicYear.setStartDate(YEAR_START);
|
||||
academicYear.setEndDate(LocalDate.of(2026, 6, 30));
|
||||
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
calendar.setId(CALENDAR_ID);
|
||||
calendar.setTitle("Тестовый календарный график");
|
||||
calendar.setAcademicYear(academicYear);
|
||||
calendar.setCourseCount(4);
|
||||
|
||||
activityType = new AcademicCalendarActivityType();
|
||||
activityType.setId(ACTIVITY_TYPE_ID);
|
||||
activityType.setCode("Т");
|
||||
activityType.setName("Теоретическое обучение");
|
||||
activityType.setAllowSchedule(true);
|
||||
|
||||
service = new AcademicCalendarGridService(
|
||||
calendarRepository,
|
||||
calendarDayRepository,
|
||||
activityTypeRepository,
|
||||
scheduleGeneratorService
|
||||
);
|
||||
|
||||
when(calendarRepository.findByIdWithDetails(CALENDAR_ID)).thenReturn(Optional.of(calendar));
|
||||
lenient().when(activityTypeRepository.findById(ACTIVITY_TYPE_ID)).thenReturn(Optional.of(activityType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateCourseAndDateBeforeMutation() {
|
||||
AcademicCalendarGridDayDto first = validRow(YEAR_START, 1, 1);
|
||||
AcademicCalendarGridDayDto duplicate = validRow(YEAR_START, 1, 1);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(first, duplicate)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Для курса и даты можно указать только одну активность");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullSecondRowBeforeMutation() {
|
||||
List<AcademicCalendarGridDayDto> rows = Arrays.asList(
|
||||
validRow(YEAR_START, 1, 1),
|
||||
null
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, rows))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Строка календарного графика не может быть пустой");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDayOfWeekThatDoesNotMatchDateBeforeMutation() {
|
||||
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
|
||||
AcademicCalendarGridDayDto invalidSecond = validRow(YEAR_START.plusDays(1), 1, 7);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, invalidSecond)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("День недели не соответствует дате");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWeekThatDoesNotMatchDateBeforeMutation() {
|
||||
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
|
||||
AcademicCalendarGridDayDto invalidSecond = validRow(YEAR_START.plusDays(7), 1, 1);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, invalidSecond)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Номер недели не соответствует дате учебного года");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDateOutsideAcademicYearBeforeMutation() {
|
||||
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
|
||||
AcademicCalendarGridDayDto outsideYear = validRow(LocalDate.of(2026, 7, 1), 44, 3);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, outsideYear)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Дата выходит за пределы учебного года");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownActivityInLastRowBeforeMutation() {
|
||||
AcademicCalendarGridDayDto validFirst = validRow(YEAR_START, 1, 1);
|
||||
AcademicCalendarGridDayDto unknownActivity = row(
|
||||
YEAR_START.plusDays(1), 1, 2, 999L
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(CALENDAR_ID, List.of(validFirst, unknownActivity)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Код активности не найден");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsMissingCalendarWithoutMutation() {
|
||||
when(calendarRepository.findByIdWithDetails(CALENDAR_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.replaceGrid(
|
||||
CALENDAR_ID,
|
||||
List.of(validRow(YEAR_START, 1, 1))
|
||||
))
|
||||
.isInstanceOf(NoSuchElementException.class)
|
||||
.hasMessage("Календарный график не найден");
|
||||
|
||||
verifyNoMutation();
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesOnlyAfterWholePayloadIsValid() {
|
||||
AcademicCalendarGridDayDto first = validRow(YEAR_START, 1, 1);
|
||||
AcademicCalendarGridDayDto second = validRow(YEAR_START.plusDays(1), 1, 2);
|
||||
|
||||
service.replaceGrid(CALENDAR_ID, List.of(first, second));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
InOrder mutationOrder = inOrder(calendarDayRepository, scheduleGeneratorService);
|
||||
mutationOrder.verify(calendarDayRepository).deleteByCalendarId(CALENDAR_ID);
|
||||
mutationOrder.verify(calendarDayRepository).flush();
|
||||
mutationOrder.verify(calendarDayRepository).saveAll(replacementCaptor.capture());
|
||||
mutationOrder.verify(calendarDayRepository).flush();
|
||||
mutationOrder.verify(scheduleGeneratorService).clearCache();
|
||||
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.extracting(AcademicCalendarDay::getDate)
|
||||
.containsExactly(YEAR_START, YEAR_START.plusDays(1));
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.allSatisfy(day -> {
|
||||
assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID);
|
||||
assertThat(day.getActivityType()).isSameAs(activityType);
|
||||
});
|
||||
}
|
||||
|
||||
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
|
||||
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
|
||||
}
|
||||
|
||||
private AcademicCalendarGridDayDto row(LocalDate date,
|
||||
int weekNumber,
|
||||
int dayOfWeek,
|
||||
long activityTypeId) {
|
||||
return new AcademicCalendarGridDayDto(
|
||||
null,
|
||||
CALENDAR_ID,
|
||||
1,
|
||||
date,
|
||||
weekNumber,
|
||||
dayOfWeek,
|
||||
activityTypeId,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private void verifyNoMutation() {
|
||||
verify(calendarDayRepository, never()).deleteByCalendarId(any());
|
||||
verify(calendarDayRepository, never()).flush();
|
||||
verify(calendarDayRepository, never()).save(any());
|
||||
verify(calendarDayRepository, never()).saveAll(any());
|
||||
verify(scheduleGeneratorService, never()).clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = PostgreSqlAdvisoryLockIntegrationTest.TestApplication.class,
|
||||
properties = {
|
||||
"spring.flyway.enabled=false",
|
||||
"spring.jpa.hibernate.ddl-auto=none"
|
||||
}
|
||||
)
|
||||
class PostgreSqlAdvisoryLockIntegrationTest {
|
||||
|
||||
private static final LocalDate TEST_DATE = LocalDate.of(2026, 9, 1);
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private PostgreSqlAdvisoryLock advisoryLock;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private ExecutorService executor;
|
||||
|
||||
@AfterEach
|
||||
void stopExecutor() throws InterruptedException {
|
||||
if (executor == null) {
|
||||
return;
|
||||
}
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesTwoSpringTransactionsForSameDate() throws Exception {
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch firstHasLock = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
CountDownLatch secondReachedLock = new CountDownLatch(1);
|
||||
AtomicInteger secondBackendPid = new AtomicInteger();
|
||||
|
||||
Future<?> first = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> {
|
||||
advisoryLock.lockScheduleOverrideDate(TEST_DATE);
|
||||
firstHasLock.countDown();
|
||||
await(releaseFirst, "Ожидание освобождения первой advisory-блокировки прервано");
|
||||
}));
|
||||
|
||||
assertThat(firstHasLock.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
Future<?> second = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> {
|
||||
secondBackendPid.set(jdbcTemplate.queryForObject("select pg_backend_pid()", Integer.class));
|
||||
secondReachedLock.countDown();
|
||||
advisoryLock.lockScheduleOverrideDate(TEST_DATE);
|
||||
}));
|
||||
|
||||
assertThat(secondReachedLock.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
awaitAdvisoryLockWait(secondBackendPid.get());
|
||||
assertThat(second).as("вторая транзакция должна ждать ту же дату").isNotDone();
|
||||
|
||||
releaseFirst.countDown();
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void awaitAdvisoryLockWait(int backendPid) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
do {
|
||||
Boolean waiting = jdbcTemplate.queryForObject("""
|
||||
select wait_event_type = 'Lock' and wait_event = 'advisory'
|
||||
from pg_stat_activity
|
||||
where pid = ?
|
||||
""", Boolean.class, backendPid);
|
||||
if (Boolean.TRUE.equals(waiting)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(25);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание PostgreSQL advisory-блокировки прервано", exception);
|
||||
}
|
||||
} while (System.nanoTime() < deadline);
|
||||
|
||||
throw new IllegalStateException("Вторая транзакция не перешла в ожидание advisory-блокировки");
|
||||
}
|
||||
|
||||
private void await(CountDownLatch latch, String message) {
|
||||
try {
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(message, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@EntityScan(basePackageClasses = ScheduleOverride.class)
|
||||
@Import(PostgreSqlAdvisoryLock.class)
|
||||
static class TestApplication {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
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.ScheduleParity;
|
||||
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.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ScheduleOverrideServiceTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final Long BASE_SLOT_ID = 101L;
|
||||
private static final Long OTHER_SLOT_ID = 202L;
|
||||
private static final Long SOURCE_TIME_SLOT_ID = 1L;
|
||||
private static final Long NEW_TIME_SLOT_ID = 2L;
|
||||
private static final Long SOURCE_TEACHER_ID = 11L;
|
||||
private static final Long NEW_TEACHER_ID = 12L;
|
||||
private static final Long SOURCE_CLASSROOM_ID = 21L;
|
||||
private static final Long NEW_CLASSROOM_ID = 22L;
|
||||
private static final Long GROUP_ID = 31L;
|
||||
private static final Long OTHER_GROUP_ID = 32L;
|
||||
private static final Long SUBGROUP_A_ID = 41L;
|
||||
private static final Long SUBGROUP_B_ID = 42L;
|
||||
|
||||
@Mock
|
||||
private ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
@Mock
|
||||
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
@Mock
|
||||
private TimeSlotRepository timeSlotRepository;
|
||||
@Mock
|
||||
private ClassroomRepository classroomRepository;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private SubgroupRepository subgroupRepository;
|
||||
@Mock
|
||||
private ScheduleQueryService scheduleQueryService;
|
||||
@Mock
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
@Mock
|
||||
private PostgreSqlAdvisoryLock advisoryLock;
|
||||
|
||||
private ScheduleOverrideService service;
|
||||
private ScheduleRuleSlot baseSlot;
|
||||
private TimeSlot sourceTimeSlot;
|
||||
private TimeSlot newTimeSlot;
|
||||
private Classroom sourceClassroom;
|
||||
private Classroom newClassroom;
|
||||
private User sourceTeacher;
|
||||
private User newTeacher;
|
||||
private RenderedLessonDto sourceLesson;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
baseSlot = ruleSlot(BASE_SLOT_ID);
|
||||
sourceTimeSlot = timeSlot(SOURCE_TIME_SLOT_ID, 1, "09:00", "10:30");
|
||||
newTimeSlot = timeSlot(NEW_TIME_SLOT_ID, 2, "10:40", "12:10");
|
||||
sourceClassroom = classroom(SOURCE_CLASSROOM_ID, "А-101");
|
||||
newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202");
|
||||
sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель");
|
||||
newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель");
|
||||
sourceLesson = lesson(
|
||||
BASE_SLOT_ID,
|
||||
SOURCE_TIME_SLOT_ID,
|
||||
"09:00",
|
||||
"10:30",
|
||||
SOURCE_TEACHER_ID,
|
||||
SOURCE_CLASSROOM_ID,
|
||||
List.of(GROUP_ID),
|
||||
List.of(),
|
||||
"Очно"
|
||||
);
|
||||
|
||||
service = new ScheduleOverrideService(
|
||||
scheduleOverrideRepository,
|
||||
scheduleRuleSlotRepository,
|
||||
timeSlotRepository,
|
||||
classroomRepository,
|
||||
userRepository,
|
||||
subgroupRepository,
|
||||
scheduleQueryService,
|
||||
scheduleGeneratorService,
|
||||
advisoryLock
|
||||
);
|
||||
|
||||
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
|
||||
when(timeSlotRepository.findById(SOURCE_TIME_SLOT_ID)).thenReturn(Optional.of(sourceTimeSlot));
|
||||
when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.of(newTimeSlot));
|
||||
when(classroomRepository.findById(SOURCE_CLASSROOM_ID)).thenReturn(Optional.of(sourceClassroom));
|
||||
when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.of(newClassroom));
|
||||
when(userRepository.findById(SOURCE_TEACHER_ID)).thenReturn(Optional.of(sourceTeacher));
|
||||
when(userRepository.findById(NEW_TEACHER_ID)).thenReturn(Optional.of(newTeacher));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.empty());
|
||||
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE)).thenReturn(List.of());
|
||||
when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(sourceLesson));
|
||||
when(subgroupRepository.findGroupReferencesByIdIn(anyList())).thenAnswer(invocation -> {
|
||||
List<Long> subgroupIds = invocation.getArgument(0);
|
||||
return subgroupIds.stream()
|
||||
.map(id -> subgroupReference(id, GROUP_ID))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullRequestWithRussianMessage() {
|
||||
assertThatThrownBy(() -> service.create(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Передайте данные изменения расписания");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingRequiredRequestReferences() {
|
||||
assertThatThrownBy(() -> service.create(request(null, DATE, "CANCEL", null, null, null, null)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Базовый слот расписания обязателен");
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(BASE_SLOT_ID, null, "CANCEL", null, null, null, null)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Дата пары обязательна");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingDatabaseReferencesWithRussianMessages() {
|
||||
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.create(cancelRequest()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Базовый слот расписания не найден");
|
||||
|
||||
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
|
||||
when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.create(moveRequest()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Временной слот не найден");
|
||||
|
||||
when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.of(newTimeSlot));
|
||||
when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.create(replaceRequest()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Аудитория не найдена");
|
||||
|
||||
when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.of(newClassroom));
|
||||
when(userRepository.findById(NEW_TEACHER_ID)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.create(replaceRequest()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Преподаватель не найден");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesCancelMoveReplaceActionMatrix() {
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "SKIP", null, null, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Недопустимое действие изменения расписания");
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "CANCEL", NEW_TIME_SLOT_ID, null, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Для отмены пары новые параметры должны быть пустыми");
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "MOVE", null, null, NEW_TEACHER_ID, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Для переноса укажите новое время или аудиторию");
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Для замены укажите нового преподавателя, аудиторию или формат пары");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedLessonFormat() {
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null, NEW_CLASSROOM_ID, NEW_TEACHER_ID, "Гибрид"
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Формат пары должен быть «Очно» или «Онлайн»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCancelMoveAndReplaceAndNormalizesText() {
|
||||
ScheduleOverrideDto cancelled = service.create(cancelRequest());
|
||||
assertThat(cancelled.action()).isEqualTo("CANCEL");
|
||||
|
||||
stubEffectiveDay(movedLesson());
|
||||
ScheduleOverrideDto moved = service.create(request(
|
||||
BASE_SLOT_ID, DATE, " move ", NEW_TIME_SLOT_ID, null, null, null
|
||||
));
|
||||
assertThat(moved.action()).isEqualTo("MOVE");
|
||||
assertThat(moved.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
|
||||
|
||||
stubEffectiveDay(replacedLesson());
|
||||
ScheduleOverrideDto replaced = service.create(request(
|
||||
BASE_SLOT_ID, DATE, " replace ", null, NEW_CLASSROOM_ID, NEW_TEACHER_ID, " Онлайн "
|
||||
));
|
||||
assertThat(replaced.action()).isEqualTo("REPLACE");
|
||||
assertThat(replaced.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
|
||||
assertThat(replaced.newClassroomId()).isEqualTo(NEW_CLASSROOM_ID);
|
||||
assertThat(replaced.newLessonFormat()).isEqualTo("Онлайн");
|
||||
|
||||
verify(scheduleGeneratorService, org.mockito.Mockito.times(3)).clearCache();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsReplaceWithOnlyOneReplacementField() {
|
||||
RenderedLessonDto teacherOnlyReplacement = lesson(
|
||||
BASE_SLOT_ID,
|
||||
SOURCE_TIME_SLOT_ID,
|
||||
"09:00",
|
||||
"10:30",
|
||||
NEW_TEACHER_ID,
|
||||
SOURCE_CLASSROOM_ID,
|
||||
List.of(GROUP_ID),
|
||||
List.of(),
|
||||
"Очно"
|
||||
);
|
||||
stubEffectiveDay(teacherOnlyReplacement);
|
||||
|
||||
ScheduleOverrideDto replaced = service.create(request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null, null, NEW_TEACHER_ID, null
|
||||
));
|
||||
|
||||
assertThat(replaced.action()).isEqualTo("REPLACE");
|
||||
assertThat(replaced.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
|
||||
assertThat(replaced.newClassroomId()).isNull();
|
||||
assertThat(replaced.newLessonFormat()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOverrideWhenActualSourceLessonDoesNotExist() {
|
||||
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(
|
||||
lesson(OTHER_SLOT_ID, SOURCE_TIME_SLOT_ID, "09:00", "10:30",
|
||||
SOURCE_TEACHER_ID, SOURCE_CLASSROOM_ID, List.of(GROUP_ID), List.of(), "Очно")
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> service.create(moveRequest()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("На выбранную дату базовая пара не формируется по правилам расписания");
|
||||
|
||||
verify(scheduleOverrideRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActualNoOpMove() {
|
||||
stubEffectiveDay(sourceLesson);
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "MOVE", null, SOURCE_CLASSROOM_ID, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDifferentTimeSlotIdWithSameActualInterval() {
|
||||
TimeSlot sameIntervalSlot = timeSlot(3L, 3, "09:00", "10:30");
|
||||
when(timeSlotRepository.findById(3L)).thenReturn(Optional.of(sameIntervalSlot));
|
||||
stubEffectiveDay(lesson(
|
||||
BASE_SLOT_ID,
|
||||
3L,
|
||||
"09:00",
|
||||
"10:30",
|
||||
SOURCE_TEACHER_ID,
|
||||
SOURCE_CLASSROOM_ID,
|
||||
List.of(GROUP_ID),
|
||||
List.of(),
|
||||
"Очно"
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActualNoOpReplace() {
|
||||
stubEffectiveDay(sourceLesson);
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null,
|
||||
SOURCE_CLASSROOM_ID, SOURCE_TEACHER_ID, "Очно"
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Замена должна фактически менять преподавателя, аудиторию или формат пары");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTeacherConflictFromEffectiveDay() {
|
||||
RenderedLessonDto candidate = movedLesson();
|
||||
RenderedLessonDto occupied = lesson(
|
||||
OTHER_SLOT_ID, 8L, "11:00", "12:30",
|
||||
SOURCE_TEACHER_ID, NEW_CLASSROOM_ID,
|
||||
List.of(OTHER_GROUP_ID), List.of(), "Очно"
|
||||
);
|
||||
stubEffectiveDay(candidate, occupied);
|
||||
|
||||
assertThatThrownBy(() -> service.create(moveRequest()))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsClassroomConflictFromEffectiveDay() {
|
||||
RenderedLessonDto candidate = movedLesson();
|
||||
RenderedLessonDto occupied = lesson(
|
||||
OTHER_SLOT_ID, 8L, "11:00", "12:30",
|
||||
NEW_TEACHER_ID, SOURCE_CLASSROOM_ID,
|
||||
List.of(OTHER_GROUP_ID), List.of(), "Очно"
|
||||
);
|
||||
stubEffectiveDay(candidate, occupied);
|
||||
|
||||
assertThatThrownBy(() -> service.create(moveRequest()))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: аудитория уже занята в указанное время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWholeGroupConflictWithSubgroupLesson() {
|
||||
RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of());
|
||||
RenderedLessonDto occupied = lesson(
|
||||
OTHER_SLOT_ID, 8L, "11:00", "12:30",
|
||||
NEW_TEACHER_ID, NEW_CLASSROOM_ID,
|
||||
List.of(GROUP_ID), List.of(SUBGROUP_A_ID), "Очно"
|
||||
);
|
||||
stubEffectiveDay(candidate, occupied);
|
||||
|
||||
assertThatThrownBy(() -> service.create(moveRequest()))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: группа или подгруппа уже занята в указанное время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsConflictOfSameSubgroup() {
|
||||
RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of(SUBGROUP_A_ID));
|
||||
RenderedLessonDto occupied = lesson(
|
||||
OTHER_SLOT_ID, 8L, "11:00", "12:30",
|
||||
NEW_TEACHER_ID, NEW_CLASSROOM_ID,
|
||||
List.of(GROUP_ID), List.of(SUBGROUP_A_ID), "Очно"
|
||||
);
|
||||
stubEffectiveDay(candidate, occupied);
|
||||
|
||||
assertThatThrownBy(() -> service.create(moveRequest()))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: группа или подгруппа уже занята в указанное время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsDifferentSubgroupsOfSameGroup() {
|
||||
RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of(SUBGROUP_A_ID));
|
||||
RenderedLessonDto occupied = lesson(
|
||||
OTHER_SLOT_ID, 8L, "11:00", "12:30",
|
||||
NEW_TEACHER_ID, NEW_CLASSROOM_ID,
|
||||
List.of(GROUP_ID), List.of(SUBGROUP_B_ID), "Очно"
|
||||
);
|
||||
stubEffectiveDay(candidate, occupied);
|
||||
|
||||
ScheduleOverrideDto saved = service.create(moveRequest());
|
||||
|
||||
assertThat(saved.action()).isEqualTo("MOVE");
|
||||
verify(scheduleOverrideRepository).saveAndFlush(any(ScheduleOverride.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsAdjacentIntervalsForSameResources() {
|
||||
RenderedLessonDto candidate = movedLesson();
|
||||
RenderedLessonDto adjacent = lesson(
|
||||
OTHER_SLOT_ID, 9L, "12:10", "13:40",
|
||||
SOURCE_TEACHER_ID, SOURCE_CLASSROOM_ID,
|
||||
List.of(GROUP_ID), List.of(), "Очно"
|
||||
);
|
||||
stubEffectiveDay(candidate, adjacent);
|
||||
|
||||
ScheduleOverrideDto saved = service.create(moveRequest());
|
||||
|
||||
assertThat(saved.action()).isEqualTo("MOVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresExistingCancellationBecauseConflictIsCalculatedFromEffectiveDay() {
|
||||
ScheduleOverride cancelledOtherLesson = override(700L, ruleSlot(OTHER_SLOT_ID), "CANCEL", null);
|
||||
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(cancelledOtherLesson));
|
||||
stubEffectiveDay(movedLesson());
|
||||
|
||||
service.create(moveRequest());
|
||||
|
||||
ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
|
||||
verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
|
||||
assertThat(captor.getValue())
|
||||
.extracting(ScheduleOverride::getAction)
|
||||
.containsExactlyInAnyOrder("CANCEL", "MOVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() {
|
||||
ScheduleOverride stored = override(500L, baseSlot, "CANCEL", null);
|
||||
when(scheduleOverrideRepository.findLessonDateById(500L)).thenReturn(Optional.of(DATE));
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(500L)).thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(stored));
|
||||
RenderedLessonDto occupied = lesson(
|
||||
OTHER_SLOT_ID, 8L, "11:00", "12:30",
|
||||
SOURCE_TEACHER_ID, NEW_CLASSROOM_ID,
|
||||
List.of(OTHER_GROUP_ID), List.of(), "Очно"
|
||||
);
|
||||
stubEffectiveDay(movedLesson(), occupied);
|
||||
|
||||
assertThatThrownBy(() -> service.update(500L, moveRequest()))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
|
||||
|
||||
ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
|
||||
verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
|
||||
assertThat(captor.getValue()).hasSize(1);
|
||||
assertThat(captor.getValue().iterator().next()).isNotSameAs(stored);
|
||||
assertThat(stored.getAction()).isEqualTo("CANCEL");
|
||||
assertThat(stored.getNewTimeSlot()).isNull();
|
||||
verify(scheduleOverrideRepository, never()).saveAndFlush(stored);
|
||||
verify(scheduleGeneratorService, never()).clearCache();
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotentUpdateExcludesStoredOverrideFromConflictCheck() {
|
||||
ScheduleOverride stored = override(501L, baseSlot, "MOVE", newTimeSlot);
|
||||
when(scheduleOverrideRepository.findLessonDateById(501L)).thenReturn(Optional.of(DATE));
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(501L)).thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(stored));
|
||||
stubEffectiveDay(movedLesson());
|
||||
|
||||
ScheduleOverrideDto result = service.update(501L, moveRequest());
|
||||
|
||||
assertThat(result.id()).isEqualTo(501L);
|
||||
assertThat(result.action()).isEqualTo("MOVE");
|
||||
assertThat(result.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
|
||||
ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
|
||||
verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
|
||||
assertThat(captor.getValue()).singleElement().isNotSameAs(stored);
|
||||
verify(scheduleOverrideRepository).saveAndFlush(stored);
|
||||
}
|
||||
|
||||
private void stubEffectiveDay(RenderedLessonDto... lessons) {
|
||||
when(scheduleQueryService.buildEffectiveDayForAllGroups(
|
||||
eq(List.of(sourceLesson)),
|
||||
eq(DATE),
|
||||
any(Collection.class)
|
||||
)).thenReturn(List.of(lessons));
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto cancelRequest() {
|
||||
return request(BASE_SLOT_ID, DATE, "CANCEL", null, null, null, null);
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto moveRequest() {
|
||||
return request(BASE_SLOT_ID, DATE, "MOVE", NEW_TIME_SLOT_ID, null, null, null);
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto replaceRequest() {
|
||||
return request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null,
|
||||
NEW_CLASSROOM_ID, NEW_TEACHER_ID, "Онлайн"
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto request(Long baseRuleSlotId,
|
||||
LocalDate date,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String format) {
|
||||
return new ScheduleOverrideDto(
|
||||
null,
|
||||
baseRuleSlotId,
|
||||
date,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
format,
|
||||
" Тестовый комментарий ",
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private RenderedLessonDto movedLesson() {
|
||||
return movedLesson(List.of(GROUP_ID), List.of());
|
||||
}
|
||||
|
||||
private RenderedLessonDto movedLesson(List<Long> groupIds, List<Long> subgroupIds) {
|
||||
return lesson(
|
||||
BASE_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
"10:40",
|
||||
"12:10",
|
||||
SOURCE_TEACHER_ID,
|
||||
SOURCE_CLASSROOM_ID,
|
||||
groupIds,
|
||||
subgroupIds,
|
||||
"Очно"
|
||||
);
|
||||
}
|
||||
|
||||
private RenderedLessonDto replacedLesson() {
|
||||
return lesson(
|
||||
BASE_SLOT_ID,
|
||||
SOURCE_TIME_SLOT_ID,
|
||||
"09:00",
|
||||
"10:30",
|
||||
NEW_TEACHER_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
List.of(GROUP_ID),
|
||||
List.of(),
|
||||
"Онлайн"
|
||||
);
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(Long ruleSlotId,
|
||||
Long timeSlotId,
|
||||
String start,
|
||||
String end,
|
||||
Long teacherId,
|
||||
Long classroomId,
|
||||
List<Long> groupIds,
|
||||
List<Long> subgroupIds,
|
||||
String format) {
|
||||
return new RenderedLessonDto(
|
||||
900L,
|
||||
ruleSlotId,
|
||||
DATE,
|
||||
2,
|
||||
"Вторник",
|
||||
1,
|
||||
ScheduleParity.BOTH,
|
||||
timeSlotId,
|
||||
1,
|
||||
LocalTime.parse(start),
|
||||
LocalTime.parse(end),
|
||||
501L,
|
||||
"Тестовая дисциплина",
|
||||
teacherId,
|
||||
"Тестовый преподаватель",
|
||||
classroomId,
|
||||
"Тестовая аудитория",
|
||||
601L,
|
||||
"Лекция",
|
||||
format,
|
||||
subgroupIds.size() == 1 ? subgroupIds.get(0) : null,
|
||||
subgroupIds.size() == 1 ? "Подгруппа" : null,
|
||||
subgroupIds,
|
||||
subgroupIds.stream().map(id -> "Подгруппа " + id).toList(),
|
||||
groupIds,
|
||||
groupIds.stream().map(id -> "Группа " + id).toList(),
|
||||
"Лекция",
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
ScheduleParity.BOTH
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlot ruleSlot(Long id) {
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
slot.setId(id);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private TimeSlot timeSlot(Long id, int order, String start, String end) {
|
||||
TimeSlot slot = new TimeSlot();
|
||||
slot.setId(id);
|
||||
slot.setOrderNumber(order);
|
||||
slot.setStartTime(LocalTime.parse(start));
|
||||
slot.setEndTime(LocalTime.parse(end));
|
||||
slot.setDurationMinutes((int) java.time.Duration.between(slot.getStartTime(), slot.getEndTime()).toMinutes());
|
||||
return slot;
|
||||
}
|
||||
|
||||
private Classroom classroom(Long id, String name) {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setId(id);
|
||||
classroom.setName(name);
|
||||
classroom.setIsAvailable(true);
|
||||
return classroom;
|
||||
}
|
||||
|
||||
private User teacher(Long id, String name) {
|
||||
User teacher = new User();
|
||||
teacher.setId(id);
|
||||
teacher.setRole(Role.TEACHER);
|
||||
teacher.setFullName(name);
|
||||
return teacher;
|
||||
}
|
||||
|
||||
private ScheduleOverride override(Long id,
|
||||
ScheduleRuleSlot slot,
|
||||
String action,
|
||||
TimeSlot replacementTimeSlot) {
|
||||
ScheduleOverride override = new ScheduleOverride();
|
||||
ReflectionTestUtils.setField(override, "id", id);
|
||||
override.setBaseRuleSlot(slot);
|
||||
override.setLessonDate(DATE);
|
||||
override.setAction(action);
|
||||
override.setNewTimeSlot(replacementTimeSlot);
|
||||
return override;
|
||||
}
|
||||
|
||||
private SubgroupRepository.SubgroupGroupReference subgroupReference(Long subgroupId, Long groupId) {
|
||||
SubgroupRepository.SubgroupGroupReference reference = org.mockito.Mockito.mock(
|
||||
SubgroupRepository.SubgroupGroupReference.class
|
||||
);
|
||||
when(reference.getSubgroupId()).thenReturn(subgroupId);
|
||||
when(reference.getGroupId()).thenReturn(groupId);
|
||||
return reference;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private ArgumentCaptor<Collection<ScheduleOverride>> collectionCaptor() {
|
||||
return ArgumentCaptor.forClass((Class) Collection.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ScheduleQueryServiceOverrideTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
|
||||
@Test
|
||||
void appliesAllReplacementFieldsToProvidedDaySnapshot() {
|
||||
ScheduleQueryService service = service();
|
||||
RenderedLessonDto base = lesson();
|
||||
ScheduleOverride override = override("MOVE");
|
||||
|
||||
TimeSlot newTimeSlot = new TimeSlot();
|
||||
newTimeSlot.setId(2L);
|
||||
newTimeSlot.setOrderNumber(2);
|
||||
newTimeSlot.setStartTime(LocalTime.of(10, 40));
|
||||
newTimeSlot.setEndTime(LocalTime.of(12, 10));
|
||||
override.setNewTimeSlot(newTimeSlot);
|
||||
|
||||
Classroom newClassroom = new Classroom();
|
||||
newClassroom.setId(22L);
|
||||
newClassroom.setName("Б-202");
|
||||
override.setNewClassroom(newClassroom);
|
||||
|
||||
User newTeacher = new User();
|
||||
newTeacher.setId(12L);
|
||||
newTeacher.setRole(Role.TEACHER);
|
||||
newTeacher.setFullName("Новый преподаватель");
|
||||
override.setNewTeacher(newTeacher);
|
||||
override.setNewLessonFormat("Онлайн");
|
||||
|
||||
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
|
||||
List.of(base),
|
||||
DATE,
|
||||
List.of(override)
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().satisfies(lesson -> {
|
||||
assertThat(lesson.timeSlotId()).isEqualTo(2L);
|
||||
assertThat(lesson.startTime()).isEqualTo(LocalTime.of(10, 40));
|
||||
assertThat(lesson.endTime()).isEqualTo(LocalTime.of(12, 10));
|
||||
assertThat(lesson.teacherId()).isEqualTo(12L);
|
||||
assertThat(lesson.teacherName()).isEqualTo("Новый преподаватель");
|
||||
assertThat(lesson.classroomId()).isEqualTo(22L);
|
||||
assertThat(lesson.classroomName()).isEqualTo("Б-202");
|
||||
assertThat(lesson.lessonFormat()).isEqualTo("Онлайн");
|
||||
assertThat(lesson.groupIds()).containsExactly(31L);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesCancelledOccurrenceFromProvidedDaySnapshot() {
|
||||
ScheduleQueryService service = service();
|
||||
|
||||
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
|
||||
List.of(lesson()),
|
||||
DATE,
|
||||
List.of(override("CANCEL"))
|
||||
);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresOverrideForAnotherDate() {
|
||||
ScheduleQueryService service = service();
|
||||
ScheduleOverride override = override("CANCEL");
|
||||
override.setLessonDate(DATE.plusDays(1));
|
||||
|
||||
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
|
||||
List.of(lesson()),
|
||||
DATE,
|
||||
List.of(override)
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().extracting(RenderedLessonDto::scheduleRuleSlotId).isEqualTo(101L);
|
||||
}
|
||||
|
||||
private ScheduleQueryService service() {
|
||||
return new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
mock(GroupRepository.class),
|
||||
mock(ScheduleOverrideRepository.class),
|
||||
mock(StudentGroupLifecycleService.class)
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleOverride override(String action) {
|
||||
ScheduleRuleSlot baseSlot = new ScheduleRuleSlot();
|
||||
baseSlot.setId(101L);
|
||||
ScheduleOverride override = new ScheduleOverride();
|
||||
override.setBaseRuleSlot(baseSlot);
|
||||
override.setLessonDate(DATE);
|
||||
override.setAction(action);
|
||||
return override;
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson() {
|
||||
return new RenderedLessonDto(
|
||||
900L,
|
||||
101L,
|
||||
DATE,
|
||||
2,
|
||||
"Вторник",
|
||||
1,
|
||||
ScheduleParity.BOTH,
|
||||
1L,
|
||||
1,
|
||||
LocalTime.of(9, 0),
|
||||
LocalTime.of(10, 30),
|
||||
501L,
|
||||
"Тестовая дисциплина",
|
||||
11L,
|
||||
"Исходный преподаватель",
|
||||
21L,
|
||||
"А-101",
|
||||
601L,
|
||||
"Лекция",
|
||||
"Очно",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(31L),
|
||||
List.of("Группа 31"),
|
||||
"Т",
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
ScheduleParity.BOTH
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("Поиск расписания преподавателя с точечными изменениями")
|
||||
class ScheduleQueryServiceTeacherOverrideTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final long SOURCE_TEACHER_ID = 11L;
|
||||
private static final long REPLACEMENT_TEACHER_ID = 12L;
|
||||
private static final long GROUP_ID = 31L;
|
||||
|
||||
private final ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||
private final GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
private final ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||
private final StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
|
||||
private final ScheduleQueryService service = new ScheduleQueryService(
|
||||
generatorService,
|
||||
groupRepository,
|
||||
overrideRepository,
|
||||
groupLifecycleService
|
||||
);
|
||||
|
||||
@Test
|
||||
@DisplayName("Замена A на B видна преподавателю B и скрыта у преподавателя A")
|
||||
void replacementIsVisibleOnlyToEffectiveTeacher() {
|
||||
RenderedLessonDto sourceLesson = lesson(101L, SOURCE_TEACHER_ID, 1);
|
||||
ScheduleOverride replacement = replacement(101L, REPLACEMENT_TEACHER_ID);
|
||||
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
|
||||
.thenReturn(List.of());
|
||||
when(generatorService.buildScheduleForTeacher(SOURCE_TEACHER_ID, DATE, DATE))
|
||||
.thenReturn(List.of(sourceLesson));
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(replacement));
|
||||
stubBaseDay(List.of(sourceLesson));
|
||||
|
||||
List<RenderedLessonDto> replacementSchedule = searchTeacher(REPLACEMENT_TEACHER_ID);
|
||||
List<RenderedLessonDto> sourceSchedule = searchTeacher(SOURCE_TEACHER_ID);
|
||||
|
||||
assertThat(replacementSchedule).singleElement().satisfies(lesson -> {
|
||||
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L);
|
||||
assertThat(lesson.teacherId()).isEqualTo(REPLACEMENT_TEACHER_ID);
|
||||
});
|
||||
assertThat(sourceSchedule).isEmpty();
|
||||
verify(overrideRepository, times(2)).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Точечное изменение без базового occurrence не создаёт фантомную пару")
|
||||
void missingBaseOccurrenceDoesNotCreatePhantomLesson() {
|
||||
ScheduleOverride replacement = replacement(101L, REPLACEMENT_TEACHER_ID);
|
||||
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
|
||||
.thenReturn(List.of());
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(replacement));
|
||||
stubBaseDay(List.of(lesson(102L, SOURCE_TEACHER_ID, 2)));
|
||||
|
||||
List<RenderedLessonDto> result = searchTeacher(REPLACEMENT_TEACHER_ID);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Несколько замен одной даты генерируют базовый день только один раз")
|
||||
void multipleReplacementsOnSameDateGenerateBaseDayOnce() {
|
||||
RenderedLessonDto first = lesson(101L, SOURCE_TEACHER_ID, 1);
|
||||
RenderedLessonDto second = lesson(102L, SOURCE_TEACHER_ID, 2);
|
||||
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
|
||||
.thenReturn(List.of());
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(
|
||||
replacement(101L, REPLACEMENT_TEACHER_ID),
|
||||
replacement(102L, REPLACEMENT_TEACHER_ID)
|
||||
));
|
||||
stubBaseDay(List.of(first, second));
|
||||
|
||||
List<RenderedLessonDto> result = searchTeacher(REPLACEMENT_TEACHER_ID);
|
||||
|
||||
assertThat(result)
|
||||
.extracting(RenderedLessonDto::scheduleRuleSlotId)
|
||||
.containsExactly(101L, 102L);
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Совпавшая базовая и достроенная пара остаётся в результате один раз")
|
||||
void duplicateSourceOccurrenceIsRemovedAfterApplyingSnapshot() {
|
||||
RenderedLessonDto existingLesson = lesson(101L, REPLACEMENT_TEACHER_ID, 1);
|
||||
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
|
||||
.thenReturn(List.of(existingLesson));
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(replacement(101L, REPLACEMENT_TEACHER_ID)));
|
||||
stubBaseDay(List.of(existingLesson));
|
||||
|
||||
List<RenderedLessonDto> result = searchTeacher(REPLACEMENT_TEACHER_ID);
|
||||
|
||||
assertThat(result).singleElement().satisfies(lesson ->
|
||||
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Отмена и перенос применяются из единого снимка изменений")
|
||||
void cancelAndMoveContinueToApplyFromSingleSnapshot() {
|
||||
RenderedLessonDto cancelledLesson = lesson(101L, SOURCE_TEACHER_ID, 1);
|
||||
RenderedLessonDto movedLesson = lesson(102L, SOURCE_TEACHER_ID, 2);
|
||||
ScheduleOverride cancellation = override(101L, "CANCEL");
|
||||
ScheduleOverride move = override(102L, "MOVE");
|
||||
TimeSlot newTimeSlot = new TimeSlot();
|
||||
newTimeSlot.setId(3L);
|
||||
newTimeSlot.setOrderNumber(3);
|
||||
newTimeSlot.setStartTime(LocalTime.of(13, 0));
|
||||
newTimeSlot.setEndTime(LocalTime.of(14, 30));
|
||||
move.setNewTimeSlot(newTimeSlot);
|
||||
when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group()));
|
||||
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||
when(generatorService.buildScheduleForGroup(GROUP_ID, DATE, DATE))
|
||||
.thenReturn(List.of(cancelledLesson, movedLesson));
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
|
||||
.thenReturn(List.of(cancellation, move));
|
||||
|
||||
List<RenderedLessonDto> result = service.search(
|
||||
GROUP_ID, null, null, null, null, null, null, null, DATE, DATE
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().satisfies(lesson -> {
|
||||
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(102L);
|
||||
assertThat(lesson.timeSlotId()).isEqualTo(3L);
|
||||
assertThat(lesson.startTime()).isEqualTo(LocalTime.of(13, 0));
|
||||
assertThat(lesson.endTime()).isEqualTo(LocalTime.of(14, 30));
|
||||
});
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> searchTeacher(long teacherId) {
|
||||
return service.search(
|
||||
null, teacherId, null, null, null, null, null, null, DATE, DATE
|
||||
);
|
||||
}
|
||||
|
||||
private void stubBaseDay(List<RenderedLessonDto> lessons) {
|
||||
when(groupRepository.findAll()).thenReturn(List.of(group()));
|
||||
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||
when(generatorService.buildScheduleForGroup(GROUP_ID, DATE, DATE)).thenReturn(lessons);
|
||||
}
|
||||
|
||||
private StudentGroup group() {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(GROUP_ID);
|
||||
group.setName("Группа 31");
|
||||
return group;
|
||||
}
|
||||
|
||||
private ScheduleOverride replacement(long baseRuleSlotId, long teacherId) {
|
||||
ScheduleOverride override = override(baseRuleSlotId, "REPLACE");
|
||||
User newTeacher = new User();
|
||||
newTeacher.setId(teacherId);
|
||||
newTeacher.setRole(Role.TEACHER);
|
||||
newTeacher.setFullName("Замещающий преподаватель");
|
||||
override.setNewTeacher(newTeacher);
|
||||
return override;
|
||||
}
|
||||
|
||||
private ScheduleOverride override(long baseRuleSlotId, String action) {
|
||||
ScheduleRuleSlot baseRuleSlot = new ScheduleRuleSlot();
|
||||
baseRuleSlot.setId(baseRuleSlotId);
|
||||
ScheduleOverride override = new ScheduleOverride();
|
||||
override.setBaseRuleSlot(baseRuleSlot);
|
||||
override.setLessonDate(DATE);
|
||||
override.setAction(action);
|
||||
return override;
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(long baseRuleSlotId, long teacherId, int timeSlotOrder) {
|
||||
return new RenderedLessonDto(
|
||||
900L + baseRuleSlotId,
|
||||
baseRuleSlotId,
|
||||
DATE,
|
||||
2,
|
||||
"Вторник",
|
||||
1,
|
||||
ScheduleParity.BOTH,
|
||||
(long) timeSlotOrder,
|
||||
timeSlotOrder,
|
||||
LocalTime.of(7 + timeSlotOrder * 2, 0),
|
||||
LocalTime.of(8 + timeSlotOrder * 2, 30),
|
||||
500L + baseRuleSlotId,
|
||||
"Тестовая дисциплина " + baseRuleSlotId,
|
||||
teacherId,
|
||||
teacherId == SOURCE_TEACHER_ID ? "Исходный преподаватель" : "Замещающий преподаватель",
|
||||
21L,
|
||||
"А-101",
|
||||
601L,
|
||||
"Лекция",
|
||||
"Очно",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(GROUP_ID),
|
||||
List.of("Группа 31"),
|
||||
"Т",
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
ScheduleParity.BOTH
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||
properties = "spring.jpa.open-in-view=false"
|
||||
)
|
||||
class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void databaseProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private ScheduleRuleService service;
|
||||
|
||||
@Autowired
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
private ExecutorService executor;
|
||||
|
||||
@AfterEach
|
||||
void stopExecutor() throws InterruptedException {
|
||||
if (executor == null) {
|
||||
return;
|
||||
}
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
|
||||
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
||||
|
||||
SeedIds ids = createIsolatedSemesterAndLoadSeedIds();
|
||||
ScheduleRuleDto request = request(ids);
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<CreateOutcome>> futures = new ArrayList<>();
|
||||
|
||||
try (Connection lockConnection = dataSource.getConnection()) {
|
||||
lockConnection.setAutoCommit(false);
|
||||
int holderPid = lockSemesterRow(lockConnection, ids.semesterId());
|
||||
futures.add(executor.submit(() -> createAfterSignal(request, ready, start)));
|
||||
futures.add(executor.submit(() -> createAfterSignal(request, ready, start)));
|
||||
awaitReady(ready);
|
||||
start.countDown();
|
||||
awaitTwoBlockedCreates(futures, holderPid);
|
||||
lockConnection.commit();
|
||||
}
|
||||
|
||||
List<CreateOutcome> outcomes = List.of(
|
||||
futures.get(0).get(10, TimeUnit.SECONDS),
|
||||
futures.get(1).get(10, TimeUnit.SECONDS)
|
||||
);
|
||||
|
||||
assertThat(outcomes).filteredOn(outcome -> outcome.result() != null).hasSize(1);
|
||||
assertThat(outcomes).filteredOn(outcome -> outcome.failure() != null).singleElement()
|
||||
.satisfies(outcome -> {
|
||||
assertThat(outcome.failure()).isInstanceOf(ScheduleRuleConflictException.class);
|
||||
assertThat(outcome.failure())
|
||||
.hasMessage("Невозможно сохранить правило: слот занят");
|
||||
});
|
||||
assertThat(queryCount("SELECT count(*) FROM schedule_rules WHERE semester_id = ?", ids.semesterId()))
|
||||
.isEqualTo(1L);
|
||||
assertThat(queryCount("""
|
||||
SELECT count(*)
|
||||
FROM schedule_rule_slots slot
|
||||
JOIN schedule_rules rule ON rule.id = slot.schedule_rule_id
|
||||
WHERE rule.semester_id = ?
|
||||
""", ids.semesterId())).isEqualTo(1L);
|
||||
assertThat(queryCount("""
|
||||
SELECT count(*)
|
||||
FROM schedule_rule_groups rule_group
|
||||
JOIN schedule_rules rule ON rule.id = rule_group.schedule_rule_id
|
||||
WHERE rule.semester_id = ?
|
||||
""", ids.semesterId())).isEqualTo(1L);
|
||||
verify(scheduleGeneratorService, times(1)).clearCache();
|
||||
}
|
||||
|
||||
private CreateOutcome createAfterSignal(ScheduleRuleDto request,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
ready.countDown();
|
||||
try {
|
||||
if (!start.await(5, TimeUnit.SECONDS)) {
|
||||
return new CreateOutcome(null, new IllegalStateException(
|
||||
"Конкурентные операции не получили общий сигнал запуска"
|
||||
));
|
||||
}
|
||||
return new CreateOutcome(service.create(request), null);
|
||||
} catch (Throwable failure) {
|
||||
return new CreateOutcome(null, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitReady(CountDownLatch ready) {
|
||||
try {
|
||||
if (!ready.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Конкурентные операции не успели подготовиться");
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание конкурентных операций прервано", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private int lockSemesterRow(Connection connection, long semesterId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT id, pg_backend_pid() FROM semesters WHERE id = ? FOR UPDATE"
|
||||
)) {
|
||||
statement.setLong(1, semesterId);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
assertThat(result.next()).as("Строка тестового семестра должна существовать").isTrue();
|
||||
assertThat(result.getLong(1)).isEqualTo(semesterId);
|
||||
int holderPid = result.getInt(2);
|
||||
assertThat(holderPid).isPositive();
|
||||
return holderPid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitTwoBlockedCreates(List<Future<CreateOutcome>> futures, int holderPid) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
do {
|
||||
failOnEarlyCompletion(futures);
|
||||
Long blocked = jdbcTemplate.queryForObject("""
|
||||
SELECT count(DISTINCT activity.pid)
|
||||
FROM pg_stat_activity activity
|
||||
WHERE activity.datname = current_database()
|
||||
AND activity.pid <> ?
|
||||
AND activity.state = 'active'
|
||||
AND activity.wait_event_type = 'Lock'
|
||||
AND lower(activity.query) LIKE '%semester%'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_locks relation_lock
|
||||
WHERE relation_lock.pid = activity.pid
|
||||
AND relation_lock.relation = 'semesters'::regclass
|
||||
)
|
||||
""", Long.class, holderPid);
|
||||
if (blocked != null && blocked >= 2L) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(25);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание блокировки операций прервано", exception);
|
||||
}
|
||||
} while (System.nanoTime() < deadline);
|
||||
|
||||
failOnEarlyCompletion(futures);
|
||||
throw new IllegalStateException("Обе операции создания не заблокировались на строке одного семестра. "
|
||||
+ "Снимок PostgreSQL: " + lockWaitSnapshot(holderPid));
|
||||
}
|
||||
|
||||
private void failOnEarlyCompletion(List<Future<CreateOutcome>> futures) {
|
||||
for (int index = 0; index < futures.size(); index++) {
|
||||
Future<CreateOutcome> future = futures.get(index);
|
||||
if (!future.isDone()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
CreateOutcome outcome = future.get(0, TimeUnit.MILLISECONDS);
|
||||
String detail = outcome.failure() == null
|
||||
? "операция неожиданно завершилась успешно"
|
||||
: outcome.failure().getClass().getSimpleName() + ": " + outcome.failure().getMessage();
|
||||
throw new IllegalStateException(
|
||||
"Конкурентная операция " + (index + 1) + " завершилась до снятия внешнего барьера: " + detail
|
||||
);
|
||||
} catch (IllegalStateException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(
|
||||
"Не удалось прочитать ранний результат конкурентной операции " + (index + 1),
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String lockWaitSnapshot(int holderPid) {
|
||||
List<String> rows = jdbcTemplate.query("""
|
||||
SELECT concat(
|
||||
'pid=', activity.pid,
|
||||
', state=', activity.state,
|
||||
', wait=', coalesce(activity.wait_event_type, '-'), '/', coalesce(activity.wait_event, '-'),
|
||||
', blockers=', pg_blocking_pids(activity.pid)::text,
|
||||
', query=', left(regexp_replace(activity.query, '\\s+', ' ', 'g'), 180)
|
||||
)
|
||||
FROM pg_stat_activity activity
|
||||
WHERE activity.datname = current_database()
|
||||
AND activity.pid <> ?
|
||||
ORDER BY activity.pid
|
||||
""", (result, rowNumber) -> result.getString(1), holderPid);
|
||||
return rows.isEmpty() ? "активные подключения не найдены" : String.join(" | ", rows);
|
||||
}
|
||||
|
||||
private SeedIds createIsolatedSemesterAndLoadSeedIds() {
|
||||
Long academicYearId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO academic_years (title, start_date, end_date)
|
||||
VALUES ('2098-2099', DATE '2098-09-01', DATE '2099-06-30')
|
||||
RETURNING id
|
||||
""", Long.class);
|
||||
Long semesterId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
VALUES (?, 'autumn', DATE '2098-09-01', DATE '2099-01-31')
|
||||
RETURNING id
|
||||
""", Long.class, academicYearId);
|
||||
|
||||
return new SeedIds(
|
||||
semesterId,
|
||||
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
|
||||
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
|
||||
queryId("""
|
||||
SELECT slot.id
|
||||
FROM time_slots slot
|
||||
JOIN time_slot_scopes scope ON scope.id = slot.time_slot_scope_id
|
||||
WHERE scope.code = 'default' AND slot.order_number = 1
|
||||
"""),
|
||||
queryId("SELECT id FROM users WHERE username = 'Тестовый преподаватель'"),
|
||||
queryId("SELECT id FROM classrooms WHERE name = '101 Ленинская'"),
|
||||
queryId("SELECT id FROM lesson_types WHERE name = 'Лекция'")
|
||||
);
|
||||
}
|
||||
|
||||
private long queryId(String sql) {
|
||||
Long value = jdbcTemplate.queryForObject(sql, Long.class);
|
||||
assertThat(value).as("Тестовый seed-идентификатор должен существовать: %s", sql).isNotNull();
|
||||
return value;
|
||||
}
|
||||
|
||||
private long queryCount(String sql, Object... arguments) {
|
||||
Long value = jdbcTemplate.queryForObject(sql, Long.class, arguments);
|
||||
assertThat(value).isNotNull();
|
||||
return value;
|
||||
}
|
||||
|
||||
private ScheduleRuleDto request(SeedIds ids) {
|
||||
ScheduleRuleSlotDto slot = new ScheduleRuleSlotDto(
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
ScheduleParity.BOTH,
|
||||
ids.timeSlotId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
ids.teacherId(),
|
||||
null,
|
||||
ids.classroomId(),
|
||||
null,
|
||||
ids.lessonTypeId(),
|
||||
null,
|
||||
"Очно"
|
||||
);
|
||||
return new ScheduleRuleDto(
|
||||
null,
|
||||
ids.subjectId(),
|
||||
null,
|
||||
ids.semesterId(),
|
||||
null,
|
||||
null,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
List.of(ids.groupId()),
|
||||
List.of(),
|
||||
List.of(slot)
|
||||
);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@EntityScan(basePackageClasses = ScheduleRule.class)
|
||||
@EnableJpaRepositories(basePackageClasses = ScheduleRuleRepository.class)
|
||||
@Import({ScheduleRuleService.class, TestDependencies.class})
|
||||
static class TestApplication {
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class TestDependencies {
|
||||
|
||||
@Bean
|
||||
ScheduleGeneratorService scheduleGeneratorService() {
|
||||
return mock(ScheduleGeneratorService.class);
|
||||
}
|
||||
}
|
||||
|
||||
private record SeedIds(
|
||||
long semesterId,
|
||||
long subjectId,
|
||||
long groupId,
|
||||
long timeSlotId,
|
||||
long teacherId,
|
||||
long classroomId,
|
||||
long lessonTypeId
|
||||
) {
|
||||
}
|
||||
|
||||
private record CreateOutcome(ScheduleRuleDto result, Throwable failure) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
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.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
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.TimeSlotScope;
|
||||
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.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ScheduleRuleServiceTest {
|
||||
|
||||
private static final long RULE_ID = 100L;
|
||||
private static final long SUBJECT_ID = 10L;
|
||||
private static final long SEMESTER_ID = 20L;
|
||||
private static final long GROUP_ID = 30L;
|
||||
private static final long TIME_SLOT_ID = 40L;
|
||||
private static final long TEACHER_ID = 50L;
|
||||
private static final long OTHER_TEACHER_ID = 51L;
|
||||
private static final long CLASSROOM_ID = 60L;
|
||||
private static final long OTHER_CLASSROOM_ID = 61L;
|
||||
private static final long LECTURE_TYPE_ID = 70L;
|
||||
private static final long LABORATORY_TYPE_ID = 71L;
|
||||
private static final long SUBGROUP_A_ID = 80L;
|
||||
private static final long SUBGROUP_B_ID = 81L;
|
||||
|
||||
@Mock
|
||||
private ScheduleRuleRepository scheduleRuleRepository;
|
||||
@Mock
|
||||
private SubjectRepository subjectRepository;
|
||||
@Mock
|
||||
private SemesterRepository semesterRepository;
|
||||
@Mock
|
||||
private GroupRepository groupRepository;
|
||||
@Mock
|
||||
private TimeSlotRepository timeSlotRepository;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private ClassroomRepository classroomRepository;
|
||||
@Mock
|
||||
private LessonTypesRepository lessonTypesRepository;
|
||||
@Mock
|
||||
private SubgroupRepository subgroupRepository;
|
||||
@Mock
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
private ScheduleRuleService service;
|
||||
private Subject subject;
|
||||
private Semester semester;
|
||||
private StudentGroup group;
|
||||
private TimeSlot timeSlot;
|
||||
private User teacher;
|
||||
private User otherTeacher;
|
||||
private Classroom classroom;
|
||||
private Classroom otherClassroom;
|
||||
private LessonType lectureType;
|
||||
private LessonType laboratoryType;
|
||||
private Subgroup subgroupA;
|
||||
private Subgroup subgroupB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
subject = subject();
|
||||
semester = semester();
|
||||
group = group();
|
||||
timeSlot = timeSlot();
|
||||
teacher = user(TEACHER_ID, Role.TEACHER, "Первый преподаватель");
|
||||
otherTeacher = user(OTHER_TEACHER_ID, Role.TEACHER, "Второй преподаватель");
|
||||
classroom = classroom(CLASSROOM_ID, "А-101");
|
||||
otherClassroom = classroom(OTHER_CLASSROOM_ID, "Б-202");
|
||||
lectureType = lessonType(LECTURE_TYPE_ID, "Лекция");
|
||||
laboratoryType = lessonType(LABORATORY_TYPE_ID, "Лабораторная работа");
|
||||
subgroupA = subgroup(SUBGROUP_A_ID, "Подгруппа 1");
|
||||
subgroupB = subgroup(SUBGROUP_B_ID, "Подгруппа 2");
|
||||
|
||||
service = new ScheduleRuleService(
|
||||
scheduleRuleRepository,
|
||||
subjectRepository,
|
||||
semesterRepository,
|
||||
groupRepository,
|
||||
timeSlotRepository,
|
||||
userRepository,
|
||||
classroomRepository,
|
||||
lessonTypesRepository,
|
||||
subgroupRepository,
|
||||
scheduleGeneratorService
|
||||
);
|
||||
|
||||
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester));
|
||||
when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID)))
|
||||
.thenReturn(List.of(semester));
|
||||
when(subjectRepository.findById(SUBJECT_ID)).thenReturn(Optional.of(subject));
|
||||
when(groupRepository.findAllById(List.of(GROUP_ID))).thenReturn(List.of(group));
|
||||
when(timeSlotRepository.findById(TIME_SLOT_ID)).thenReturn(Optional.of(timeSlot));
|
||||
when(userRepository.findById(TEACHER_ID)).thenReturn(Optional.of(teacher));
|
||||
when(userRepository.findById(OTHER_TEACHER_ID)).thenReturn(Optional.of(otherTeacher));
|
||||
when(classroomRepository.findById(CLASSROOM_ID)).thenReturn(Optional.of(classroom));
|
||||
when(classroomRepository.findById(OTHER_CLASSROOM_ID)).thenReturn(Optional.of(otherClassroom));
|
||||
when(lessonTypesRepository.findById(LECTURE_TYPE_ID)).thenReturn(Optional.of(lectureType));
|
||||
when(lessonTypesRepository.findById(LABORATORY_TYPE_ID)).thenReturn(Optional.of(laboratoryType));
|
||||
when(subgroupRepository.findById(SUBGROUP_A_ID)).thenReturn(Optional.of(subgroupA));
|
||||
when(subgroupRepository.findById(SUBGROUP_B_ID)).thenReturn(Optional.of(subgroupB));
|
||||
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, null))
|
||||
.thenReturn(List.of());
|
||||
when(scheduleRuleRepository.saveAndFlush(any(ScheduleRule.class)))
|
||||
.thenAnswer(invocation -> assignGeneratedIds(invocation.getArgument(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOddHoursOfEveryLessonCategoryWithRussianMessages() {
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(1, 0, 0, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Количество часов для лекций должно быть чётным");
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(0, 3, 0, laboratorySlot(SUBGROUP_A_ID))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Количество часов для лабораторных занятий должно быть чётным");
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(0, 0, 5, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Количество часов для практических занятий должно быть чётным");
|
||||
|
||||
verify(semesterRepository, never()).findByIdForUpdate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesMissingNegativeZeroAndUnusedTypeHours() {
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(null, 0, 0, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Укажите количество часов для лекций");
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(-2, 0, 0, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Количество часов для лекций не может быть отрицательным");
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(0, 0, 0, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Укажите часы хотя бы для одного типа занятий");
|
||||
|
||||
ScheduleRuleDto saved = service.create(ruleRequest(2, 0, 0, lectureSlot()));
|
||||
assertThat(saved.lectureAcademicHours()).isEqualTo(2);
|
||||
assertThat(saved.laboratoryAcademicHours()).isZero();
|
||||
assertThat(saved.practiceAcademicHours()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActiveUserWithoutTeacherRole() {
|
||||
User administrator = user(TEACHER_ID, Role.ADMIN, "Администратор");
|
||||
when(userRepository.findById(TEACHER_ID)).thenReturn(Optional.of(administrator));
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("В слот расписания можно назначить только пользователя с ролью преподавателя");
|
||||
|
||||
verify(scheduleRuleRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsMissingReferencedTeacherAsInvalidPayload() {
|
||||
when(userRepository.findById(TEACHER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, lectureSlot())))
|
||||
.isExactlyInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Преподаватель не найден");
|
||||
|
||||
verify(scheduleRuleRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedFormatAndAcceptsTrimmedSupportedFormat() {
|
||||
ScheduleRuleSlotDto invalid = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Гибрид"
|
||||
);
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, invalid)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Формат занятия должен быть «Очно» или «Онлайн»");
|
||||
|
||||
ScheduleRuleSlotDto valid = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), " Онлайн "
|
||||
);
|
||||
ScheduleRuleDto saved = service.create(ruleRequest(2, 0, 0, valid));
|
||||
|
||||
assertThat(saved.slots()).singleElement().extracting(ScheduleRuleSlotDto::lessonFormat)
|
||||
.isEqualTo("Онлайн");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExactDuplicateInsideNewRule() {
|
||||
ScheduleRuleSlotDto duplicate = lectureSlot();
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, duplicate, duplicate)))
|
||||
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
|
||||
assertThat(exception.getMessage())
|
||||
.isEqualTo("Невозможно сохранить правило: в списке есть дублирующиеся слоты");
|
||||
assertThat(exception.getConflictRule()).isNull();
|
||||
assertThat(exception.getConflictFields()).containsExactly("slot");
|
||||
assertThat(exception.getConflictReasons()).containsExactly("Дублирующийся слот");
|
||||
});
|
||||
|
||||
verify(scheduleRuleRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInternalConflictAndReportsAllSharedResourcesInStableOrder() {
|
||||
ScheduleRuleSlotDto first = lectureSlot();
|
||||
ScheduleRuleSlotDto second = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Онлайн"
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(4, 0, 0, first, second)))
|
||||
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
|
||||
assertThat(exception.getMessage())
|
||||
.isEqualTo("Невозможно сохранить правило: слоты внутри правила конфликтуют");
|
||||
assertThat(exception.getConflictRule()).isNull();
|
||||
assertThat(exception.getConflictFields()).containsExactly("teacher", "classroom", "group");
|
||||
assertThat(exception.getConflictReasons())
|
||||
.containsExactly("Преподаватель", "Аудитория", "Группа");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsOddAndEvenSlotsBecauseTheirActiveWeeksDoNotOverlap() {
|
||||
ScheduleRuleSlotDto odd = slot(
|
||||
ScheduleParity.ODD, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Очно"
|
||||
);
|
||||
ScheduleRuleSlotDto even = slot(
|
||||
ScheduleParity.EVEN, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Очно"
|
||||
);
|
||||
|
||||
ScheduleRuleDto saved = service.create(ruleRequest(4, 0, 0, odd, even));
|
||||
|
||||
assertThat(saved.slots()).extracting(ScheduleRuleSlotDto::parity)
|
||||
.containsExactlyInAnyOrder(ScheduleParity.ODD, ScheduleParity.EVEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBothParityWhenItOverlapsOddOrEvenSlot() {
|
||||
ScheduleRuleSlotDto bothFirst = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Очно"
|
||||
);
|
||||
ScheduleRuleSlotDto oddSecond = slot(
|
||||
ScheduleParity.ODD, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Онлайн"
|
||||
);
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(6, 0, 0, bothFirst, oddSecond)))
|
||||
.isInstanceOf(ScheduleRuleConflictException.class)
|
||||
.hasMessage("Невозможно сохранить правило: слоты внутри правила конфликтуют");
|
||||
|
||||
ScheduleRuleSlotDto evenFirst = slot(
|
||||
ScheduleParity.EVEN, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Очно"
|
||||
);
|
||||
ScheduleRuleSlotDto bothSecond = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Онлайн"
|
||||
);
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(6, 0, 0, evenFirst, bothSecond)))
|
||||
.isInstanceOf(ScheduleRuleConflictException.class)
|
||||
.hasMessage("Невозможно сохранить правило: слоты внутри правила конфликтуют");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWholeGroupAndSubgroupAtSameTime() {
|
||||
ScheduleRuleSlotDto wholeGroup = laboratorySlot();
|
||||
ScheduleRuleSlotDto subgroup = slot(
|
||||
ScheduleParity.BOTH, OTHER_TEACHER_ID, OTHER_CLASSROOM_ID,
|
||||
LABORATORY_TYPE_ID, List.of(SUBGROUP_A_ID), "Очно"
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(0, 4, 0, wholeGroup, subgroup)))
|
||||
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
|
||||
assertThat(exception.getConflictFields()).containsExactly("group");
|
||||
assertThat(exception.getConflictReasons()).containsExactly("Группа");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSameSubgroupAtSameTime() {
|
||||
ScheduleRuleSlotDto first = laboratorySlot(SUBGROUP_A_ID);
|
||||
ScheduleRuleSlotDto second = slot(
|
||||
ScheduleParity.BOTH, OTHER_TEACHER_ID, OTHER_CLASSROOM_ID,
|
||||
LABORATORY_TYPE_ID, List.of(SUBGROUP_A_ID), "Онлайн"
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(0, 4, 0, first, second)))
|
||||
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception ->
|
||||
assertThat(exception.getConflictFields()).containsExactly("group"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsDifferentSubgroupsOfSameGroupAtSameTime() {
|
||||
ScheduleRuleSlotDto first = laboratorySlot(SUBGROUP_A_ID);
|
||||
ScheduleRuleSlotDto second = slot(
|
||||
ScheduleParity.BOTH, OTHER_TEACHER_ID, OTHER_CLASSROOM_ID,
|
||||
LABORATORY_TYPE_ID, List.of(SUBGROUP_B_ID), "Очно"
|
||||
);
|
||||
|
||||
ScheduleRuleDto saved = service.create(ruleRequest(0, 4, 0, first, second));
|
||||
|
||||
assertThat(saved.slots()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresArchivedExternalRuleButRejectsEquivalentActiveRule() {
|
||||
ScheduleRule archived = persistedRule(200L, LifecycleEntity.STATUS_ARCHIVED, "Очно");
|
||||
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, null))
|
||||
.thenReturn(List.of(archived));
|
||||
|
||||
ScheduleRuleDto saved = service.create(ruleRequest(2, 0, 0, lectureSlot()));
|
||||
assertThat(saved.id()).isNotNull();
|
||||
|
||||
ScheduleRule active = persistedRule(201L, LifecycleEntity.STATUS_ACTIVE, "Очно");
|
||||
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, null))
|
||||
.thenReturn(List.of(active));
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequest(2, 0, 0, lectureSlot())))
|
||||
.isInstanceOfSatisfying(ScheduleRuleConflictException.class, exception -> {
|
||||
assertThat(exception.getMessage()).isEqualTo("Невозможно сохранить правило: слот занят");
|
||||
assertThat(exception.getConflictRule()).isNotNull();
|
||||
assertThat(exception.getConflictRule().id()).isEqualTo(201L);
|
||||
assertThat(exception.getConflictFields()).containsExactly("teacher", "classroom", "group");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateExcludesCurrentRuleFromExternalConflictCheck() {
|
||||
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
|
||||
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
|
||||
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, RULE_ID))
|
||||
.thenReturn(List.of(managed));
|
||||
ScheduleRuleSlotDto updatedSlot = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Онлайн"
|
||||
);
|
||||
|
||||
ScheduleRuleDto updated = service.update(RULE_ID, ruleRequest(2, 0, 0, updatedSlot));
|
||||
|
||||
assertThat(updated.id()).isEqualTo(RULE_ID);
|
||||
assertThat(updated.slots()).singleElement().extracting(ScheduleRuleSlotDto::lessonFormat)
|
||||
.isEqualTo("Онлайн");
|
||||
verify(scheduleRuleRepository).findActiveBySemesterIdWithDetails(SEMESTER_ID, RULE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedUpdateDoesNotMutateManagedRule() {
|
||||
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
|
||||
ScheduleRule externalConflict = persistedRule(201L, LifecycleEntity.STATUS_ACTIVE, "Онлайн");
|
||||
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
|
||||
when(scheduleRuleRepository.findActiveBySemesterIdWithDetails(SEMESTER_ID, RULE_ID))
|
||||
.thenReturn(List.of(externalConflict));
|
||||
ScheduleRuleSlotDto changedSlot = slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Онлайн"
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.update(RULE_ID, ruleRequest(2, 0, 0, changedSlot)))
|
||||
.isInstanceOf(ScheduleRuleConflictException.class)
|
||||
.hasMessage("Невозможно сохранить правило: слот занят");
|
||||
|
||||
assertThat(managed.getSlots()).singleElement().satisfies(slot ->
|
||||
assertThat(slot.getLessonFormat()).isEqualTo("Очно"));
|
||||
verify(scheduleRuleRepository, never()).saveAndFlush(managed);
|
||||
}
|
||||
|
||||
private ScheduleRuleDto ruleRequest(Integer lectureHours,
|
||||
Integer laboratoryHours,
|
||||
Integer practiceHours,
|
||||
ScheduleRuleSlotDto... slots) {
|
||||
return new ScheduleRuleDto(
|
||||
null,
|
||||
SUBJECT_ID,
|
||||
null,
|
||||
SEMESTER_ID,
|
||||
null,
|
||||
null,
|
||||
lectureHours,
|
||||
laboratoryHours,
|
||||
practiceHours,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
List.of(GROUP_ID),
|
||||
null,
|
||||
List.of(slots)
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto lectureSlot() {
|
||||
return slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID, List.of(), "Очно"
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto laboratorySlot(Long... subgroupIds) {
|
||||
return slot(
|
||||
ScheduleParity.BOTH,
|
||||
TEACHER_ID,
|
||||
CLASSROOM_ID,
|
||||
LABORATORY_TYPE_ID,
|
||||
List.of(subgroupIds),
|
||||
"Очно"
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto slot(ScheduleParity parity,
|
||||
long teacherId,
|
||||
long classroomId,
|
||||
long lessonTypeId,
|
||||
List<Long> subgroupIds,
|
||||
String format) {
|
||||
return new ScheduleRuleSlotDto(
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
parity,
|
||||
TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
subgroupIds,
|
||||
null,
|
||||
teacherId,
|
||||
null,
|
||||
classroomId,
|
||||
null,
|
||||
lessonTypeId,
|
||||
null,
|
||||
format
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRule persistedRule(long id, String status, String format) {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setId(id);
|
||||
rule.setStatus(status);
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
rule.setLectureAcademicHours(2);
|
||||
rule.setLaboratoryAcademicHours(0);
|
||||
rule.setPracticeAcademicHours(0);
|
||||
rule.setLectureStartWeek(1);
|
||||
rule.setLaboratoryStartWeek(1);
|
||||
rule.setPracticeStartWeek(1);
|
||||
rule.setGroups(new LinkedHashSet<>(List.of(group)));
|
||||
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
ReflectionTestUtils.setField(slot, "id", id * 10);
|
||||
slot.setScheduleRule(rule);
|
||||
slot.setDayOfWeek(1);
|
||||
slot.setParity(ScheduleParity.BOTH);
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lectureType);
|
||||
slot.setLessonFormat(format);
|
||||
rule.setSlots(new LinkedHashSet<>(List.of(slot)));
|
||||
return rule;
|
||||
}
|
||||
|
||||
private ScheduleRule assignGeneratedIds(ScheduleRule rule) {
|
||||
if (rule.getId() == null) {
|
||||
rule.setId(900L);
|
||||
}
|
||||
AtomicLong nextSlotId = new AtomicLong(1_000L);
|
||||
rule.getSlots().stream()
|
||||
.filter(slot -> slot.getId() == null)
|
||||
.forEach(slot -> ReflectionTestUtils.setField(slot, "id", nextSlotId.getAndIncrement()));
|
||||
return rule;
|
||||
}
|
||||
|
||||
private Subject subject() {
|
||||
Subject subject = new Subject();
|
||||
subject.setId(SUBJECT_ID);
|
||||
subject.setName("Тестовая дисциплина");
|
||||
subject.setDepartmentId(1L);
|
||||
return subject;
|
||||
}
|
||||
|
||||
private Semester semester() {
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(1L);
|
||||
year.setTitle("2026-2027");
|
||||
year.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
year.setEndDate(LocalDate.of(2027, 6, 30));
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setId(SEMESTER_ID);
|
||||
semester.setAcademicYear(year);
|
||||
semester.setSemesterType(SemesterType.autumn);
|
||||
semester.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
semester.setEndDate(LocalDate.of(2027, 1, 31));
|
||||
return semester;
|
||||
}
|
||||
|
||||
private StudentGroup group() {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(GROUP_ID);
|
||||
group.setName("Тестовая группа");
|
||||
return group;
|
||||
}
|
||||
|
||||
private TimeSlot timeSlot() {
|
||||
TimeSlotScope scope = new TimeSlotScope();
|
||||
scope.setId(1L);
|
||||
scope.setApplyMode("DEFAULT");
|
||||
TimeSlot slot = new TimeSlot();
|
||||
slot.setId(TIME_SLOT_ID);
|
||||
slot.setOrderNumber(1);
|
||||
slot.setStartTime(LocalTime.of(9, 0));
|
||||
slot.setEndTime(LocalTime.of(10, 30));
|
||||
slot.setDurationMinutes(90);
|
||||
slot.setTimeSlotScope(scope);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private User user(long id, Role role, String name) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setRole(role);
|
||||
user.setFullName(name);
|
||||
user.setJobTitle("Преподаватель");
|
||||
return user;
|
||||
}
|
||||
|
||||
private Classroom classroom(long id, String name) {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setId(id);
|
||||
classroom.setName(name);
|
||||
classroom.setIsAvailable(true);
|
||||
return classroom;
|
||||
}
|
||||
|
||||
private LessonType lessonType(long id, String name) {
|
||||
LessonType type = new LessonType();
|
||||
type.setId(id);
|
||||
type.setLessonType(name);
|
||||
return type;
|
||||
}
|
||||
|
||||
private Subgroup subgroup(long id, String name) {
|
||||
Subgroup subgroup = new Subgroup();
|
||||
subgroup.setId(id);
|
||||
subgroup.setName(name);
|
||||
subgroup.setStudentGroup(group);
|
||||
return subgroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Testcontainers
|
||||
class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
|
||||
private static final String DOMAIN = "alpha";
|
||||
private static final String OLD_DATABASE = "tenant_old";
|
||||
private static final String BAD_CHECKSUM_DATABASE = "tenant_bad_checksum";
|
||||
private static final String VALID_DATABASE = "tenant_valid";
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
|
||||
|
||||
private TenantRoutingDataSource routingDataSource;
|
||||
private TenantDatabaseMigrationService migrationService;
|
||||
private KubernetesTenantSecretUpdater tenantSecretUpdater;
|
||||
private TenantLifecycleService lifecycleService;
|
||||
private RetiredTenantPoolService retiredPoolService;
|
||||
private HikariDataSource oldPool;
|
||||
private TenantConfig oldConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
createDatabase(OLD_DATABASE);
|
||||
createDatabase(BAD_CHECKSUM_DATABASE);
|
||||
createDatabase(VALID_DATABASE);
|
||||
|
||||
routingDataSource = new TenantRoutingDataSource();
|
||||
migrationService = new TenantDatabaseMigrationService();
|
||||
tenantSecretUpdater = mock(KubernetesTenantSecretUpdater.class);
|
||||
retiredPoolService = new RetiredTenantPoolService(Duration.ofSeconds(2), Duration.ofMillis(25));
|
||||
lifecycleService = new TenantLifecycleService(
|
||||
routingDataSource,
|
||||
tenantSecretUpdater,
|
||||
migrationService,
|
||||
retiredPoolService
|
||||
);
|
||||
|
||||
oldConfig = config("Старый tenant", jdbcUrl(OLD_DATABASE), POSTGRES.getPassword());
|
||||
migrateDatabase(oldConfig);
|
||||
TenantConfig badChecksumConfig = config(
|
||||
"Tenant с повреждённой историей",
|
||||
jdbcUrl(BAD_CHECKSUM_DATABASE),
|
||||
POSTGRES.getPassword()
|
||||
);
|
||||
migrateDatabase(badChecksumConfig);
|
||||
corruptFlywayChecksum(jdbcUrl(BAD_CHECKSUM_DATABASE));
|
||||
|
||||
oldPool = routingDataSource.prepareTenantDataSource(oldConfig);
|
||||
assertThat(routingDataSource.swapTenant(oldConfig, oldPool)).isNull();
|
||||
assertOldState();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closePools() {
|
||||
TenantContext.clear();
|
||||
if (routingDataSource != null) {
|
||||
routingDataSource.getTenantDataSource(DOMAIN).ifPresent(this::closeIfHikari);
|
||||
}
|
||||
if (oldPool != null && !oldPool.isClosed()) {
|
||||
oldPool.close();
|
||||
}
|
||||
if (retiredPoolService != null) {
|
||||
retiredPoolService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesOldPoolForCredentialsFlywayAndPersistenceFailuresThenSwapsSuccessfully() throws Exception {
|
||||
TenantConfig wrongCredentials = config(
|
||||
"Неверные credentials",
|
||||
jdbcUrl(OLD_DATABASE),
|
||||
POSTGRES.getPassword() + "-wrong"
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(wrongCredentials))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось подключиться к базе данных тенанта");
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
assertOldState();
|
||||
|
||||
reset(tenantSecretUpdater);
|
||||
TenantConfig badChecksum = config(
|
||||
"Повреждённый Flyway",
|
||||
jdbcUrl(BAD_CHECKSUM_DATABASE),
|
||||
POSTGRES.getPassword()
|
||||
);
|
||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(badChecksum))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
assertOldState();
|
||||
|
||||
reset(tenantSecretUpdater);
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
|
||||
TenantConfig validCandidate = config(
|
||||
"Новый tenant",
|
||||
jdbcUrl(VALID_DATABASE),
|
||||
POSTGRES.getPassword()
|
||||
);
|
||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(validCandidate))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
||||
assertOldState();
|
||||
|
||||
reset(tenantSecretUpdater);
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
|
||||
TenantConfig saved;
|
||||
try (Connection inFlightOldConnection = oldPool.getConnection()) {
|
||||
saved = lifecycleService.addOrUpdateTenant(validCandidate);
|
||||
|
||||
assertThat(oldPool.isClosed()).isFalse();
|
||||
try (Statement statement = inFlightOldConnection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT current_database()")) {
|
||||
assertThat(result.next()).isTrue();
|
||||
assertThat(result.getString(1)).isEqualTo(OLD_DATABASE);
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(saved.getDomain()).isEqualTo(DOMAIN);
|
||||
assertThat(saved.getUrl()).isEqualTo(jdbcUrl(VALID_DATABASE));
|
||||
awaitPoolClosed(oldPool);
|
||||
DataSource activePool = routingDataSource.getTenantDataSource(DOMAIN).orElseThrow();
|
||||
assertThat(activePool).isNotSameAs(oldPool);
|
||||
assertThat(activePool).isInstanceOfSatisfying(
|
||||
HikariDataSource.class,
|
||||
pool -> assertThat(pool.isClosed()).isFalse()
|
||||
);
|
||||
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE);
|
||||
}
|
||||
|
||||
private void awaitPoolClosed(HikariDataSource pool) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (!pool.isClosed() && System.nanoTime() < deadline) {
|
||||
try {
|
||||
Thread.sleep(25);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ожидание закрытия старого tenant pool прервано", exception);
|
||||
}
|
||||
}
|
||||
assertThat(pool.isClosed()).as("Старый tenant pool должен закрыться после drain").isTrue();
|
||||
}
|
||||
|
||||
private void assertOldState() {
|
||||
assertThat(routingDataSource.getTenantDataSource(DOMAIN)).containsSame(oldPool);
|
||||
TenantConfig storedConfig = routingDataSource.snapshotTenantConfigs().get(DOMAIN);
|
||||
assertThat(storedConfig).isNotNull();
|
||||
assertThat(storedConfig.getUrl()).isEqualTo(jdbcUrl(OLD_DATABASE));
|
||||
assertThat(storedConfig.getUsername()).isEqualTo(POSTGRES.getUsername());
|
||||
assertThat(oldPool.isClosed()).isFalse();
|
||||
assertThat(currentDatabaseThroughRouter()).isEqualTo(OLD_DATABASE);
|
||||
}
|
||||
|
||||
private String currentDatabaseThroughRouter() {
|
||||
TenantContext.setCurrentTenant(DOMAIN);
|
||||
try (Connection connection = routingDataSource.getConnection();
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT current_database()")) {
|
||||
assertThat(result.next()).isTrue();
|
||||
return result.getString(1);
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Старое tenant-подключение перестало работать", failure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void createDatabase(String databaseName) throws Exception {
|
||||
try (Connection connection = POSTGRES.createConnection("");
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE DATABASE " + databaseName);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateDatabase(TenantConfig config) {
|
||||
HikariDataSource dataSource = routingDataSource.prepareTenantDataSource(config);
|
||||
try {
|
||||
migrationService.migrate(dataSource);
|
||||
} finally {
|
||||
dataSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void corruptFlywayChecksum(String url) throws Exception {
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
url,
|
||||
POSTGRES.getUsername(),
|
||||
POSTGRES.getPassword()
|
||||
); Statement statement = connection.createStatement()) {
|
||||
int changed = statement.executeUpdate("""
|
||||
UPDATE flyway_schema_history
|
||||
SET checksum = checksum + 1
|
||||
WHERE version = '4'
|
||||
""");
|
||||
assertThat(changed).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
private TenantConfig config(String name, String url, String password) {
|
||||
return new TenantConfig(name, DOMAIN, url, POSTGRES.getUsername(), password);
|
||||
}
|
||||
|
||||
private String jdbcUrl(String databaseName) {
|
||||
return "jdbc:postgresql://" + POSTGRES.getHost() + ":"
|
||||
+ POSTGRES.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT) + "/" + databaseName;
|
||||
}
|
||||
|
||||
private void closeIfHikari(DataSource dataSource) {
|
||||
if (dataSource instanceof HikariDataSource hikariDataSource && !hikariDataSource.isClosed()) {
|
||||
hikariDataSource.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
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.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TenantLifecycleServiceTest {
|
||||
|
||||
@Mock
|
||||
private TenantRoutingDataSource routingDataSource;
|
||||
@Mock
|
||||
private KubernetesTenantSecretUpdater tenantSecretUpdater;
|
||||
@Mock
|
||||
private TenantDatabaseMigrationService migrationService;
|
||||
@Mock
|
||||
private RetiredTenantPoolService retiredPoolService;
|
||||
@Mock
|
||||
private HikariDataSource candidate;
|
||||
@Mock
|
||||
private HikariDataSource oldDataSource;
|
||||
@Mock
|
||||
private Connection candidateConnection;
|
||||
|
||||
private TenantLifecycleService service;
|
||||
private TenantConfig oldAlpha;
|
||||
private TenantConfig beta;
|
||||
private Map<String, TenantConfig> oldSnapshot;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
service = new TenantLifecycleService(
|
||||
routingDataSource,
|
||||
tenantSecretUpdater,
|
||||
migrationService,
|
||||
retiredPoolService
|
||||
);
|
||||
oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
beta = config("Beta", "beta", "jdbc:beta", "beta-user", "beta-password");
|
||||
oldSnapshot = new LinkedHashMap<>();
|
||||
oldSnapshot.put("beta", beta);
|
||||
oldSnapshot.put("alpha", oldAlpha);
|
||||
|
||||
when(routingDataSource.snapshotTenantConfigs()).thenReturn(oldSnapshot);
|
||||
when(routingDataSource.prepareTenantDataSource(any(TenantConfig.class))).thenReturn(candidate);
|
||||
when(candidate.getConnection()).thenReturn(candidateConnection);
|
||||
when(candidateConnection.isValid(5)).thenReturn(true);
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
|
||||
when(routingDataSource.swapTenant(any(TenantConfig.class), any()))
|
||||
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesSuccessfulReplacementInSafeOrderAndClosesOnlyOldPool() throws Exception {
|
||||
TenantConfig requested = config(
|
||||
" Новый Alpha ", " ALPHA ", " jdbc:new-alpha ", "new-user", "new-password"
|
||||
);
|
||||
|
||||
TenantConfig saved = service.addOrUpdateTenant(requested);
|
||||
|
||||
assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
||||
ArgumentCaptor<List<TenantConfig>> persistedConfigs = listCaptor();
|
||||
InOrder order = inOrder(
|
||||
routingDataSource,
|
||||
candidate,
|
||||
candidateConnection,
|
||||
migrationService,
|
||||
tenantSecretUpdater,
|
||||
retiredPoolService
|
||||
);
|
||||
order.verify(routingDataSource).snapshotTenantConfigs();
|
||||
order.verify(routingDataSource).prepareTenantDataSource(preparedConfig.capture());
|
||||
order.verify(candidate).getConnection();
|
||||
order.verify(candidateConnection).isValid(5);
|
||||
order.verify(candidateConnection).close();
|
||||
order.verify(migrationService).migrate(candidate);
|
||||
order.verify(tenantSecretUpdater).updateTenantsConfig(persistedConfigs.capture());
|
||||
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
||||
order.verify(retiredPoolService).retire(oldDataSource);
|
||||
|
||||
assertConfig(preparedConfig.getValue(),
|
||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
assertThat(persistedConfigs.getValue()).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(persistedConfigs.getValue().get(0),
|
||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
verify(candidate, never()).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception {
|
||||
when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации"));
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось подключиться к базе данных тенанта");
|
||||
|
||||
verify(candidate).close();
|
||||
verify(migrationService, never()).migrate(any());
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesCandidateAndKeepsOldStateWhenFlywayFails() {
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
|
||||
.when(migrationService).migrate(candidate);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||
|
||||
verify(candidate).close();
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesCandidateAndRestoresOldSnapshotWhenPersistenceReturnsFalse() {
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
||||
|
||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
|
||||
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(snapshots.getAllValues().get(1).get(0),
|
||||
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
verify(candidate).close();
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresOldSnapshotWhenFirstPersistenceThrows() {
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any()))
|
||||
.thenThrow(new IllegalStateException("неопределённый исход PATCH"))
|
||||
.thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
||||
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
||||
verify(candidate).close();
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsDesynchronizationWhenPersistenceAndCompensationFail() {
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, false);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret");
|
||||
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
||||
verify(candidate).close();
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensatesPersistedSnapshotWhenActivationFails() {
|
||||
when(routingDataSource.swapTenant(any(), any()))
|
||||
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена");
|
||||
|
||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
|
||||
assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(snapshots.getAllValues().get(0).get(0),
|
||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(snapshots.getAllValues().get(1).get(0),
|
||||
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
verify(candidate).close();
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsSecretDesynchronizationWhenActivationAndCompensationFail() {
|
||||
when(routingDataSource.swapTenant(any(), any()))
|
||||
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, false);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось активировать подключение тенанта; требуется проверить tenant Secret");
|
||||
|
||||
verify(candidate).close();
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removalPersistsDesiredStateBeforeAtomicRemovalAndClosesRemovedPool() {
|
||||
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||
|
||||
TenantConfig removed = service.removeTenant(" ALPHA ");
|
||||
|
||||
assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
ArgumentCaptor<List<TenantConfig>> desired = listCaptor();
|
||||
InOrder order = inOrder(routingDataSource, tenantSecretUpdater, retiredPoolService);
|
||||
order.verify(routingDataSource).snapshotTenantConfigs();
|
||||
order.verify(tenantSecretUpdater).updateTenantsConfig(desired.capture());
|
||||
order.verify(routingDataSource).removeTenantAtomically("alpha");
|
||||
order.verify(retiredPoolService).retire(oldDataSource);
|
||||
assertThat(desired.getValue()).extracting(TenantConfig::getDomain).containsExactly("beta");
|
||||
}
|
||||
|
||||
@Test
|
||||
void removalFailureRestoresOldPersistedSnapshotAndKeepsPoolOpen() {
|
||||
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||
.thenThrow(new IllegalStateException("тестовый отказ удаления"));
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
|
||||
|
||||
assertThatThrownBy(() -> service.removeTenant("alpha"))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена");
|
||||
|
||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
|
||||
assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("beta");
|
||||
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() {
|
||||
String maxLengthDomain = "a".repeat(63);
|
||||
|
||||
TenantConfig shortest = service.addOrUpdateTenant(config(
|
||||
"Короткий", " A ", "jdbc:short", "user", "password"
|
||||
));
|
||||
TenantConfig longest = service.addOrUpdateTenant(config(
|
||||
"Длинный", maxLengthDomain, "jdbc:long", "user", "password"
|
||||
));
|
||||
|
||||
assertThat(shortest.getDomain()).isEqualTo("a");
|
||||
assertThat(longest.getDomain()).isEqualTo(maxLengthDomain);
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(config(
|
||||
"Слишком длинный", "a".repeat(64), "jdbc:too-long", "user", "password"
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Домен содержит недопустимые символы");
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(config(
|
||||
"С точкой", "alpha.example", "jdbc:dotted", "user", "password"
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Домен содержит недопустимые символы");
|
||||
}
|
||||
|
||||
private TenantConfig newAlpha() {
|
||||
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
}
|
||||
|
||||
private TenantConfig config(String name,
|
||||
String domain,
|
||||
String url,
|
||||
String username,
|
||||
String password) {
|
||||
return new TenantConfig(name, domain, url, username, password);
|
||||
}
|
||||
|
||||
private void assertConfig(TenantConfig actual,
|
||||
String name,
|
||||
String domain,
|
||||
String url,
|
||||
String username,
|
||||
String password) {
|
||||
assertThat(actual.getName()).isEqualTo(name);
|
||||
assertThat(actual.getDomain()).isEqualTo(domain);
|
||||
assertThat(actual.getUrl()).isEqualTo(url);
|
||||
assertThat(actual.getUsername()).isEqualTo(username);
|
||||
assertThat(actual.getPassword()).isEqualTo(password);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private ArgumentCaptor<List<TenantConfig>> listCaptor() {
|
||||
return ArgumentCaptor.forClass((Class) List.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user