баг-фикс 30/34

This commit is contained in:
Zuev
2026-07-19 14:40:43 +03:00
parent 3d798c13e3
commit bc0e1ab1b4
172 changed files with 13431 additions and 2910 deletions

View File

@@ -0,0 +1,146 @@
package com.magistr.app.config.auth;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy.
*/
@Component
public class ClientIpResolver {
private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class);
private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048;
private static final int MAX_PROXY_HOPS = 20;
private static final String UNKNOWN_ADDRESS = "неизвестен";
private final List<CidrRange> trustedProxyRanges;
public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) {
this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs);
}
public String resolve(HttpServletRequest request) {
Optional<InetAddress> remoteAddress = parseAddress(request.getRemoteAddr());
String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS);
if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) {
return fallback;
}
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded == null || forwarded.isBlank()) {
return fallback;
}
if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) {
log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина");
return fallback;
}
String[] hops = forwarded.split(",", -1);
if (hops.length > MAX_PROXY_HOPS) {
log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy");
return fallback;
}
InetAddress leftmost = null;
for (int index = hops.length - 1; index >= 0; index--) {
Optional<InetAddress> hop = parseAddress(hops[index].trim());
if (hop.isEmpty()) {
log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес");
return fallback;
}
leftmost = hop.get();
if (!isTrusted(hop.get())) {
return hop.get().getHostAddress();
}
}
return leftmost == null ? fallback : leftmost.getHostAddress();
}
private boolean isTrusted(InetAddress address) {
return trustedProxyRanges.stream().anyMatch(range -> range.contains(address));
}
private List<CidrRange> parseTrustedRanges(String rawRanges) {
if (rawRanges == null || rawRanges.isBlank()) {
return List.of();
}
List<CidrRange> ranges = new ArrayList<>();
for (String rawRange : rawRanges.split(",")) {
String value = rawRange.trim();
if (!value.isEmpty()) {
ranges.add(CidrRange.parse(value));
}
}
return List.copyOf(ranges);
}
private static Optional<InetAddress> parseAddress(String rawAddress) {
if (rawAddress == null) {
return Optional.empty();
}
String value = rawAddress.trim().toLowerCase(Locale.ROOT);
if (value.startsWith("[") && value.endsWith("]")) {
value = value.substring(1, value.length() - 1);
}
if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) {
return Optional.empty();
}
try {
return Optional.of(InetAddress.getByName(value));
} catch (UnknownHostException ignored) {
return Optional.empty();
}
}
private record CidrRange(byte[] network, int prefixLength) {
static CidrRange parse(String value) {
String[] parts = value.split("/", -1);
Optional<InetAddress> address = ClientIpResolver.parseAddress(parts[0]);
if (address.isEmpty()) {
throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value);
}
int addressBits = address.get().getAddress().length * 8;
int prefix;
try {
prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]);
} catch (NumberFormatException exception) {
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception);
}
if (parts.length > 2 || prefix < 0 || prefix > addressBits) {
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value);
}
return new CidrRange(mask(address.get().getAddress(), prefix), prefix);
}
boolean contains(InetAddress candidate) {
byte[] candidateBytes = candidate.getAddress();
return candidateBytes.length == network.length
&& Arrays.equals(mask(candidateBytes, prefixLength), network);
}
private static byte[] mask(byte[] address, int prefix) {
byte[] masked = Arrays.copyOf(address, address.length);
int fullBytes = prefix / 8;
int remainingBits = prefix % 8;
if (fullBytes < masked.length && remainingBits > 0) {
masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits));
fullBytes++;
}
Arrays.fill(masked, fullBytes, masked.length, (byte) 0);
return masked;
}
}
}

View File

@@ -0,0 +1,167 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */
@Component
public class LoginAttemptAuditCleanupJob {
private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class);
private final TenantRoutingDataSource routingDataSource;
private final AuthLoginAttemptAuditRepository auditRepository;
private final AuthLoginRateLimitRepository rateLimitRepository;
private final boolean enabled;
private final Duration retention;
private final int batchSize;
private final int maxBatchesPerTenant;
private final Clock clock;
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
@Autowired
public LoginAttemptAuditCleanupJob(
TenantRoutingDataSource routingDataSource,
AuthLoginAttemptAuditRepository auditRepository,
AuthLoginRateLimitRepository rateLimitRepository,
@Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled,
@Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention,
@Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize,
@Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention,
batchSize, maxBatchesPerTenant, Clock.systemUTC());
}
LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource,
AuthLoginAttemptAuditRepository auditRepository,
AuthLoginRateLimitRepository rateLimitRepository,
boolean enabled,
Duration retention,
int batchSize,
int maxBatchesPerTenant,
Clock clock) {
this.routingDataSource = Objects.requireNonNull(routingDataSource);
this.auditRepository = Objects.requireNonNull(auditRepository);
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
if (retention == null || retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным");
}
if (batchSize < 1 || batchSize > 10_000) {
throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000");
}
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000");
}
this.enabled = enabled;
this.retention = retention;
this.batchSize = batchSize;
this.maxBatchesPerTenant = maxBatchesPerTenant;
this.clock = Objects.requireNonNull(clock);
}
@Scheduled(
fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}",
initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}"
)
public void cleanupScheduled() {
if (enabled) {
cleanupNow();
}
}
public CleanupSummary cleanupNow() {
if (!cleanupInProgress.compareAndSet(false, true)) {
return new CleanupSummary(0, 0, 0, 0, true);
}
String previousTenant = TenantContext.getCurrentTenant();
try {
TenantContext.clear();
Map<String, ?> tenants = routingDataSource.snapshotTenantConfigs();
Instant now = clock.instant();
Instant cutoff = now.minus(retention);
int deletedAudits = 0;
int deletedLimits = 0;
int failedTenants = 0;
for (String tenant : tenants.keySet()) {
TenantContext.setCurrentTenant(tenant);
try {
deletedAudits += cleanupAudit(tenant, cutoff);
deletedLimits += cleanupLimits(tenant, cutoff, now);
} catch (RuntimeException failure) {
failedTenants++;
log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}",
tenant, failure.getClass().getSimpleName());
log.debug("Технические детали очистки аудита входа", failure);
} finally {
TenantContext.clear();
}
}
if (deletedAudits + deletedLimits > 0) {
log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}",
tenants.size(), deletedAudits, deletedLimits, failedTenants);
}
return new CleanupSummary(
tenants.size(), deletedAudits, deletedLimits, failedTenants, false
);
} finally {
restoreTenant(previousTenant);
cleanupInProgress.set(false);
}
}
private int cleanupAudit(String tenant, Instant cutoff) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant);
return deletedTotal;
}
private int cleanupLimits(String tenant, Instant cutoff, Instant now) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant);
return deletedTotal;
}
private void restoreTenant(String tenant) {
if (tenant == null || tenant.isBlank()) {
TenantContext.clear();
} else {
TenantContext.setCurrentTenant(tenant);
}
}
public record CleanupSummary(int tenantCount,
int auditDeletedCount,
int rateLimitDeletedCount,
int failedTenantCount,
boolean skippedBecauseAlreadyRunning) {
}
}

View File

@@ -0,0 +1,226 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@Service
public class LoginRateLimitService {
private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
private static final String OUTCOME_FAILURE = "FAILURE";
private static final String OUTCOME_BLOCKED = "BLOCKED";
private final AuthLoginRateLimitRepository rateLimitRepository;
private final AuthLoginAttemptAuditRepository auditRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final LoginSecurityProperties properties;
private final Clock clock;
private final String dummyPasswordHash;
@Autowired
public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
AuthLoginAttemptAuditRepository auditRepository,
UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
LoginSecurityProperties properties) {
this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
properties, Clock.systemUTC());
}
LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
AuthLoginAttemptAuditRepository auditRepository,
UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
LoginSecurityProperties properties,
Clock clock) {
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
this.auditRepository = Objects.requireNonNull(auditRepository);
this.userRepository = Objects.requireNonNull(userRepository);
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
this.properties = Objects.requireNonNull(properties);
this.clock = Objects.requireNonNull(clock);
this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
}
/**
* Блокировка строки удерживается до завершения проверки пароля и записи результата.
* Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
*/
@Transactional
public AuthenticationDecision authenticate(String tenant,
String username,
String rawPassword,
String clientIp) {
String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
Instant now = clock.instant();
rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
AuthLoginRateLimit state = rateLimitRepository
.findForUpdate(safeTenant, normalizedUsername, safeClientIp)
.orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));
if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
return AuthenticationDecision.blocked(retryAfter);
}
if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
state.setFailureCount(0);
state.setWindowStartedAt(now);
state.setBlockedUntil(null);
}
Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
boolean passwordMatches = passwordEncoder.matches(
rawPassword == null ? "" : rawPassword,
storedHash
);
boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();
if (authenticated) {
rateLimitRepository.delete(state);
return AuthenticationDecision.authenticated(user.get());
}
int failureCount = state.getFailureCount() + 1;
state.setFailureCount(failureCount);
state.setLastFailureAt(now);
state.setUpdatedAt(now);
if (failureCount >= properties.getMaxFailures()) {
Duration blockDuration = progressiveBlockDuration(failureCount);
Instant blockedUntil = now.plus(blockDuration);
state.setBlockedUntil(blockedUntil);
long retryAfter = retryAfterSeconds(now, blockedUntil);
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
return AuthenticationDecision.blocked(retryAfter);
}
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
return AuthenticationDecision.rejected();
}
private Duration progressiveBlockDuration(int failureCount) {
int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
long multiplier = 1L << exponent;
Duration candidate;
try {
candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
} catch (ArithmeticException overflow) {
return properties.getMaxBlockDuration();
}
return candidate.compareTo(properties.getMaxBlockDuration()) > 0
? properties.getMaxBlockDuration()
: candidate;
}
private void audit(String tenant,
String username,
String clientIp,
String outcome,
Instant occurredAt,
Long retryAfterSeconds) {
Integer retryAfter = retryAfterSeconds == null
? null
: Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
auditRepository.save(new AuthLoginAttemptAudit(
tenant, username, clientIp, outcome, occurredAt, retryAfter
));
}
private void logDenied(String tenant,
String username,
String clientIp,
String outcome,
Long retryAfterSeconds) {
log.warn(
"Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
tenant,
fingerprint(username),
clientIp,
outcome,
retryAfterSeconds
);
}
private String normalizeAndLimit(String value, int maxLength, String fallback) {
String bounded = value == null
? fallback
: value.substring(0, Math.min(value.length(), maxLength * 4));
String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
.replaceAll("\\p{C}", "")
.strip()
.toLowerCase(Locale.ROOT);
if (normalized.isBlank()) {
normalized = fallback;
}
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
private long retryAfterSeconds(Instant now, Instant blockedUntil) {
Duration remaining = Duration.between(now, blockedUntil);
long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
return Math.max(1, seconds);
}
private String fingerprint(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
}
}
public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {
static AuthenticationDecision authenticated(User user) {
return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
}
static AuthenticationDecision rejected() {
return new AuthenticationDecision(Status.REJECTED, null, 0);
}
static AuthenticationDecision blocked(long retryAfterSeconds) {
return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
}
public enum Status {
AUTHENTICATED,
REJECTED,
BLOCKED
}
}
}

View File

@@ -0,0 +1,70 @@
package com.magistr.app.config.auth;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "app.login-security")
public class LoginSecurityProperties {
private int maxFailures = 5;
private Duration attemptWindow = Duration.ofMinutes(15);
private Duration baseBlockDuration = Duration.ofMinutes(1);
private Duration maxBlockDuration = Duration.ofMinutes(15);
@PostConstruct
void validate() {
if (maxFailures < 2 || maxFailures > 100) {
throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100");
}
requirePositive(attemptWindow, "Окно учёта попыток входа");
requirePositive(baseBlockDuration, "Начальный срок блокировки входа");
requirePositive(maxBlockDuration, "Максимальный срок блокировки входа");
if (maxBlockDuration.compareTo(baseBlockDuration) < 0) {
throw new IllegalStateException(
"Максимальный срок блокировки входа не может быть меньше начального"
);
}
}
private void requirePositive(Duration duration, String fieldName) {
if (duration == null || duration.isZero() || duration.isNegative()) {
throw new IllegalStateException(fieldName + " должен быть положительным");
}
}
public int getMaxFailures() {
return maxFailures;
}
public void setMaxFailures(int maxFailures) {
this.maxFailures = maxFailures;
}
public Duration getAttemptWindow() {
return attemptWindow;
}
public void setAttemptWindow(Duration attemptWindow) {
this.attemptWindow = attemptWindow;
}
public Duration getBaseBlockDuration() {
return baseBlockDuration;
}
public void setBaseBlockDuration(Duration baseBlockDuration) {
this.baseBlockDuration = baseBlockDuration;
}
public Duration getMaxBlockDuration() {
return maxBlockDuration;
}
public void setMaxBlockDuration(Duration maxBlockDuration) {
this.maxBlockDuration = maxBlockDuration;
}
}

View File

@@ -0,0 +1,163 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД.
*/
@Component
public class RefreshTokenCleanupJob {
private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class);
private final TenantRoutingDataSource routingDataSource;
private final AuthRefreshTokenRepository repository;
private final boolean enabled;
private final Duration retention;
private final int batchSize;
private final int maxBatchesPerTenant;
private final Clock clock;
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
@Autowired
public RefreshTokenCleanupJob(
TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
@Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled,
@Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention,
@Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize,
@Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
this(
routingDataSource,
repository,
enabled,
retention,
batchSize,
maxBatchesPerTenant,
Clock.systemUTC()
);
}
RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
boolean enabled,
Duration retention,
int batchSize,
int maxBatchesPerTenant,
Clock clock) {
this.routingDataSource = Objects.requireNonNull(
routingDataSource,
"Маршрутизатор tenant-БД обязателен"
);
this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен");
if (retention == null || retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным");
}
if (batchSize < 1 || batchSize > 10_000) {
throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000");
}
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000");
}
this.enabled = enabled;
this.retention = retention;
this.batchSize = batchSize;
this.maxBatchesPerTenant = maxBatchesPerTenant;
this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны");
}
@Scheduled(
fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}",
initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}"
)
public void cleanupScheduled() {
if (enabled) {
cleanupNow();
}
}
/**
* Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков
* внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod.
*/
public CleanupSummary cleanupNow() {
if (!cleanupInProgress.compareAndSet(false, true)) {
return new CleanupSummary(0, 0, 0, true);
}
String previousTenant = TenantContext.getCurrentTenant();
try {
TenantContext.clear();
Map<String, ?> tenantSnapshot = routingDataSource.snapshotTenantConfigs();
Instant cutoff = clock.instant().minus(retention);
int deletedTotal = 0;
int failedTenants = 0;
for (String tenant : tenantSnapshot.keySet()) {
TenantContext.setCurrentTenant(tenant);
try {
deletedTotal += cleanupTenant(tenant, cutoff);
} catch (RuntimeException cleanupFailure) {
failedTenants++;
log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}",
tenant, cleanupFailure.getClass().getSimpleName());
log.debug("Технические детали очистки refresh-сессий", cleanupFailure);
} finally {
TenantContext.clear();
}
}
if (deletedTotal > 0) {
log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}",
tenantSnapshot.size(), deletedTotal, failedTenants);
}
return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false);
} finally {
restoreTenant(previousTenant);
cleanupInProgress.set(false);
}
}
private int cleanupTenant(String tenant, Instant cutoff) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = repository.deleteCleanupBatch(cutoff, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход",
tenant);
return deletedTotal;
}
private void restoreTenant(String previousTenant) {
if (previousTenant == null || previousTenant.isBlank()) {
TenantContext.clear();
} else {
TenantContext.setCurrentTenant(previousTenant);
}
}
public record CleanupSummary(int tenantCount,
int deletedCount,
int failedTenantCount,
boolean skippedBecauseAlreadyRunning) {
}
}

View File

@@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
@@ -25,17 +27,24 @@ public class RefreshTokenService {
private final SecureRandom secureRandom = new SecureRandom();
private final AuthRefreshTokenRepository repository;
private final JwtProperties properties;
private final Clock clock;
@Autowired
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
this(repository, properties, Clock.systemUTC());
}
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
this.repository = repository;
this.properties = properties;
this.clock = clock;
}
@Transactional
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
String refreshToken = generateRawToken();
AuthRefreshToken token = new AuthRefreshToken();
LocalDateTime now = LocalDateTime.now();
Instant now = clock.instant();
token.setUser(user);
token.setTenant(tenant);
token.setTokenHash(hashToken(refreshToken));
@@ -53,7 +62,7 @@ public class RefreshTokenService {
return Optional.empty();
}
LocalDateTime now = LocalDateTime.now();
Instant now = clock.instant();
String currentHash = hashToken(rawToken);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
if (storedOpt.isEmpty()) {
@@ -101,7 +110,7 @@ public class RefreshTokenService {
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
.filter(token -> token.getRevokedAt() == null)
.ifPresent(token -> {
token.setRevokedAt(LocalDateTime.now());
token.setRevokedAt(clock.instant());
repository.save(token);
});
}

View File

@@ -65,7 +65,7 @@ public class TenantDataSourceConfig {
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
);
tenants.add(defaultTenant);
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
}
readinessRegistry.clearConfigurationFailure();
@@ -79,10 +79,10 @@ public class TenantDataSourceConfig {
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
}
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
if (routingDataSource.getTenantConfigs().isEmpty()) {
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
log.warn("Добавьте тенант через POST /api/database/tenants");
TenantConfig h2Fallback = new TenantConfig(
@@ -155,7 +155,7 @@ public class TenantDataSourceConfig {
log.info("Загружено конфигураций тенантов: {}", list.size());
return new TenantConfigLoadResult(List.copyOf(list), false);
} catch (Exception e) {
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали чтения конфигурации тенантов", e);
return new TenantConfigLoadResult(List.of(), true);
@@ -189,15 +189,15 @@ public class TenantDataSourceConfig {
readinessRegistry.markConnectivity(tenant.getDomain(), true);
}
closeDataSource(previous == null ? null : previous.dataSource());
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
return true;
} catch (Exception startupFailure) {
if (requiredForReadiness) {
readinessRegistry.markMigrationFailed(tenant);
}
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
tenant.getDomain(), startupFailure.getClass().getSimpleName());
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
return false;
} finally {
if (!activated) {
@@ -211,9 +211,9 @@ public class TenantDataSourceConfig {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
closeFailure.getClass().getSimpleName());
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
}
}
}

View File

@@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant);
return true;
}
// Проверяем, существует ли тенант
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host);
response.setStatus(404);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
@@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
return true;
}

View File

@@ -141,7 +141,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
try (Connection connection = tenantDataSource.get().getConnection()) {
return connection.isValid(5);
} catch (SQLException e) {
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
normalizeDomain(domain), e.getClass().getSimpleName());
log.debug("Технические детали проверки подключения тенанта", e);
return false;
@@ -165,7 +165,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
}
return "Подключение не валидно";
} catch (Exception e) {
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали проверки внешнего подключения", e);
return "Не удалось подключиться к базе данных";

View File

@@ -4,6 +4,7 @@ import com.magistr.app.config.tenant.TenantRoutingDataSource;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -41,6 +42,7 @@ public class TenantDatabaseHealthMonitor {
private final ExecutorService executor;
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
@Autowired
public TenantDatabaseHealthMonitor(
TenantReadinessRegistry registry,
TenantRoutingDataSource routingDataSource,

View File

@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicPeriodService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -22,17 +23,20 @@ public class AcademicCalendarAdminController {
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicPeriodService academicPeriodService;
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarDayRepository calendarDayRepository,
ScheduleGeneratorService scheduleGeneratorService) {
ScheduleGeneratorService scheduleGeneratorService,
AcademicPeriodService academicPeriodService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarDayRepository = calendarDayRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicPeriodService = academicPeriodService;
}
@GetMapping("/years")
@@ -45,43 +49,17 @@ public class AcademicCalendarAdminController {
@PostMapping("/years")
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
String validationError = validateYear(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
}
AcademicYear year = new AcademicYear();
applyYear(year, request);
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request)));
}
@PutMapping("/years/{id}")
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
AcademicYear year = academicYearRepository.findById(id).orElse(null);
if (year == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateYear(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
applyYear(year, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
}
@DeleteMapping("/years/{id}")
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
if (!academicYearRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
academicYearRepository.deleteById(id);
scheduleGeneratorService.clearCache();
academicPeriodService.deleteYear(id);
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
}
@@ -94,39 +72,12 @@ public class AcademicCalendarAdminController {
@PostMapping("/years/{academicYearId}/semesters")
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
if (year == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
}
String validationError = validateSemester(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
}
Semester semester = new Semester();
semester.setAcademicYear(year);
applySemester(semester, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
}
@PutMapping("/semesters/{id}")
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
Semester semester = semesterRepository.findById(id).orElse(null);
if (semester == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateSemester(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
applySemester(semester, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
}
@GetMapping("/activity-types")
@@ -185,32 +136,6 @@ public class AcademicCalendarAdminController {
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
}
private String validateYear(AcademicYearDto request) {
if (request.title() == null || request.title().isBlank()) {
return "Название учебного года обязательно";
}
if (request.startDate() == null || request.endDate() == null) {
return "Даты начала и окончания учебного года обязательны";
}
if (request.endDate().isBefore(request.startDate())) {
return "Дата окончания не может быть раньше даты начала";
}
return null;
}
private String validateSemester(SemesterDto request) {
if (request.semesterType() == null) {
return "Тип семестра обязателен";
}
if (request.startDate() == null || request.endDate() == null) {
return "Даты начала и окончания семестра обязательны";
}
if (request.endDate().isBefore(request.startDate())) {
return "Дата окончания семестра не может быть раньше даты начала";
}
return null;
}
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
if (request == null) {
return "Передайте код активности";
@@ -224,18 +149,6 @@ public class AcademicCalendarAdminController {
return null;
}
private void applyYear(AcademicYear year, AcademicYearDto request) {
year.setTitle(request.title().trim());
year.setStartDate(request.startDate());
year.setEndDate(request.endDate());
}
private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate());
semester.setEndDate(request.endDate());
}
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
activityType.setCode(request.code().trim());
activityType.setName(request.name().trim());

View File

@@ -5,6 +5,7 @@ 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.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -32,6 +33,7 @@ public class AcademicCalendarController {
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicStructureService academicStructureService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
@@ -42,7 +44,8 @@ public class AcademicCalendarController {
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
ScheduleGeneratorService scheduleGeneratorService) {
ScheduleGeneratorService scheduleGeneratorService,
AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.academicYearRepository = academicYearRepository;
@@ -53,6 +56,7 @@ public class AcademicCalendarController {
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicStructureService = academicStructureService;
}
@GetMapping
@@ -85,17 +89,7 @@ public class AcademicCalendarController {
@PutMapping("/{id}")
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
}
@DeleteMapping("/{id}")
@@ -103,14 +97,9 @@ public class AcademicCalendarController {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
try {
calendarRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
}
calendarRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
}
@GetMapping("/{id}/grid")

View File

@@ -3,17 +3,18 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.JwtProperties;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.auth.ClientIpResolver;
import com.magistr.app.config.auth.LoginRateLimitService;
import com.magistr.app.config.auth.RefreshTokenRotation;
import com.magistr.app.config.auth.RefreshTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -27,8 +28,8 @@ import java.util.Optional;
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final LoginRateLimitService loginRateLimitService;
private final ClientIpResolver clientIpResolver;
private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
@@ -42,13 +43,13 @@ public class AuthController {
"STUDENT", "/student/"
);
public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
public AuthController(LoginRateLimitService loginRateLimitService,
ClientIpResolver clientIpResolver,
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.loginRateLimitService = loginRateLimitService;
this.clientIpResolver = clientIpResolver;
this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
@@ -56,28 +57,46 @@ public class AuthController {
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() ||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
TenantContext.getCurrentTenant(),
request.getUsername(),
request.getPassword(),
clientIpResolver.resolve(servletRequest)
);
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) {
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds()))
.body(new LoginResponse(
false,
"Слишком много попыток входа. Повторите позже",
null,
null,
null,
null
));
}
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
.body(new LoginResponse(
false,
"Неверное имя пользователя или пароль",
null,
null,
null,
null
));
}
User user = userOpt.get();
if (user.isArchivedRecord()) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
}
User user = decision.user();
String tenant = TenantContext.getCurrentTenant();
String accessToken = jwtTokenService.createAccessToken(user, tenant);
String refreshToken = refreshTokenService.createSession(
user,
tenant,
servletRequest.getHeader("User-Agent"),
clientIp(servletRequest)
clientIpResolver.resolve(servletRequest)
);
return ResponseEntity.ok()
@@ -96,7 +115,7 @@ public class AuthController {
refreshToken.get(),
TenantContext.getCurrentTenant(),
request.getHeader("User-Agent"),
clientIp(request)
clientIpResolver.resolve(request)
);
if (rotation.isEmpty()) {
return unauthorizedWithClearedCookie();
@@ -192,11 +211,4 @@ public class AuthController {
return Optional.empty();
}
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -0,0 +1,190 @@
package com.magistr.app.controller;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки.
*/
final class DatabaseConstraintViolationMapper {
private static final Pattern CONSTRAINT_PATTERN = Pattern.compile(
"(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?"
);
private static final Map<String, ViolationResponse> KNOWN_CONSTRAINTS = Map.ofEntries(
badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"),
badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"),
badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"),
badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"),
badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"),
badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"),
badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"),
badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"),
badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"),
badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"),
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"),
badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"),
badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"),
badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"),
badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"),
badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"),
badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"),
badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"),
badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"),
badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"),
conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"),
conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"),
conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"),
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
conflict("uq_calendar_days", "День календарного графика уже существует"),
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),
conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"),
conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"),
conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"),
conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"),
conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"),
conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением")
);
ViolationResponse map(DataIntegrityViolationException exception) {
String constraintName = constraintName(exception).orElse(null);
String sqlState = sqlState(exception).orElse(null);
if (constraintName != null) {
ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName);
if (known != null) {
return known.withDetails(constraintName, sqlState);
}
if (constraintName.endsWith("_fkey")) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция невозможна из-за связанных данных",
constraintName,
sqlState
);
}
}
if ("23502".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.BAD_REQUEST,
"Не заполнено обязательное поле",
constraintName,
sqlState
);
}
if ("23514".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.BAD_REQUEST,
"Данные нарушают правила предметной области",
constraintName,
sqlState
);
}
if ("23505".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Такая запись уже существует",
constraintName,
sqlState
);
}
if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция невозможна из-за конфликта или связанных данных",
constraintName,
sqlState
);
}
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция нарушает ограничения целостности данных",
constraintName,
sqlState
);
}
private Optional<String> constraintName(Throwable failure) {
for (Throwable current = failure; current != null; current = nextCause(current)) {
if (current instanceof ConstraintViolationException constraintViolation
&& constraintViolation.getConstraintName() != null) {
return normalize(constraintViolation.getConstraintName());
}
String message = current.getMessage();
if (message != null) {
Matcher matcher = CONSTRAINT_PATTERN.matcher(message);
if (matcher.find()) {
return normalize(matcher.group(1));
}
}
}
return Optional.empty();
}
private Optional<String> sqlState(Throwable failure) {
for (Throwable current = failure; current != null; current = nextCause(current)) {
if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) {
return Optional.of(sqlException.getSQLState());
}
}
return Optional.empty();
}
private Throwable nextCause(Throwable current) {
Throwable cause = current.getCause();
return cause == current ? null : cause;
}
private Optional<String> normalize(String constraintName) {
if (constraintName == null || constraintName.isBlank()) {
return Optional.empty();
}
String normalized = constraintName.trim()
.replace("\"", "")
.toLowerCase(Locale.ROOT);
int dot = normalized.lastIndexOf('.');
return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized);
}
private static Map.Entry<String, ViolationResponse> badRequest(String name, String message) {
return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514"));
}
private static Map.Entry<String, ViolationResponse> conflict(String name, String message) {
return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null));
}
record ViolationResponse(HttpStatus status,
String message,
String constraintName,
String sqlState) {
private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) {
return new ViolationResponse(status, message, actualConstraintName, actualSqlState);
}
}
}

View File

@@ -9,7 +9,6 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -93,9 +92,8 @@ public class DepartmentController {
)
);
} catch (Exception e) {
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
logger.error("Ошибка при создании кафедры", e);
throw e;
}
}
@@ -148,9 +146,8 @@ public class DepartmentController {
department.getDepartmentCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
throw e;
}
}

View File

@@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.SubjectImportService;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +44,9 @@ public class DepartmentWorkspaceController {
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService;
private final TeacherDepartmentService teacherDepartmentService;
private final SubjectImportService subjectImportService;
private final BusinessTimeService businessTime;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
@@ -47,7 +54,26 @@ public class DepartmentWorkspaceController {
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) {
ScheduleQueryService scheduleQueryService,
TeacherDepartmentService teacherDepartmentService,
SubjectImportService subjectImportService) {
this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository,
teacherDepartmentAssignmentRepository, teacherCreationRequestRepository,
scheduleQueryService, teacherDepartmentService, subjectImportService,
BusinessTimeService.systemDefault());
}
@Autowired
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService,
TeacherDepartmentService teacherDepartmentService,
SubjectImportService subjectImportService,
BusinessTimeService businessTime) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
@@ -55,6 +81,9 @@ public class DepartmentWorkspaceController {
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService;
this.teacherDepartmentService = teacherDepartmentService;
this.subjectImportService = subjectImportService;
this.businessTime = businessTime;
}
@GetMapping("/subjects")
@@ -77,19 +106,7 @@ public class DepartmentWorkspaceController {
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
List<Subject> saved = requests.stream()
.filter(request -> request.getName() != null && !request.getName().isBlank())
.map(request -> {
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
subject.setName(request.getName().trim());
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
subject.setDepartmentId(departmentId);
if (subject.isArchivedRecord()) {
subject.restore();
}
return subjectRepository.save(subject);
})
.toList();
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
return ResponseEntity.ok(Map.of(
"message", "Дисциплины загружены",
"count", saved.size()
@@ -118,6 +135,7 @@ public class DepartmentWorkspaceController {
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
}
SubjectComment comment = new SubjectComment();
comment.setCreatedAt(businessTime.now());
comment.setSubject(subject);
comment.setComment(commentText.trim());
Long currentUserId = AuthContext.currentUserId();
@@ -132,21 +150,19 @@ public class DepartmentWorkspaceController {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.map(user -> toUserResponse(
user,
teacherDepartmentService.findPrimaryDepartmentIdAtDate(
user.getId(),
businessTime.today()
).orElse(user.getDepartmentId())
))
.toList();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return teachersById.values().stream()
.map(this::toUserResponse)
return teacherDepartmentService
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
.map(user -> toUserResponse(user, effectiveDepartmentId))
.toList();
}
@@ -167,7 +183,7 @@ public class DepartmentWorkspaceController {
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
}
LocalDate today = LocalDate.now();
LocalDate today = businessTime.today();
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
}
@@ -225,6 +241,8 @@ public class DepartmentWorkspaceController {
}
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
teacherRequest.setCreatedAt(businessTime.now());
teacherRequest.setUpdatedAt(businessTime.now());
teacherRequest.setDepartment(department);
teacherRequest.setUsername(username);
teacherRequest.setFullName(fullName);
@@ -257,14 +275,14 @@ public class DepartmentWorkspaceController {
);
}
private UserResponse toUserResponse(User user) {
private UserResponse toUserResponse(User user, Long departmentId) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
departmentId
);
response.setStatus(user.getStatus());
return response;

View File

@@ -15,7 +15,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN})
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EducationFormController {
private final EducationFormRepository educationFormRepository;

View File

@@ -4,6 +4,7 @@ import com.magistr.app.service.ScheduleConflictException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
@@ -20,6 +21,8 @@ import java.util.NoSuchElementException;
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final DatabaseConstraintViolationMapper constraintViolationMapper =
new DatabaseConstraintViolationMapper();
@ExceptionHandler(ScheduleConflictException.class)
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
@@ -58,6 +61,23 @@ public class GlobalExceptionHandler {
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
DataIntegrityViolationException exception,
HttpServletRequest request) {
DatabaseConstraintViolationMapper.ViolationResponse violation =
constraintViolationMapper.map(exception);
log.warn(
"Нарушение целостности данных {} {}: constraint={}, sqlState={}",
request.getMethod(),
request.getRequestURI(),
violation.constraintName() == null ? "не определено" : violation.constraintName(),
violation.sqlState() == null ? "не определено" : violation.sqlState()
);
log.debug("Технические детали нарушения целостности данных", exception);
return error(violation.status(), violation.message(), request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
HttpServletRequest request) {
@@ -67,7 +87,7 @@ public class GlobalExceptionHandler {
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("timestamp", Instant.now().toString());
body.put("status", status.value());
body.put("error", reason(status));
body.put("message", message);

View File

@@ -5,9 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
@@ -17,12 +15,15 @@ import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.service.BusinessTimeService;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -45,33 +46,49 @@ public class GroupController {
private final EducationFormRepository educationFormRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicYearRepository academicYearRepository;
private final AcademicCalendarRepository academicCalendarRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicStructureService academicStructureService;
private final BusinessTimeService businessTime;
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService) {
this(groupRepository, educationFormRepository, specialtiesRepository,
specialtyProfileRepository, calendarSubjectRepository, assignmentRepository,
scheduleGeneratorService, groupLifecycleService, academicStructureService,
BusinessTimeService.systemDefault());
}
@Autowired
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService,
BusinessTimeService businessTime) {
this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
this.academicYearRepository = academicYearRepository;
this.academicCalendarRepository = academicCalendarRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
this.academicStructureService = academicStructureService;
this.businessTime = businessTime;
}
@GetMapping
@@ -82,7 +99,7 @@ public class GroupController {
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.toList();
List<GroupResponse> response = groups.stream()
@@ -101,7 +118,7 @@ public class GroupController {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.toList();
if(groups.isEmpty()) {
@@ -163,10 +180,9 @@ public class GroupController {
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка при создании группы", e);
throw e;
}
}
@@ -174,50 +190,9 @@ public class GroupController {
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id);
try {
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
if (groupOpt.isEmpty()) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
ResponseEntity<?> validationError = validateGroupRequest(request);
if (validationError != null) {
return validationError;
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
}
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElse(null);
if (speciality == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
}
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
.orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
}
StudentGroup group = groupOpt.get();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(speciality);
group.setSpecialtyProfile(profile);
groupRepository.save(group);
logger.info("Группа с ID - {} успешно обновлена", id);
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e) {
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
}
StudentGroup group = academicStructureService.updateGroup(id, request);
logger.info("Группа с ID - {} успешно обновлена", id);
return ResponseEntity.ok(mapToResponse(group));
}
@DeleteMapping("/{id}")
@@ -229,7 +204,7 @@ public class GroupController {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
group.archive("Группа архивирована");
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
groupRepository.save(group);
logger.info("Группа с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
@@ -278,11 +253,11 @@ public class GroupController {
}
private GroupResponse mapToResponse(StudentGroup group) {
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
LocalDate today = businessTime.today();
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile();
LocalDate today = LocalDate.now();
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse(
group.getId(),
@@ -323,42 +298,9 @@ public class GroupController {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
@RequestBody GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
}
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
if (academicYear == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
}
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
if (calendar == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
}
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
}
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
}
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
}
StudentGroupCalendarAssignment assignment = assignmentRepository
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
.orElseGet(StudentGroupCalendarAssignment::new);
assignment.setStudentGroup(group);
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
scheduleGeneratorService.clearCache();
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
.findByCalendarIdWithSubject(calendar.getId())
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
.stream()
.map(this::toCalendarSubjectDto)
.toList();

View File

@@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -36,6 +35,7 @@ public class SpecialityController {
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
@@ -102,9 +102,8 @@ public class SpecialityController {
)
);
} catch (Exception e) {
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
logger.error("Ошибка при создании специальности", e);
throw e;
}
}
@@ -158,9 +157,8 @@ public class SpecialityController {
speciality.getSpecialityCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
throw e;
}
}
@@ -194,6 +192,7 @@ public class SpecialityController {
}
@GetMapping("/profiles")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public List<SpecialtyProfileDto> getAllProfiles() {
logger.info("Получен запрос на получение всех профилей обучения");
return specialtyProfileRepository.findAll().stream()
@@ -204,6 +203,7 @@ public class SpecialityController {
}
@GetMapping("/{specialtyId}/profiles")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) {
return ResponseEntity.notFound().build();
@@ -255,14 +255,8 @@ public class SpecialityController {
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
return ResponseEntity.notFound().build();
}
try {
specialtyProfileRepository.delete(profile);
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
} catch (Exception e) {
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
return ResponseEntity.badRequest()
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
}
specialtyProfileRepository.delete(profile);
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
}
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {

View File

@@ -9,7 +9,6 @@ import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -125,14 +124,10 @@ public class SubgroupController {
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try {
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
}
private String validate(SubgroupDto request) {

View File

@@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -87,7 +86,7 @@ public class SubjectController {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) {
String errorMessage = "Дисциплина с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
@@ -119,10 +118,9 @@ public class SubjectController {
subject.getDepartmentId()
)
);
} catch (Exception e){
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
} catch (Exception e) {
logger.error("Ошибка при создании дисциплины", e);
throw e;
}
}

View File

@@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -30,17 +29,20 @@ public class TeacherCreationRequestController {
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final BusinessTimeService businessTime;
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
BCryptPasswordEncoder passwordEncoder) {
BCryptPasswordEncoder passwordEncoder,
BusinessTimeService businessTime) {
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.passwordEncoder = passwordEncoder;
this.businessTime = businessTime;
}
@GetMapping
@@ -98,7 +100,8 @@ public class TeacherCreationRequestController {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setValidFrom(businessTime.today());
assignment.setCreatedAt(businessTime.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Создан по заявке кафедры #" + request.getId());
assignment.setCreatedBy(AuthContext.currentUserId());
@@ -110,7 +113,7 @@ public class TeacherCreationRequestController {
request.setDepartment(department);
request.setStatus(TeacherCreationRequestStatus.APPROVED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewedAt(businessTime.now());
request.setReviewComment(trimToNull(review.getReviewComment()));
request.setCreatedTeacherId(teacher.getId());
teacherCreationRequestRepository.save(request);
@@ -132,7 +135,7 @@ public class TeacherCreationRequestController {
}
request.setStatus(TeacherCreationRequestStatus.REJECTED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewedAt(businessTime.now());
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
}

View File

@@ -11,6 +11,9 @@ import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -25,13 +28,28 @@ public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
private final UserRepository userRepository;
private final SubjectRepository subjectRepository;
private final TeacherDepartmentService teacherDepartmentService;
private final BusinessTimeService businessTime;
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository) {
SubjectRepository subjectRepository,
TeacherDepartmentService teacherDepartmentService) {
this(teacherSubjectRepository, userRepository, subjectRepository,
teacherDepartmentService, BusinessTimeService.systemDefault());
}
@Autowired
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository,
TeacherDepartmentService teacherDepartmentService,
BusinessTimeService businessTime) {
this.teacherSubjectRepository = teacherSubjectRepository;
this.userRepository = userRepository;
this.subjectRepository = subjectRepository;
this.teacherDepartmentService = teacherDepartmentService;
this.businessTime = businessTime;
}
@GetMapping
@@ -111,7 +129,11 @@ public class TeacherSubjectController {
return departmentId != null
&& teacher != null
&& subject != null
&& departmentId.equals(teacher.getDepartmentId())
&& teacherDepartmentService.hasAssignmentAtDate(
teacher.getId(),
departmentId,
businessTime.today()
)
&& departmentId.equals(subject.getDepartmentId());
}
}

View File

@@ -11,11 +11,10 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.dao.DataIntegrityViolationException;
import com.magistr.app.service.TimeSlotService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -35,13 +34,16 @@ public class TimeSlotAdminController {
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final TimeSlotService timeSlotService;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
TimeSlotDateAssignmentRepository dateAssignmentRepository,
TimeSlotService timeSlotService) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
this.timeSlotService = timeSlotService;
}
@GetMapping
@@ -151,83 +153,18 @@ public class TimeSlotAdminController {
@PostMapping
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (hasDuplicate(request, null)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
TimeSlot timeSlot = new TimeSlot();
apply(timeSlot, request);
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
return ResponseEntity.ok(toDto(timeSlotService.create(request)));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
if (timeSlot == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (hasDuplicate(request, id)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
apply(timeSlot, request);
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!timeSlotRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
try {
timeSlotRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
}
}
private String validate(TimeSlotDto request) {
if (request.orderNumber() == null || request.orderNumber() <= 0) {
return "Номер пары должен быть больше нуля";
}
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
return "Выберите сетку времени";
}
if (request.startTime() == null || request.endTime() == null) {
return "Время начала и окончания обязательно";
}
if (!request.startTime().isBefore(request.endTime())) {
return "Время начала должно быть раньше времени окончания";
}
return null;
}
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
timeSlot.setOrderNumber(request.orderNumber());
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
timeSlot.setStartTime(request.startTime());
timeSlot.setEndTime(request.endTime());
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
? request.durationMinutes()
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
timeSlot.setDurationMinutes(duration);
}
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
.isPresent();
timeSlotService.delete(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
}
private List<TimeSlot> effectiveSlots(LocalDate date) {

View File

@@ -14,20 +14,20 @@ import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/users")
@@ -38,16 +38,33 @@ public class UserController {
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherDepartmentService teacherDepartmentService;
private final BCryptPasswordEncoder passwordEncoder;
private final BusinessTimeService businessTime;
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherDepartmentService teacherDepartmentService) {
this(userRepository, passwordEncoder, departmentRepository,
teacherDepartmentAssignmentRepository, teacherDepartmentService,
BusinessTimeService.systemDefault());
}
@Autowired
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherDepartmentService teacherDepartmentService,
BusinessTimeService businessTime) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherDepartmentService = teacherDepartmentService;
this.businessTime = businessTime;
}
@GetMapping
@@ -82,7 +99,10 @@ public class UserController {
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
departmentId,
businessTime.today()
);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
@@ -92,7 +112,7 @@ public class UserController {
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
return ResponseEntity.ok(users.stream()
.map(this::toUserResponse)
.map(user -> toUserResponse(user, departmentId))
.toList());
}
@@ -161,7 +181,7 @@ public class UserController {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(requestedDepartment);
assignment.setValidFrom(LocalDate.now());
assignment.setValidFrom(businessTime.today());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
@@ -180,7 +200,7 @@ public class UserController {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
user.archive("Пользователь архивирован");
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
userRepository.save(user);
logger.info("Пользователь с ID - {} успешно архивирован", id);
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
@@ -206,7 +226,11 @@ public class UserController {
var currentUser = AuthContext.getCurrentUser();
if (currentUser != null
&& currentUser.role() == Role.DEPARTMENT
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
&& !teacherDepartmentService.hasAssignmentAtDate(
id,
currentUser.departmentId(),
businessTime.today()
)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
}
@@ -216,46 +240,13 @@ public class UserController {
}
@PostMapping("/{id}/department-transfer")
@Transactional
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
User teacher = userRepository.findById(id).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
}
if (request.departmentId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
.ifPresent(current -> {
if (current.getValidFrom().isBefore(validFrom)) {
current.setValidTo(validFrom.minusDays(1));
teacherDepartmentAssignmentRepository.save(current);
} else {
teacherDepartmentAssignmentRepository.delete(current);
}
});
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setPrimaryAssignment(true);
assignment.setComment(request.comment());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
id,
request,
businessTime.today(),
AuthContext.currentUserId()
);
return ResponseEntity.ok(toAssignmentDto(assignment));
}
@@ -268,16 +259,25 @@ public class UserController {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
.map(this::toUserResponse)
LocalDate effectiveDate = date == null ? businessTime.today() : date;
return ResponseEntity.ok(teacherDepartmentService
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
.map(user -> toUserResponse(user, departmentId))
.toList());
}
private UserResponse toUserResponse(User user) {
String departmentName = user.getDepartmentId() == null
Long departmentId = user.getRole() == Role.TEACHER
? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today())
.orElse(user.getDepartmentId())
: user.getDepartmentId();
return toUserResponse(user, departmentId);
}
private UserResponse toUserResponse(User user, Long departmentId) {
String departmentName = departmentId == null
? null
: departmentRepository.findById(user.getDepartmentId())
: departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Неизвестно");
UserResponse response = new UserResponse(
@@ -288,7 +288,7 @@ public class UserController {
user.getJobTitle(),
departmentName
);
response.setDepartmentId(user.getDepartmentId());
response.setDepartmentId(departmentId);
response.setStatus(user.getStatus());
return response;
}
@@ -307,23 +307,6 @@ public class UserController {
);
}
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
Map<Long, User> teachersById = new LinkedHashMap<>();
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
if (assignments != null) {
assignments.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
}
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
if (directTeachers != null) {
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
}
return List.copyOf(teachersById.values());
}
private boolean canAccessDepartment(Long departmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser == null

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.dto.RenderedLessonDto;
@@ -7,11 +8,13 @@ import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.TeacherDepartmentService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
@@ -27,17 +30,17 @@ public class WorkloadController {
private final ScheduleQueryService scheduleQueryService;
private final ClassroomRepository classroomRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentService teacherDepartmentService;
public WorkloadController(ScheduleQueryService scheduleQueryService,
ClassroomRepository classroomRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository) {
DepartmentRepository departmentRepository,
TeacherDepartmentService teacherDepartmentService) {
this.scheduleQueryService = scheduleQueryService;
this.classroomRepository = classroomRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentService = teacherDepartmentService;
}
@GetMapping("/teachers")
@@ -46,19 +49,11 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
teacherId -> {
User teacher = teachersById.get(teacherId);
return teacher == null ? null : teacher.getDepartmentId();
});
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarizeTeacherWorkload(lessons, startDate, endDate);
}
@GetMapping("/classrooms")
@@ -67,8 +62,10 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
}
@@ -78,22 +75,20 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
Map<Long, String> departments = departmentRepository.findAll().stream()
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
.collect(Collectors.groupingBy(lesson -> {
User teacher = teachersById.get(lesson.teacherId());
return teacher == null ? 0L : teacher.getDepartmentId();
}));
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
lesson,
assignmentsByTeacher
).orElse(0L)));
return byDepartment.entrySet().stream()
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
@@ -101,14 +96,76 @@ public class WorkloadController {
.toList();
}
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey(
lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
),
LinkedHashMap::new,
Collectors.toList()
));
return grouped.entrySet().stream()
.map(entry -> summary(
entry.getKey().teacherId(),
entry.getValue().get(0).teacherName(),
entry.getKey().departmentId(),
entry.getValue()
))
.sorted(Comparator
.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))
.thenComparing(
WorkloadSummaryDto::departmentId,
Comparator.nullsLast(Long::compareTo)
))
.toList();
}
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate
) {
Set<Long> teacherIds = lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return teacherDepartmentService.findAssignmentsForTeachersBetween(
teacherIds,
startDate,
endDate
);
}
private Optional<Long> departmentIdAtLesson(
RenderedLessonDto lesson,
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher
) {
if (lesson.teacherId() == null || lesson.date() == null) {
return Optional.empty();
}
return teacherDepartmentService.resolveDepartmentIdAtDate(
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
lesson.date()
);
}
@GetMapping("/time-slots")
public List<WorkloadSummaryDto> timeSlotWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarize(lessons, RenderedLessonDto::timeSlotId,
lesson -> lesson.timeSlotOrder() == null
? "Пара"
@@ -121,8 +178,10 @@ public class WorkloadController {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam Long timeSlotId
) {
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
null, null, timeSlotId, null, date, date)
Long effectiveDepartmentId = effectiveDepartmentId(null);
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, timeSlotId, date, date
)
.stream()
.map(RenderedLessonDto::classroomId)
.filter(Objects::nonNull)
@@ -166,6 +225,9 @@ public class WorkloadController {
if (departmentId == null) {
return null;
}
if (departmentId == 0L) {
return "Кафедра не указана";
}
return departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Кафедра не найдена");
@@ -185,4 +247,35 @@ public class WorkloadController {
new ArrayList<>(classroom.getEquipments())
);
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
if (currentUser == null) {
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"Не удалось определить текущего пользователя"
);
}
if (currentUser.role() != Role.DEPARTMENT) {
return requestedDepartmentId;
}
Long ownDepartmentId = currentUser.departmentId();
if (ownDepartmentId == null) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Для пользователя кафедры не указана кафедра"
);
}
if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Нельзя просматривать нагрузку другой кафедры"
);
}
return ownDepartmentId;
}
private record TeacherDepartmentKey(Long teacherId, Long departmentId) {
}
}

View File

@@ -1,7 +1,7 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
public class ClassroomResponse {
@@ -12,7 +12,7 @@ public class ClassroomResponse {
private Integer floor;
private Boolean isAvailable;
private String status;
private LocalDateTime archivedAt;
private Instant archivedAt;
private String archiveReason;
private List<Equipment> equipments;
@@ -25,7 +25,7 @@ public class ClassroomResponse {
}
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, String status, LocalDateTime archivedAt,
Boolean isAvailable, String status, Instant archivedAt,
String archiveReason, List<Equipment> equipments) {
this.id = id;
this.name = name;
@@ -95,11 +95,11 @@ public class ClassroomResponse {
this.status = status;
}
public LocalDateTime getArchivedAt() {
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}

View File

@@ -1,7 +1,7 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
public record ScheduleOverrideDto(
Long id,
@@ -14,6 +14,6 @@ public record ScheduleOverrideDto(
String newLessonFormat,
String comment,
Long createdBy,
LocalDateTime createdAt
Instant createdAt
) {
}

View File

@@ -1,6 +1,6 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
import java.time.Instant;
public record SubjectCommentDto(
Long id,
@@ -8,6 +8,6 @@ public record SubjectCommentDto(
Long authorId,
String authorName,
String comment,
LocalDateTime createdAt
Instant createdAt
) {
}

View File

@@ -1,6 +1,6 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
import java.time.Instant;
public record TeacherCreationRequestResponse(
Long id,
@@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse(
String comment,
String status,
Long requestedBy,
LocalDateTime createdAt,
Instant createdAt,
Long reviewedBy,
LocalDateTime reviewedAt,
Instant reviewedAt,
String reviewComment,
Long createdTeacherId
) {

View File

@@ -0,0 +1,78 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "auth_login_attempt_audit")
public class AuthLoginAttemptAudit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String tenant;
@Column(name = "username_normalized", nullable = false, length = 100)
private String usernameNormalized;
@Column(name = "client_ip", nullable = false, length = 64)
private String clientIp;
@Column(nullable = false, length = 20)
private String outcome;
@Column(name = "occurred_at", nullable = false)
private Instant occurredAt;
@Column(name = "retry_after_seconds")
private Integer retryAfterSeconds;
public AuthLoginAttemptAudit() {
}
public AuthLoginAttemptAudit(String tenant,
String usernameNormalized,
String clientIp,
String outcome,
Instant occurredAt,
Integer retryAfterSeconds) {
this.tenant = tenant;
this.usernameNormalized = usernameNormalized;
this.clientIp = clientIp;
this.outcome = outcome;
this.occurredAt = occurredAt;
this.retryAfterSeconds = retryAfterSeconds;
}
public String getTenant() {
return tenant;
}
public String getUsernameNormalized() {
return usernameNormalized;
}
public String getClientIp() {
return clientIp;
}
public String getOutcome() {
return outcome;
}
public Instant getOccurredAt() {
return occurredAt;
}
public Integer getRetryAfterSeconds() {
return retryAfterSeconds;
}
}

View File

@@ -0,0 +1,83 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "auth_login_rate_limits")
public class AuthLoginRateLimit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String tenant;
@Column(name = "username_normalized", nullable = false, length = 100)
private String usernameNormalized;
@Column(name = "client_ip", nullable = false, length = 64)
private String clientIp;
@Column(name = "failure_count", nullable = false)
private int failureCount;
@Column(name = "window_started_at", nullable = false)
private Instant windowStartedAt;
@Column(name = "last_failure_at")
private Instant lastFailureAt;
@Column(name = "blocked_until")
private Instant blockedUntil;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() {
return id;
}
public int getFailureCount() {
return failureCount;
}
public void setFailureCount(int failureCount) {
this.failureCount = failureCount;
}
public Instant getWindowStartedAt() {
return windowStartedAt;
}
public void setWindowStartedAt(Instant windowStartedAt) {
this.windowStartedAt = windowStartedAt;
}
public Instant getLastFailureAt() {
return lastFailureAt;
}
public void setLastFailureAt(Instant lastFailureAt) {
this.lastFailureAt = lastFailureAt;
}
public Instant getBlockedUntil() {
return blockedUntil;
}
public void setBlockedUntil(Instant blockedUntil) {
this.blockedUntil = blockedUntil;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "auth_refresh_tokens")
@@ -31,13 +31,13 @@ public class AuthRefreshToken {
private String tokenHash;
@Column(name = "issued_at", nullable = false)
private LocalDateTime issuedAt;
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
private Instant expiresAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
private Instant revokedAt;
@Column(name = "rotated_to_token_hash", length = 64)
private String rotatedToTokenHash;
@@ -80,27 +80,27 @@ public class AuthRefreshToken {
this.tokenHash = tokenHash;
}
public LocalDateTime getIssuedAt() {
public Instant getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(LocalDateTime issuedAt) {
public void setIssuedAt(Instant issuedAt) {
this.issuedAt = issuedAt;
}
public LocalDateTime getExpiresAt() {
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public LocalDateTime getRevokedAt() {
public Instant getRevokedAt() {
return revokedAt;
}
public void setRevokedAt(LocalDateTime revokedAt) {
public void setRevokedAt(Instant revokedAt) {
this.revokedAt = revokedAt;
}
@@ -128,7 +128,7 @@ public class AuthRefreshToken {
this.ipAddress = ipAddress;
}
public boolean isActive(LocalDateTime now) {
public boolean isActive(Instant now) {
return revokedAt == null && expiresAt.isAfter(now);
}
}

View File

@@ -5,7 +5,8 @@ import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
@MappedSuperclass
public abstract class LifecycleEntity {
@@ -23,7 +24,7 @@ public abstract class LifecycleEntity {
private LocalDate activeTo;
@Column(name = "archived_at")
private LocalDateTime archivedAt;
private Instant archivedAt;
@Column(name = "archive_reason")
private String archiveReason;
@@ -52,11 +53,11 @@ public abstract class LifecycleEntity {
this.activeTo = activeTo;
}
public LocalDateTime getArchivedAt() {
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(LocalDateTime archivedAt) {
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}
@@ -92,9 +93,13 @@ public abstract class LifecycleEntity {
}
public void archive(String reason) {
archive(reason, LocalDate.now(ZoneId.of("Europe/Moscow")), Instant.now());
}
public void archive(String reason, LocalDate businessDate, Instant archiveMoment) {
status = STATUS_ARCHIVED;
archivedAt = LocalDateTime.now();
activeTo = LocalDate.now();
archivedAt = archiveMoment;
activeTo = businessDate;
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
}

View File

@@ -3,7 +3,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "schedule_overrides")
@@ -45,7 +45,7 @@ public class ScheduleOverride {
private Long createdBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
public Long getId() {
return id;
@@ -123,11 +123,11 @@ public class ScheduleOverride {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 200)
@Column(nullable = false, length = 200)
private String name;
@Column(name = "code")

View File

@@ -2,7 +2,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "subject_comments")
@@ -24,7 +24,7 @@ public class SubjectComment {
private String comment;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
public Long getId() {
return id;
@@ -54,11 +54,11 @@ public class SubjectComment {
this.comment = comment;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -2,7 +2,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "teacher_creation_requests")
@@ -36,16 +36,16 @@ public class TeacherCreationRequest {
private Long requestedBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
private Instant updatedAt = Instant.now();
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
private Instant reviewedAt;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@@ -55,7 +55,7 @@ public class TeacherCreationRequest {
@PreUpdate
public void touch() {
updatedAt = LocalDateTime.now();
updatedAt = Instant.now();
}
public Long getId() {
@@ -118,19 +118,19 @@ public class TeacherCreationRequest {
this.requestedBy = requestedBy;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
@@ -142,11 +142,11 @@ public class TeacherCreationRequest {
this.reviewedBy = reviewedBy;
}
public LocalDateTime getReviewedAt() {
public Instant getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(LocalDateTime reviewedAt) {
public void setReviewedAt(Instant reviewedAt) {
this.reviewedAt = reviewedAt;
}

View File

@@ -3,7 +3,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "teacher_department_assignments")
@@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment {
private String comment;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
private Instant createdAt = Instant.now();
@Column(name = "created_by")
private Long createdBy;
@@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment {
this.comment = comment;
}
public LocalDateTime getCreatedAt() {
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}

View File

@@ -7,6 +7,7 @@ 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;
@@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCal
@Param("date") LocalDate date
);
@Query("""
select day
from AcademicCalendarDay day
join fetch day.academicCalendar
join fetch day.activityType
where day.academicCalendar.id in :calendarIds
and day.date between :startDate and :endDate
order by day.academicCalendar.id, day.courseNumber, day.date
""")
List<AcademicCalendarDay> findForScheduleBatch(
@Param("calendarIds") Collection<Long> calendarIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
boolean existsByActivityTypeId(Long activityTypeId);
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
@Query("""
select case when count(day) > 0 then true else false end
from AcademicCalendarDay day
where day.academicCalendar.id = :calendarId
and (day.date < :startDate or day.date > :endDate)
""")
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Modifying
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
void deleteByCalendarId(@Param("calendarId") Long calendarId);

View File

@@ -1,7 +1,9 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendar;
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;
@@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
where calendar.id = :id
""")
Optional<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select calendar
from AcademicCalendar calendar
join fetch calendar.academicYear
join fetch calendar.speciality
join fetch calendar.specialtyProfile
join fetch calendar.studyForm
where calendar.id = :id
""")
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
}

View File

@@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository<Academi
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
void deleteByAcademicCalendar_Id(Long calendarId);
boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber);
}

View File

@@ -1,11 +1,44 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicYear;
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.Optional;
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
Optional<AcademicYear> findByTitle(String title);
boolean existsByTitle(String title);
boolean existsByTitleAndIdNot(String title, Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select year from AcademicYear year where year.id = :id")
Optional<AcademicYear> findByIdForUpdate(@Param("id") Long id);
@Query("""
select case when count(year) > 0 then true else false end
from AcademicYear year
where year.startDate <= :endDate
and year.endDate >= :startDate
""")
boolean existsOverlapping(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Query("""
select case when count(year) > 0 then true else false end
from AcademicYear year
where year.id <> :excludedId
and year.startDate <= :endDate
and year.endDate >= :startDate
""")
boolean existsOverlappingExcluding(@Param("excludedId") Long excludedId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}

View File

@@ -0,0 +1,31 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthLoginAttemptAudit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
public interface AuthLoginAttemptAuditRepository extends JpaRepository<AuthLoginAttemptAudit, Long> {
@Modifying
@Transactional
@Query(value = """
WITH cleanup_candidates AS (
SELECT id
FROM auth_login_attempt_audit
WHERE occurred_at < :cutoff
ORDER BY occurred_at, id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
DELETE FROM auth_login_attempt_audit audit
USING cleanup_candidates candidate
WHERE audit.id = candidate.id
""", nativeQuery = true)
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
@Param("batchSize") int batchSize);
}

View File

@@ -0,0 +1,61 @@
package com.magistr.app.repository;
import com.magistr.app.model.AuthLoginRateLimit;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
public interface AuthLoginRateLimitRepository extends JpaRepository<AuthLoginRateLimit, Long> {
@Modifying(flushAutomatically = true)
@Query(value = """
INSERT INTO auth_login_rate_limits (
tenant, username_normalized, client_ip, failure_count,
window_started_at, updated_at
) VALUES (:tenant, :username, :clientIp, 0, :now, :now)
ON CONFLICT (tenant, username_normalized, client_ip) DO NOTHING
""", nativeQuery = true)
int ensureExists(@Param("tenant") String tenant,
@Param("username") String username,
@Param("clientIp") String clientIp,
@Param("now") Instant now);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT limit
FROM AuthLoginRateLimit limit
WHERE limit.tenant = :tenant
AND limit.usernameNormalized = :username
AND limit.clientIp = :clientIp
""")
Optional<AuthLoginRateLimit> findForUpdate(@Param("tenant") String tenant,
@Param("username") String username,
@Param("clientIp") String clientIp);
@Modifying
@Transactional
@Query(value = """
WITH cleanup_candidates AS (
SELECT id
FROM auth_login_rate_limits
WHERE updated_at < :cutoff
AND (blocked_until IS NULL OR blocked_until <= :now)
ORDER BY updated_at, id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
DELETE FROM auth_login_rate_limits rate_limit
USING cleanup_candidates candidate
WHERE rate_limit.id = candidate.id
""", nativeQuery = true)
int deleteStaleBatch(@Param("cutoff") Instant cutoff,
@Param("now") Instant now,
@Param("batchSize") int batchSize);
}

View File

@@ -4,9 +4,12 @@ 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.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
@@ -21,4 +24,27 @@ public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshTok
WHERE token.tokenHash = :tokenHash
""")
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
/**
* Удаляет ограниченную пачку старых audit-записей. SKIP LOCKED позволяет нескольким
* backend-pod безопасно разбирать непересекающиеся пачки одной tenant-БД.
*/
@Modifying
@Transactional
@Query(value = """
WITH cleanup_candidates AS (
SELECT id
FROM auth_refresh_tokens
WHERE expires_at < :cutoff
OR (revoked_at IS NOT NULL AND revoked_at < :cutoff)
ORDER BY LEAST(expires_at, COALESCE(revoked_at, expires_at)), id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
DELETE FROM auth_refresh_tokens token
USING cleanup_candidates candidate
WHERE token.id = candidate.id
""", nativeQuery = true)
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
@Param("batchSize") int batchSize);
}

View File

@@ -1,9 +1,14 @@
package com.magistr.app.repository;
import com.magistr.app.model.StudentGroup;
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 GroupRepository extends JpaRepository<StudentGroup, Long> {
@@ -14,4 +19,15 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
List<StudentGroup> findByStatusNot(String status);
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select studentGroup
from StudentGroup studentGroup
join fetch studentGroup.educationForm
join fetch studentGroup.speciality
join fetch studentGroup.specialtyProfile
where studentGroup.id = :id
""")
Optional<StudentGroup> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.Collection;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
@@ -60,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
@Param("semesterId") Long semesterId
);
@Query("""
select distinct r
from ScheduleRule r
join r.groups matchingGroup
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.groups groups
left join fetch r.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 matchingGroup.id in :groupIds
and sem.id in :semesterIds
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdsAndSemesterIds(
@Param("groupIds") Collection<Long> groupIds,
@Param("semesterIds") Collection<Long> semesterIds
);
@Query("""
select distinct r
from ScheduleRule r
join r.slots teacherSlot
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.groups groups
left join fetch r.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 teacherSlot.teacher.id = :teacherId
and sem.id in :semesterIds
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterIds(
@Param("teacherId") Long teacherId,
@Param("semesterIds") Collection<Long> semesterIds
);
@Query("""
select distinct r
from ScheduleRule r

View File

@@ -9,4 +9,6 @@ public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSl
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
boolean existsByTimeSlotId(Long timeSlotId);
}

View File

@@ -17,10 +17,51 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
@Query("""
select semester
from Semester semester
join fetch semester.academicYear
where semester.startDate <= :endDate
and semester.endDate >= :startDate
order by semester.startDate, semester.id
""")
List<Semester> findOverlappingForSchedule(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
boolean existsByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
boolean existsByAcademicYearIdAndSemesterTypeAndIdNot(Long academicYearId,
SemesterType semesterType,
Long id);
@Query("""
select case when count(semester) > 0 then true else false end
from Semester semester
where semester.academicYear.id = :academicYearId
and semester.startDate <= :endDate
and semester.endDate >= :startDate
""")
boolean existsOverlapping(@Param("academicYearId") Long academicYearId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Query("""
select case when count(semester) > 0 then true else false end
from Semester semester
where semester.academicYear.id = :academicYearId
and semester.id <> :excludedId
and semester.startDate <= :endDate
and semester.endDate >= :startDate
""")
boolean existsOverlappingExcluding(@Param("academicYearId") Long academicYearId,
@Param("excludedId") Long excludedId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select semester from Semester semester where semester.id = :id")
Optional<Semester> findByIdForUpdate(@Param("id") Long id);

View File

@@ -1,12 +1,15 @@
package com.magistr.app.repository;
import com.magistr.app.model.StudentGroupCalendarAssignment;
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;
import java.util.Collection;
public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<StudentGroupCalendarAssignment, Long> {
@@ -51,4 +54,52 @@ public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<
@Param("groupId") Long groupId,
@Param("academicYearId") Long academicYearId
);
@Query("""
select assignment
from StudentGroupCalendarAssignment assignment
join fetch assignment.studentGroup
join fetch assignment.academicYear
join fetch assignment.academicCalendar
where assignment.studentGroup.id in :groupIds
and assignment.academicYear.id in :academicYearIds
""")
List<StudentGroupCalendarAssignment> findForScheduleBatch(
@Param("groupIds") Collection<Long> groupIds,
@Param("academicYearIds") Collection<Long> academicYearIds
);
@Query("""
select assignment
from StudentGroupCalendarAssignment assignment
join fetch assignment.studentGroup studentGroup
join fetch studentGroup.educationForm
join fetch studentGroup.speciality
join fetch studentGroup.specialtyProfile
join fetch assignment.academicYear
join fetch assignment.academicCalendar calendar
join fetch calendar.speciality
join fetch calendar.specialtyProfile
join fetch calendar.studyForm
where calendar.id = :calendarId
order by assignment.id
""")
List<StudentGroupCalendarAssignment> findByCalendarIdWithDetails(@Param("calendarId") Long calendarId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select assignment
from StudentGroupCalendarAssignment assignment
join fetch assignment.academicYear
join fetch assignment.academicCalendar calendar
join fetch calendar.speciality
join fetch calendar.specialtyProfile
join fetch calendar.studyForm
where assignment.studentGroup.id = :groupId
and assignment.academicYear.id = :academicYearId
""")
Optional<StudentGroupCalendarAssignment> findByGroupIdAndAcademicYearIdForUpdate(
@Param("groupId") Long groupId,
@Param("academicYearId") Long academicYearId
);
}

View File

@@ -7,7 +7,7 @@ import java.util.List;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
Optional<Subject> findByNameIgnoreCase(String name);
List<Subject> findByDepartmentId(Long departmentId);

View File

@@ -1,13 +1,16 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherDepartmentAssignment;
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.List;
import java.util.Optional;
import java.util.Set;
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
@@ -60,4 +63,46 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.department
where a.teacher.id = :teacherId
and a.primaryAssignment = true
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
order by a.validFrom desc, a.id desc
""")
Optional<TeacherDepartmentAssignment> findPrimaryAtDate(
@Param("teacherId") Long teacherId,
@Param("date") LocalDate date
);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.department
where a.teacher.id = :teacherId
and a.primaryAssignment = true
order by a.validFrom, a.id
""")
List<TeacherDepartmentAssignment> findPrimaryHistoryForUpdate(@Param("teacherId") Long teacherId);
@Query("""
select a
from TeacherDepartmentAssignment a
join fetch a.teacher
join fetch a.department
where a.teacher.id in :teacherIds
and a.validFrom <= :endDate
and (a.validTo is null or a.validTo >= :startDate)
order by a.teacher.id, a.validFrom, a.id
""")
List<TeacherDepartmentAssignment> findForTeachersBetween(
@Param("teacherIds") Set<Long> teacherIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
}

View File

@@ -2,6 +2,8 @@ package com.magistr.app.repository;
import com.magistr.app.model.TimeSlotDateAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
@@ -15,5 +17,15 @@ public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlot
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
@Query("""
select assignment
from TimeSlotDateAssignment assignment
join fetch assignment.timeSlotScope
where assignment.date between :startDate and :endDate
order by assignment.date
""")
List<TimeSlotDateAssignment> findForScheduleRange(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
boolean existsByTimeSlotScopeId(Long scopeId);
}

View File

@@ -1,7 +1,13 @@
package com.magistr.app.repository;
import com.magistr.app.model.TimeSlot;
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.LocalTime;
import java.util.List;
import java.util.Optional;
@@ -14,5 +20,45 @@ public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
@Query("""
select slot
from TimeSlot slot
join fetch slot.timeSlotScope
order by slot.timeSlotScope.displayOrder, slot.orderNumber, slot.startTime
""")
List<TimeSlot> findAllForSchedule();
boolean existsByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
boolean existsByTimeSlotScopeIdAndOrderNumberAndIdNot(Long scopeId, Integer orderNumber, Long id);
boolean existsByTimeSlotScopeId(Long scopeId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select slot from TimeSlot slot where slot.id = :id")
Optional<TimeSlot> findByIdForUpdate(@Param("id") Long id);
@Query("""
select case when count(slot) > 0 then true else false end
from TimeSlot slot
where slot.timeSlotScope.id = :scopeId
and slot.startTime < :endTime
and slot.endTime > :startTime
""")
boolean existsOverlapping(@Param("scopeId") Long scopeId,
@Param("startTime") LocalTime startTime,
@Param("endTime") LocalTime endTime);
@Query("""
select case when count(slot) > 0 then true else false end
from TimeSlot slot
where slot.timeSlotScope.id = :scopeId
and slot.id <> :excludedId
and slot.startTime < :endTime
and slot.endTime > :startTime
""")
boolean existsOverlappingExcluding(@Param("scopeId") Long scopeId,
@Param("excludedId") Long excludedId,
@Param("startTime") LocalTime startTime,
@Param("endTime") LocalTime endTime);
}

View File

@@ -1,7 +1,11 @@
package com.magistr.app.repository;
import com.magistr.app.model.TimeSlotScope;
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;
@@ -15,4 +19,10 @@ public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Lo
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
List<TimeSlotScope> findByApplyModeOrderByDisplayOrderAsc(String applyMode);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select scope from TimeSlotScope scope where scope.id = :id")
Optional<TimeSlotScope> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -2,7 +2,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
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;
@@ -20,4 +24,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByRoleAndStatusNot(Role role, String status);
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from User user where user.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class AcademicDateService {
@@ -33,6 +35,64 @@ public class AcademicDateService {
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date);
}
public List<Semester> findSemesters(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
return List.of();
}
return semesterRepository.findOverlappingForSchedule(startDate, endDate);
}
/**
* Загружает календарные данные одним набором batch-запросов и возвращает снимок,
* который не обращается к репозиториям во внутренних циклах генератора.
*/
public ScheduleSnapshot createScheduleSnapshot(Collection<StudentGroup> groups,
Collection<Semester> semesters,
LocalDate endDate) {
List<Semester> semesterSnapshot = Optional.ofNullable(semesters)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.sorted(Comparator.comparing(Semester::getStartDate).thenComparing(Semester::getId))
.toList();
LocalDate snapshotStart = semesterSnapshot.stream()
.map(Semester::getStartDate)
.min(LocalDate::compareTo)
.orElse(endDate);
Set<Long> groupIds = Optional.ofNullable(groups)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.map(StudentGroup::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> academicYearIds = semesterSnapshot.stream()
.map(Semester::getAcademicYear)
.filter(Objects::nonNull)
.map(AcademicYear::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty()
? List.of()
: assignmentRepository.findForScheduleBatch(groupIds, academicYearIds);
Set<Long> calendarIds = assignments.stream()
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.filter(Objects::nonNull)
.map(AcademicCalendar::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<AcademicCalendarDay> calendarDays = calendarIds.isEmpty()
|| snapshotStart == null
|| endDate == null
|| endDate.isBefore(snapshotStart)
? List.of()
: calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate);
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate);
}
public int getWeekNumber(Semester semester, LocalDate date) {
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
if (days < 0) {
@@ -86,4 +146,128 @@ public class AcademicDateService {
}
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent();
}
public static final class ScheduleSnapshot {
private final Map<LocalDate, Semester> semesterByDate;
private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear;
private final Map<CalendarDayKey, AcademicCalendarActivityType> activityByCalendarCourseAndDate;
private final LocalDate startDate;
private final LocalDate endDate;
private ScheduleSnapshot(Collection<Semester> semesters,
Collection<StudentGroupCalendarAssignment> assignments,
Collection<AcademicCalendarDay> calendarDays,
LocalDate startDate,
LocalDate endDate) {
this.startDate = startDate;
this.endDate = endDate;
this.semesterByDate = indexSemesters(semesters, startDate, endDate);
this.assignmentByGroupAndYear = assignments.stream()
.collect(Collectors.toUnmodifiableMap(
assignment -> new GroupAcademicYearKey(
assignment.getStudentGroup().getId(),
assignment.getAcademicYear().getId()
),
Function.identity()
));
this.activityByCalendarCourseAndDate = calendarDays.stream()
.collect(Collectors.toUnmodifiableMap(
day -> new CalendarDayKey(
day.getAcademicCalendar().getId(),
day.getCourseNumber(),
day.getDate()
),
AcademicCalendarDay::getActivityType
));
}
public Optional<Semester> findSemester(LocalDate date) {
return Optional.ofNullable(semesterByDate.get(date));
}
public boolean hasCalendarAssignment(StudentGroup group, Semester semester) {
return findAssignment(group, semester).isPresent();
}
public Optional<AcademicCalendarActivityType> getActivityType(StudentGroup group,
Semester semester,
LocalDate date) {
int courseNumber = courseNumber(group, date);
if (courseNumber < 1) {
return Optional.empty();
}
return findAssignment(group, semester)
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.map(AcademicCalendar::getId)
.map(calendarId -> activityByCalendarCourseAndDate.get(
new CalendarDayKey(calendarId, courseNumber, date)
));
}
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
return getActivityType(group, semester, date)
.map(AcademicCalendarActivityType::getAllowSchedule)
.orElse(false);
}
public LocalDate startDate() {
return startDate;
}
public LocalDate endDate() {
return endDate;
}
private Optional<StudentGroupCalendarAssignment> findAssignment(StudentGroup group, Semester semester) {
if (group == null
|| group.getId() == null
|| semester == null
|| semester.getAcademicYear() == null
|| semester.getAcademicYear().getId() == null) {
return Optional.empty();
}
return Optional.ofNullable(assignmentByGroupAndYear.get(
new GroupAcademicYearKey(group.getId(), semester.getAcademicYear().getId())
));
}
private static Map<LocalDate, Semester> indexSemesters(Collection<Semester> semesters,
LocalDate startDate,
LocalDate endDate) {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
return Map.of();
}
Map<LocalDate, Semester> result = new HashMap<>();
for (Semester semester : semesters) {
LocalDate effectiveStart = semester.getStartDate().isAfter(startDate)
? semester.getStartDate()
: startDate;
LocalDate effectiveEnd = semester.getEndDate().isBefore(endDate)
? semester.getEndDate()
: endDate;
for (LocalDate date = effectiveStart; !date.isAfter(effectiveEnd); date = date.plusDays(1)) {
Semester previous = result.putIfAbsent(date, semester);
if (previous != null && !Objects.equals(previous.getId(), semester.getId())) {
throw new IllegalStateException("На одну дату назначено несколько семестров");
}
}
}
return Map.copyOf(result);
}
}
private static int courseNumber(StudentGroup group, LocalDate date) {
if (group == null || date == null || group.getYearStartStudy() == null) {
return 0;
}
int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1;
return academicYearStart - group.getYearStartStudy() + 1;
}
private record GroupAcademicYearKey(Long groupId, Long academicYearId) {
}
private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) {
}
}

View File

@@ -0,0 +1,251 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Semester;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.SemesterRepository;
import org.springframework.dao.DataIntegrityViolationException;
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.util.NoSuchElementException;
@Service
public class AcademicPeriodService {
private static final String YEAR_CONFLICT_MESSAGE =
"Название или период учебного года конфликтует с существующим учебным годом";
private static final String SEMESTER_CONFLICT_MESSAGE =
"Тип или период семестра конфликтует с существующим семестром учебного года";
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public AcademicYear createYear(AcademicYearDto request) {
validateYear(request);
String title = request.title().trim();
if (academicYearRepository.existsByTitle(title)
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
AcademicYear year = new AcademicYear();
applyYear(year, title, request.startDate(), request.endDate());
AcademicYear saved = saveYear(year);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public AcademicYear updateYear(Long id, AcademicYearDto request) {
validateYear(request);
AcademicYear year = lockYear(id);
String title = request.title().trim();
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|| academicYearRepository.existsOverlappingExcluding(
id, request.startDate(), request.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
boolean semesterOutsideNewRange = semesterRepository
.findByAcademicYearIdOrderByStartDateAsc(id)
.stream()
.anyMatch(semester -> !contains(
request.startDate(),
request.endDate(),
semester.getStartDate(),
semester.getEndDate()
));
if (semesterOutsideNewRange) {
throw new IllegalArgumentException(
"Новые границы учебного года не включают все его семестры"
);
}
applyYear(year, title, request.startDate(), request.endDate());
AcademicYear saved = saveYear(year);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public void deleteYear(Long id) {
AcademicYear year = lockYear(id);
academicYearRepository.delete(year);
academicYearRepository.flush();
clearScheduleCacheAfterCommit();
}
@Transactional
public Semester createSemester(Long academicYearId, SemesterDto request) {
validateSemester(request);
AcademicYear year = lockYear(academicYearId);
validateSemesterWithinYear(year, request.startDate(), request.endDate());
ensureSemesterAvailable(year.getId(), request, null);
Semester semester = new Semester();
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public Semester updateSemester(Long id, SemesterDto request) {
validateSemester(request);
Semester snapshot = semesterRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
AcademicYear year = lockYear(snapshot.getAcademicYear().getId());
Semester semester = semesterRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
if (!semester.getAcademicYear().getId().equals(year.getId())) {
throw new ScheduleConflictException("Учебный год семестра был изменён конкурентно");
}
validateSemesterWithinYear(year, request.startDate(), request.endDate());
ensureSemesterAvailable(year.getId(), request, id);
applySemester(semester, request);
Semester saved = saveSemester(semester);
clearScheduleCacheAfterCommit();
return saved;
}
private void validateYear(AcademicYearDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные учебного года обязательны");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название учебного года обязательно");
}
validateDateRange(
request.startDate(),
request.endDate(),
"Даты начала и окончания учебного года обязательны",
"Дата окончания учебного года не может быть раньше даты начала"
);
}
private void validateSemester(SemesterDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные семестра обязательны");
}
if (request.semesterType() == null) {
throw new IllegalArgumentException("Тип семестра обязателен");
}
validateDateRange(
request.startDate(),
request.endDate(),
"Даты начала и окончания семестра обязательны",
"Дата окончания семестра не может быть раньше даты начала"
);
}
private void validateDateRange(LocalDate startDate,
LocalDate endDate,
String requiredMessage,
String orderMessage) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException(requiredMessage);
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException(orderMessage);
}
}
private void validateSemesterWithinYear(AcademicYear year,
LocalDate startDate,
LocalDate endDate) {
if (!contains(year.getStartDate(), year.getEndDate(), startDate, endDate)) {
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
}
}
private boolean contains(LocalDate outerStart,
LocalDate outerEnd,
LocalDate innerStart,
LocalDate innerEnd) {
return !innerStart.isBefore(outerStart) && !innerEnd.isAfter(outerEnd);
}
private void ensureSemesterAvailable(Long academicYearId, SemesterDto request, Long currentId) {
boolean duplicateType = currentId == null
? semesterRepository.existsByAcademicYearIdAndSemesterType(
academicYearId, request.semesterType())
: semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
academicYearId, request.semesterType(), currentId);
boolean overlapping = currentId == null
? semesterRepository.existsOverlapping(
academicYearId, request.startDate(), request.endDate())
: semesterRepository.existsOverlappingExcluding(
academicYearId, currentId, request.startDate(), request.endDate());
if (duplicateType || overlapping) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
private AcademicYear lockYear(Long id) {
return academicYearRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Учебный год не найден"));
}
private void applyYear(AcademicYear year,
String title,
LocalDate startDate,
LocalDate endDate) {
year.setTitle(title);
year.setStartDate(startDate);
year.setEndDate(endDate);
}
private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate());
semester.setEndDate(request.endDate());
}
private AcademicYear saveYear(AcademicYear year) {
try {
return academicYearRepository.saveAndFlush(year);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
}
private Semester saveSemester(Semester semester) {
try {
return semesterRepository.saveAndFlush(semester);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
scheduleGeneratorService.clearCache();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
scheduleGeneratorService.clearCache();
}
});
}
}

View File

@@ -0,0 +1,421 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.springframework.dao.DataIntegrityViolationException;
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.util.List;
import java.util.NoSuchElementException;
@Service
public class AcademicStructureService {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final GroupRepository groupRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
GroupRepository groupRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.groupRepository = groupRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request);
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Календарный график не найден"));
CalendarDimensions candidate = resolveCalendarDimensions(request);
List<StudentGroupCalendarAssignment> assignments =
assignmentRepository.findByCalendarIdWithDetails(id);
boolean incompatibleAssignment = assignments.stream()
.anyMatch(assignment -> !isCompatible(
assignment.getStudentGroup(),
assignment.getAcademicYear(),
candidate.academicYear(),
candidate.speciality(),
candidate.profile(),
candidate.studyForm(),
request.courseCount()
));
if (incompatibleAssignment) {
throw new ScheduleConflictException(
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
);
}
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
id, request.courseCount())) {
throw new ScheduleConflictException(
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
);
}
if (calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
id, request.courseCount() * 2)) {
throw new ScheduleConflictException(
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
);
}
if (calendarDayRepository.existsOutsideDateRange(
id,
candidate.academicYear().getStartDate(),
candidate.academicYear().getEndDate())) {
throw new ScheduleConflictException(
"Нельзя изменить учебный год графика: сохранённая сетка содержит даты за его границами"
);
}
calendar.setTitle(request.title().trim());
applyCalendarDimensions(calendar, candidate, request.courseCount());
AcademicCalendar saved = saveCalendar(calendar);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public StudentGroup updateGroup(Long id, CreateGroupRequest request) {
validateGroupRequest(request);
StudentGroup group = groupRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
GroupDimensions candidate = resolveGroupDimensions(request);
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
.anyMatch(assignment -> !isCompatible(
request.getYearStartStudy(),
candidate.speciality(),
candidate.profile(),
candidate.studyForm(),
assignment.getAcademicYear(),
assignment.getAcademicCalendar()
));
if (incompatibleAssignment) {
throw new ScheduleConflictException(
"Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми"
);
}
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(candidate.studyForm());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(candidate.speciality());
group.setSpecialtyProfile(candidate.profile());
StudentGroup saved = saveGroup(group);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public StudentGroupCalendarAssignment saveAssignment(Long groupId,
GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
throw new IllegalArgumentException("Учебный год и календарный график обязательны");
}
StudentGroup group = groupRepository.findByIdForUpdate(groupId)
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(request.calendarId())
.orElseThrow(() -> new IllegalArgumentException("Календарный график не найден"));
AcademicYear academicYear = academicYearRepository.findByIdForUpdate(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
if (!isCompatible(group, academicYear, calendar)) {
throw new IllegalArgumentException(
"Календарный график не соответствует учебному году, параметрам или курсу группы"
);
}
StudentGroupCalendarAssignment assignment = assignmentRepository
.findByGroupIdAndAcademicYearIdForUpdate(groupId, academicYear.getId())
.orElseGet(StudentGroupCalendarAssignment::new);
assignment.setStudentGroup(group);
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
try {
StudentGroupCalendarAssignment saved = assignmentRepository.saveAndFlush(assignment);
clearScheduleCacheAfterCommit();
return saved;
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Назначение календарного графика было изменено конкурентно"
);
}
}
private void validateCalendarRequest(AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название графика обязательно");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null
|| request.specialtyId() == null
|| request.specialtyProfileId() == null
|| request.studyFormId() == null) {
throw new IllegalArgumentException(
"Учебный год, специальность, профиль и форма обучения обязательны"
);
}
}
private void validateGroupRequest(CreateGroupRequest request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные учебной группы");
}
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Название группы обязательно");
}
if (request.getGroupSize() == null) {
throw new IllegalArgumentException("Численность группы обязательна");
}
if (request.getEducationFormId() == null) {
throw new IllegalArgumentException("Форма обучения обязательна");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
throw new IllegalArgumentException("ID кафедры обязателен");
}
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
throw new IllegalArgumentException("Год начала обучения обязателен");
}
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
throw new IllegalArgumentException("Код специальности обязателен");
}
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
throw new IllegalArgumentException("Профиль обучения обязателен");
}
}
private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) {
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = resolveProfile(request.specialtyProfileId(), speciality);
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
return new CalendarDimensions(academicYear, speciality, profile, studyForm);
}
private GroupDimensions resolveGroupDimensions(CreateGroupRequest request) {
EducationForm studyForm = educationFormRepository.findById(request.getEducationFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = resolveProfile(request.getSpecialtyProfileId(), speciality);
return new GroupDimensions(speciality, profile, studyForm);
}
private SpecialtyProfile resolveProfile(Long profileId, Speciality speciality) {
SpecialtyProfile profile = profileRepository.findById(profileId)
.orElseThrow(() -> new IllegalArgumentException(
"Профиль обучения не найден для выбранной специальности"
));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException(
"Профиль обучения не найден для выбранной специальности"
);
}
return profile;
}
private boolean isCompatible(StudentGroup group,
AcademicYear assignmentYear,
AcademicCalendar calendar) {
return isCompatible(
group,
assignmentYear,
calendar.getAcademicYear(),
calendar.getSpeciality(),
calendar.getSpecialtyProfile(),
calendar.getStudyForm(),
calendar.getCourseCount()
);
}
private boolean isCompatible(StudentGroup group,
AcademicYear assignmentYear,
AcademicYear calendarYear,
Speciality calendarSpeciality,
SpecialtyProfile calendarProfile,
EducationForm calendarStudyForm,
Integer courseCount) {
return isCompatible(
group.getYearStartStudy(),
group.getSpeciality(),
group.getSpecialtyProfile(),
group.getEducationForm(),
assignmentYear,
calendarYear,
calendarSpeciality,
calendarProfile,
calendarStudyForm,
courseCount
);
}
private boolean isCompatible(Integer yearStartStudy,
Speciality groupSpeciality,
SpecialtyProfile groupProfile,
EducationForm groupStudyForm,
AcademicYear assignmentYear,
AcademicCalendar calendar) {
return isCompatible(
yearStartStudy,
groupSpeciality,
groupProfile,
groupStudyForm,
assignmentYear,
calendar.getAcademicYear(),
calendar.getSpeciality(),
calendar.getSpecialtyProfile(),
calendar.getStudyForm(),
calendar.getCourseCount()
);
}
private boolean isCompatible(Integer yearStartStudy,
Speciality groupSpeciality,
SpecialtyProfile groupProfile,
EducationForm groupStudyForm,
AcademicYear assignmentYear,
AcademicYear calendarYear,
Speciality calendarSpeciality,
SpecialtyProfile calendarProfile,
EducationForm calendarStudyForm,
Integer courseCount) {
if (!sameId(assignmentYear, calendarYear)
|| !sameId(groupSpeciality, calendarSpeciality)
|| !sameId(groupProfile, calendarProfile)
|| !sameId(groupStudyForm, calendarStudyForm)) {
return false;
}
int course = CourseAndSemesterCalculator.getActualCourse(
yearStartStudy,
assignmentYear.getStartDate()
);
return course >= 1 && courseCount != null && course <= courseCount;
}
private boolean sameId(AcademicYear first, AcademicYear second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private boolean sameId(Speciality first, Speciality second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private boolean sameId(SpecialtyProfile first, SpecialtyProfile second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private boolean sameId(EducationForm first, EducationForm second) {
return first != null && second != null && first.getId().equals(second.getId());
}
private void applyCalendarDimensions(AcademicCalendar calendar,
CalendarDimensions dimensions,
Integer courseCount) {
calendar.setAcademicYear(dimensions.academicYear());
calendar.setSpeciality(dimensions.speciality());
calendar.setSpecialtyProfile(dimensions.profile());
calendar.setStudyForm(dimensions.studyForm());
calendar.setCourseCount(courseCount);
}
private AcademicCalendar saveCalendar(AcademicCalendar calendar) {
try {
return calendarRepository.saveAndFlush(calendar);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Изменение календарного графика конфликтует с сохранёнными данными"
);
}
}
private StudentGroup saveGroup(StudentGroup group) {
try {
return groupRepository.saveAndFlush(group);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Изменение учебной группы конфликтует с сохранёнными данными"
);
}
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
scheduleGeneratorService.clearCache();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
scheduleGeneratorService.clearCache();
}
});
}
private record CalendarDimensions(AcademicYear academicYear,
Speciality speciality,
SpecialtyProfile profile,
EducationForm studyForm) {
}
private record GroupDimensions(Speciality speciality,
SpecialtyProfile profile,
EducationForm studyForm) {
}
}

View File

@@ -0,0 +1,56 @@
package com.magistr.app.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.zone.ZoneRulesException;
import java.util.Objects;
/** Единый источник бизнес-даты и абсолютного времени приложения. */
@Service
public class BusinessTimeService {
public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Moscow");
private final Clock clock;
private final ZoneId zoneId;
@Autowired
public BusinessTimeService(@Value("${app.time.business-zone:Europe/Moscow}") String zoneId) {
this(Clock.systemUTC(), parseZone(zoneId));
}
public BusinessTimeService(Clock clock, ZoneId zoneId) {
this.clock = Objects.requireNonNull(clock, "Часы приложения обязательны");
this.zoneId = Objects.requireNonNull(zoneId, "Часовой пояс бизнес-даты обязателен");
}
public LocalDate today() {
return LocalDate.ofInstant(clock.instant(), zoneId);
}
public Instant now() {
return clock.instant();
}
public ZoneId zoneId() {
return zoneId;
}
public static BusinessTimeService systemDefault() {
return new BusinessTimeService(Clock.systemUTC(), DEFAULT_ZONE);
}
private static ZoneId parseZone(String value) {
try {
return ZoneId.of(value == null || value.isBlank() ? DEFAULT_ZONE.getId() : value.trim());
} catch (ZoneRulesException exception) {
throw new IllegalStateException("Некорректный часовой пояс бизнес-даты: " + value, exception);
}
}
}

View File

@@ -10,6 +10,7 @@ import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.DayOfWeek;
@@ -33,6 +34,7 @@ public class ScheduleGeneratorService {
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final BusinessTimeService businessTime;
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
@@ -41,6 +43,20 @@ public class ScheduleGeneratorService {
TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
this(scheduleRuleRepository, groupRepository, userRepository, academicDateService,
timeSlotRepository, timeSlotScopeRepository, dateAssignmentRepository,
BusinessTimeService.systemDefault());
}
@Autowired
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
UserRepository userRepository,
AcademicDateService academicDateService,
TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository,
BusinessTimeService businessTime) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.groupRepository = groupRepository;
this.userRepository = userRepository;
@@ -48,45 +64,134 @@ public class ScheduleGeneratorService {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
this.businessTime = businessTime;
}
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
validateRange(startDate, endDate);
StudentGroup group = groupRepository.findById(groupId)
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
return buildScheduleForGroupsInternal(List.of(group), startDate, endDate);
}
public List<RenderedLessonDto> buildScheduleForGroups(Collection<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate) {
validateRange(startDate, endDate);
List<StudentGroup> groupSnapshot = Optional.ofNullable(groups)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
StudentGroup::getId,
group -> group,
(first, second) -> first,
LinkedHashMap::new
))
.values()
.stream()
.toList();
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
}
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate) {
if (groups.isEmpty()) {
return List.of();
}
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
if (semesters.isEmpty()) {
return List.of();
}
Set<Long> semesterIds = semesters.stream()
.map(Semester::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> groupIds = groups.stream()
.map(StudentGroup::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
.createScheduleSnapshot(groups, semesters, endDate);
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester = indexRulesByGroupAndSemester(
rules,
groupIds
);
List<RenderedLessonDto> result = new ArrayList<>();
for (StudentGroup group : groups) {
result.addAll(buildScheduleForGroup(
group,
startDate,
endDate,
dateSnapshot,
timeSlotSnapshot,
rulesByGroupAndSemester
));
}
return result;
}
private List<RenderedLessonDto> buildScheduleForGroup(StudentGroup group,
LocalDate startDate,
LocalDate endDate,
AcademicDateService.ScheduleSnapshot dateSnapshot,
TimeSlotSnapshot timeSlotSnapshot,
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester) {
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> warnedAcademicYears = new HashSet<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
if (semesterOpt.isEmpty()) {
continue;
}
Semester semester = semesterOpt.get();
if (!academicDateService.hasCalendarAssignment(group, semester)
if (!dateSnapshot.hasCalendarAssignment(group, semester)
&& warnedAcademicYears.add(semester.getAcademicYear().getId())) {
logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
group.getName(), semester.getAcademicYear().getTitle());
}
if (!academicDateService.isTheoryDay(group, semester, date)) {
if (!dateSnapshot.isTheoryDay(group, semester, date)) {
continue;
}
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
semester.getId(),
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
List<ScheduleRule> rules = rulesByGroupAndSemester.getOrDefault(
new GroupSemesterKey(group.getId(), semester.getId()),
List.of()
);
if (primedSemesterIds.add(semester.getId())) {
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
primeConsumedHoursBeforeRange(
semester,
rules,
startDate,
group,
null,
consumedHoursProgress,
dateSnapshot,
timeSlotSnapshot
);
}
for (ScheduleRule rule : sortRules(rules)) {
processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
processRuleForDate(
rule,
semester,
date,
group,
null,
consumedHoursProgress,
result,
dateSnapshot,
timeSlotSnapshot
);
}
}
@@ -99,28 +204,68 @@ public class ScheduleGeneratorService {
throw new IllegalArgumentException("Преподаватель не найден");
}
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
if (semesters.isEmpty()) {
return List.of();
}
Set<Long> semesterIds = semesters.stream()
.map(Semester::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ScheduleRule> rules = scheduleRuleRepository.findByTeacherIdAndSemesterIds(teacherId, semesterIds);
List<StudentGroup> groups = rules.stream()
.flatMap(rule -> rule.getGroups().stream())
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
StudentGroup::getId,
group -> group,
(first, second) -> first,
LinkedHashMap::new
))
.values()
.stream()
.toList();
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
.createScheduleSnapshot(groups, semesters, endDate);
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
Map<Long, List<ScheduleRule>> rulesBySemester = rules.stream()
.collect(Collectors.groupingBy(rule -> rule.getSemester().getId()));
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
if (semesterOpt.isEmpty()) {
continue;
}
Semester semester = semesterOpt.get();
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
semester.getId(),
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
);
List<ScheduleRule> semesterRules = rulesBySemester.getOrDefault(semester.getId(), List.of());
if (primedSemesterIds.add(semester.getId())) {
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
primeConsumedHoursBeforeRange(
semester,
semesterRules,
startDate,
null,
teacherId,
consumedHoursProgress,
dateSnapshot,
timeSlotSnapshot
);
}
for (ScheduleRule rule : sortRules(rules)) {
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
for (ScheduleRule rule : sortRules(semesterRules)) {
processRuleForDate(
rule,
semester,
date,
null,
teacherId,
consumedHoursProgress,
result,
dateSnapshot,
timeSlotSnapshot
);
}
}
@@ -161,12 +306,48 @@ public class ScheduleGeneratorService {
.toList();
}
private Map<GroupSemesterKey, List<ScheduleRule>> indexRulesByGroupAndSemester(
Collection<ScheduleRule> rules,
Set<Long> requestedGroupIds
) {
Map<GroupSemesterKey, List<ScheduleRule>> result = new HashMap<>();
for (ScheduleRule rule : rules) {
Long semesterId = rule.getSemester().getId();
for (StudentGroup group : rule.getGroups()) {
if (!requestedGroupIds.contains(group.getId())) {
continue;
}
result.computeIfAbsent(
new GroupSemesterKey(group.getId(), semesterId),
ignored -> new ArrayList<>()
).add(rule);
}
}
result.replaceAll((key, value) -> sortRules(value));
return result;
}
private TimeSlotSnapshot loadTimeSlotSnapshot(LocalDate startDate,
LocalDate endDate,
Collection<ScheduleRule> rules) {
if (rules.isEmpty()) {
return TimeSlotSnapshot.empty();
}
return new TimeSlotSnapshot(
dateAssignmentRepository.findForScheduleRange(startDate, endDate),
timeSlotScopeRepository.findByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_WEEKDAY),
timeSlotRepository.findAllForSchedule()
);
}
private void primeConsumedHoursBeforeRange(Semester semester,
List<ScheduleRule> rules,
LocalDate rangeStart,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress) {
Map<String, Integer> consumedHoursProgress,
AcademicDateService.ScheduleSnapshot dateSnapshot,
TimeSlotSnapshot timeSlotSnapshot) {
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
if (!semester.getStartDate().isBefore(endExclusive)) {
return;
@@ -175,7 +356,17 @@ public class ScheduleGeneratorService {
List<ScheduleRule> sortedRules = sortRules(rules);
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
for (ScheduleRule rule : sortedRules) {
processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
processRuleForDate(
rule,
semester,
date,
targetGroup,
targetTeacherId,
consumedHoursProgress,
null,
dateSnapshot,
timeSlotSnapshot
);
}
}
}
@@ -185,19 +376,21 @@ public class ScheduleGeneratorService {
}
private void processRuleForDate(ScheduleRule rule,
LocalDate date,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress,
List<RenderedLessonDto> result) {
Semester semester,
LocalDate date,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress,
List<RenderedLessonDto> result,
AcademicDateService.ScheduleSnapshot dateSnapshot,
TimeSlotSnapshot timeSlotSnapshot) {
if (rule.getLectureAcademicHours() == null
|| rule.getLaboratoryAcademicHours() == null
|| rule.getPracticeAcademicHours() == null) {
return;
}
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
if (semester == null || !Objects.equals(semester.getId(), rule.getSemester().getId())) {
return;
}
if (!rule.getSubject().isActiveOn(date)) {
@@ -207,8 +400,7 @@ public class ScheduleGeneratorService {
return;
}
Semester semester = semesterOpt.get();
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date, dateSnapshot);
if (eligibleGroups.isEmpty()) {
return;
}
@@ -233,7 +425,7 @@ public class ScheduleGeneratorService {
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
continue;
}
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
if (date.isAfter(businessTime.today()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
continue;
}
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
@@ -283,7 +475,7 @@ public class ScheduleGeneratorService {
.orElse(0);
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
totalHours, consumedHours, remainingAfterLesson));
totalHours, consumedHours, remainingAfterLesson, timeSlotSnapshot));
}
for (ActiveLessonScope scope : activeScopes) {
consumedHoursProgress.put(
@@ -294,16 +486,20 @@ public class ScheduleGeneratorService {
}
}
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
private List<StudentGroup> eligibleGroups(ScheduleRule rule,
StudentGroup targetGroup,
Semester semester,
LocalDate date,
AcademicDateService.ScheduleSnapshot dateSnapshot) {
if (targetGroup != null) {
return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
return targetGroup.isActiveOn(date) && dateSnapshot.isTheoryDay(targetGroup, semester, date)
? List.of(targetGroup)
: List.of();
}
return rule.getGroups().stream()
.filter(group -> group.isActiveOn(date))
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
.filter(group -> dateSnapshot.isTheoryDay(group, semester, date))
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
}
@@ -356,6 +552,12 @@ public class ScheduleGeneratorService {
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
}
private record GroupSemesterKey(Long groupId, Long semesterId) {
}
private record ScopeOrderKey(Long scopeId, Integer orderNumber) {
}
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
ScheduleRuleSlot slot,
LocalDate date,
@@ -365,8 +567,9 @@ public class ScheduleGeneratorService {
List<Subgroup> subgroups,
int lessonTypeAcademicHours,
int consumedBeforeLesson,
int remainingAfterLesson) {
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
int remainingAfterLesson,
TimeSlotSnapshot timeSlotSnapshot) {
TimeSlot timeSlot = timeSlotSnapshot.resolve(slot.getTimeSlot(), date);
List<StudentGroup> sortedGroups = groups.stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
@@ -415,23 +618,55 @@ public class ScheduleGeneratorService {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
return baseSlot;
}
return effectiveScope(date)
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
scope.getId(),
baseSlot.getOrderNumber()))
.orElse(baseSlot);
}
private static final class TimeSlotSnapshot {
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()));
private final Map<LocalDate, Long> dateScopeIds;
private final Map<Integer, Long> weekdayScopeIds;
private final Map<ScopeOrderKey, TimeSlot> slotsByScopeAndOrder;
private TimeSlotSnapshot(Collection<TimeSlotDateAssignment> dateAssignments,
Collection<TimeSlotScope> weekdayScopes,
Collection<TimeSlot> timeSlots) {
this.dateScopeIds = dateAssignments.stream()
.collect(Collectors.toUnmodifiableMap(
TimeSlotDateAssignment::getDate,
assignment -> assignment.getTimeSlotScope().getId()
));
this.weekdayScopeIds = weekdayScopes.stream()
.filter(scope -> scope.getDayOfWeek() != null)
.collect(Collectors.toUnmodifiableMap(
TimeSlotScope::getDayOfWeek,
TimeSlotScope::getId,
(first, second) -> first
));
this.slotsByScopeAndOrder = timeSlots.stream()
.collect(Collectors.toUnmodifiableMap(
slot -> new ScopeOrderKey(
slot.getTimeSlotScope().getId(),
slot.getOrderNumber()
),
slot -> slot
));
}
private static TimeSlotSnapshot empty() {
return new TimeSlotSnapshot(List.of(), List.of(), List.of());
}
private TimeSlot resolve(TimeSlot baseSlot, LocalDate date) {
if (baseSlot == null || baseSlot.getOrderNumber() == null || date == null) {
return baseSlot;
}
Long scopeId = Optional.ofNullable(dateScopeIds.get(date))
.orElseGet(() -> weekdayScopeIds.get(date.getDayOfWeek().getValue()));
if (scopeId == null) {
return baseSlot;
}
return slotsByScopeAndOrder.getOrDefault(
new ScopeOrderKey(scopeId, baseSlot.getOrderNumber()),
baseSlot
);
}
}
public static String dayName(DayOfWeek dayOfWeek) {

View File

@@ -41,10 +41,10 @@ public class ScheduleQueryService {
if (date == null) {
throw new IllegalArgumentException("Дата расписания обязательна");
}
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
List<StudentGroup> groups = groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
.toList();
List<RenderedLessonDto> generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date);
return deduplicate(generatedLessons);
}
@@ -85,16 +85,70 @@ public class ScheduleQueryService {
String parity,
LocalDate startDate,
LocalDate endDate) {
return searchInternal(
groupId,
teacherId,
classroomId,
departmentId,
subjectId,
lessonTypeId,
timeSlotId,
parity,
startDate,
endDate,
true
);
}
/**
* Строит расписание для workload-агрегатов без интерактивного ограничения 50 групп.
* Набор поддерживаемых фильтров намеренно сужен до кафедры и временного слота, чтобы
* этот путь не превратился во второй общий поисковый API.
*/
public List<RenderedLessonDto> searchForAggregation(Long departmentId,
Long timeSlotId,
LocalDate startDate,
LocalDate endDate) {
return searchInternal(
null,
null,
null,
departmentId,
null,
null,
timeSlotId,
null,
startDate,
endDate,
false
);
}
private List<RenderedLessonDto> searchInternal(Long groupId,
Long teacherId,
Long classroomId,
Long departmentId,
Long subjectId,
Long lessonTypeId,
Long timeSlotId,
String parity,
LocalDate startDate,
LocalDate endDate,
boolean enforceInteractiveGroupLimit) {
validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons;
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);
generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
List<StudentGroup> groups = resolveGroups(
groupId,
departmentId,
startDate,
endDate,
enforceInteractiveGroupLimit
);
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
}
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate);
@@ -249,7 +303,11 @@ public class ScheduleQueryService {
}
}
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
private List<StudentGroup> resolveGroups(Long groupId,
Long departmentId,
LocalDate startDate,
LocalDate endDate,
boolean enforceInteractiveGroupLimit) {
if (groupId != null) {
return groupRepository.findById(groupId)
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
@@ -265,7 +323,9 @@ public class ScheduleQueryService {
groups = groups.stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList();
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
if (enforceInteractiveGroupLimit
&& departmentId == null
&& groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
}

View File

@@ -0,0 +1,105 @@
package com.magistr.app.service;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@Service
public class SubjectImportService {
private static final int MAX_NAME_LENGTH = 200;
private static final int MAX_CODE_LENGTH = 20;
private final SubjectRepository subjectRepository;
public SubjectImportService(SubjectRepository subjectRepository) {
this.subjectRepository = subjectRepository;
}
@Transactional
public List<Subject> importSubjects(List<CreateSubjectRequest> requests, Long departmentId) {
if (requests == null || requests.isEmpty()) {
throw new IllegalArgumentException("Передайте список дисциплин для загрузки");
}
if (departmentId == null || departmentId <= 0) {
throw new IllegalArgumentException("Кафедра обязательна");
}
List<SubjectCandidate> candidates = normalizeCandidates(requests);
Map<String, Subject> existingByCanonicalName = new LinkedHashMap<>();
for (SubjectCandidate candidate : candidates) {
subjectRepository.findByNameIgnoreCase(candidate.name()).ifPresent(existing -> {
if (!Objects.equals(existing.getDepartmentId(), departmentId)) {
throw new ScheduleConflictException(
"Дисциплина «" + candidate.name() + "» уже принадлежит другой кафедре"
);
}
existingByCanonicalName.put(candidate.canonicalName(), existing);
});
}
List<Subject> subjects = new ArrayList<>(candidates.size());
for (SubjectCandidate candidate : candidates) {
Subject subject = existingByCanonicalName.getOrDefault(
candidate.canonicalName(),
new Subject()
);
subject.setName(candidate.name());
subject.setCode(candidate.code());
subject.setDepartmentId(departmentId);
if (subject.isArchivedRecord()) {
subject.restore();
}
subjects.add(subject);
}
try {
return subjectRepository.saveAllAndFlush(subjects);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Дисциплина с таким названием уже существует"
);
}
}
private List<SubjectCandidate> normalizeCandidates(List<CreateSubjectRequest> requests) {
Map<String, SubjectCandidate> unique = new LinkedHashMap<>();
for (CreateSubjectRequest request : requests) {
if (request == null || request.getName() == null || request.getName().isBlank()) {
continue;
}
String name = request.getName().trim();
String code = trimToNull(request.getCode());
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("Название дисциплины не может быть длиннее 200 символов");
}
if (code != null && code.length() > MAX_CODE_LENGTH) {
throw new IllegalArgumentException("Код дисциплины не может быть длиннее 20 символов");
}
String canonicalName = name.toLowerCase(Locale.ROOT);
unique.put(canonicalName, new SubjectCandidate(canonicalName, name, code));
}
return List.copyOf(unique.values());
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private record SubjectCandidate(String canonicalName, String name, String code) {
}
}

View File

@@ -0,0 +1,234 @@
package com.magistr.app.service;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.model.Department;
import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class TeacherDepartmentService {
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final BusinessTimeService businessTime;
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository) {
this(assignmentRepository, userRepository, departmentRepository,
BusinessTimeService.systemDefault());
}
@Autowired
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
BusinessTimeService businessTime) {
this.assignmentRepository = assignmentRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public List<User> findTeachersForDepartmentAtDate(Long departmentId, LocalDate date) {
if (departmentId == null || date == null) {
return List.of();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
assignmentRepository.findDepartmentTeachersAtDate(departmentId, date).stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(teacher -> teacher.getRole() == Role.TEACHER)
.filter(teacher -> teacher.isActiveOn(date))
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return List.copyOf(teachersById.values());
}
@Transactional(readOnly = true)
public boolean hasAssignmentAtDate(Long teacherId, Long departmentId, LocalDate date) {
return teacherId != null
&& departmentId != null
&& date != null
&& assignmentRepository.existsActiveAssignment(teacherId, departmentId, date);
}
@Transactional(readOnly = true)
public Optional<Long> findPrimaryDepartmentIdAtDate(Long teacherId, LocalDate date) {
if (teacherId == null || date == null) {
return Optional.empty();
}
return assignmentRepository.findPrimaryAtDate(teacherId, date)
.map(TeacherDepartmentAssignment::getDepartment)
.filter(Objects::nonNull)
.map(Department::getId);
}
@Transactional(readOnly = true)
public Map<Long, List<TeacherDepartmentAssignment>> findAssignmentsForTeachersBetween(
Collection<Long> teacherIds,
LocalDate startDate,
LocalDate endDate
) {
if (teacherIds == null || teacherIds.isEmpty() || startDate == null || endDate == null) {
return Map.of();
}
Set<Long> normalizedIds = teacherIds.stream()
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (normalizedIds.isEmpty()) {
return Map.of();
}
return assignmentRepository.findForTeachersBetween(normalizedIds, startDate, endDate).stream()
.collect(Collectors.groupingBy(
assignment -> assignment.getTeacher().getId(),
LinkedHashMap::new,
Collectors.toList()
));
}
public Optional<Long> resolveDepartmentIdAtDate(
Collection<TeacherDepartmentAssignment> assignments,
LocalDate date
) {
if (assignments == null || date == null) {
return Optional.empty();
}
return assignments.stream()
.filter(Objects::nonNull)
.filter(assignment -> isActiveAt(assignment, date))
.filter(assignment -> assignment.getDepartment() != null)
.sorted(Comparator
.comparing(
(TeacherDepartmentAssignment assignment) ->
!Boolean.TRUE.equals(assignment.getPrimaryAssignment())
)
.thenComparing(
TeacherDepartmentAssignment::getValidFrom,
Comparator.nullsLast(Comparator.reverseOrder())
)
.thenComparing(
TeacherDepartmentAssignment::getId,
Comparator.nullsLast(Comparator.reverseOrder())
))
.map(assignment -> assignment.getDepartment().getId())
.findFirst();
}
@Transactional
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
DepartmentTransferRequest request,
LocalDate today,
Long createdBy) {
if (request == null || request.departmentId() == null) {
throw new IllegalArgumentException("Кафедра обязательна");
}
LocalDate operationDate = today == null ? businessTime.today() : today;
LocalDate validFrom = request.validFrom() == null ? operationDate : request.validFrom();
User teacher = userRepository.findByIdForUpdate(teacherId)
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.getRole() != Role.TEACHER) {
throw new IllegalArgumentException("Преподаватель не найден");
}
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя перевести");
}
Department department = departmentRepository.findById(request.departmentId())
.filter(candidate -> !candidate.isArchivedRecord())
.orElseThrow(() -> new IllegalArgumentException("Активная кафедра не найдена"));
List<TeacherDepartmentAssignment> primaryHistory = new ArrayList<>(
assignmentRepository.findPrimaryHistoryForUpdate(teacherId)
);
TeacherDepartmentAssignment sameDate = primaryHistory.stream()
.filter(assignment -> validFrom.equals(assignment.getValidFrom()))
.findFirst()
.orElse(null);
if (sameDate != null) {
sameDate.setDepartment(department);
sameDate.setComment(normalizeComment(request.comment()));
sameDate.setCreatedBy(createdBy);
TeacherDepartmentAssignment saved = saveAssignment(sameDate);
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
return saved;
}
primaryHistory.stream()
.filter(assignment -> isActiveAt(assignment, validFrom))
.findFirst()
.ifPresent(current -> current.setValidTo(validFrom.minusDays(1)));
LocalDate nextValidFrom = primaryHistory.stream()
.map(TeacherDepartmentAssignment::getValidFrom)
.filter(date -> date != null && date.isAfter(validFrom))
.min(LocalDate::compareTo)
.orElse(null);
assignmentRepository.flush();
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setValidTo(nextValidFrom == null ? null : nextValidFrom.minusDays(1));
assignment.setPrimaryAssignment(true);
assignment.setComment(normalizeComment(request.comment()));
assignment.setCreatedBy(createdBy);
TeacherDepartmentAssignment saved = saveAssignment(assignment);
primaryHistory.add(saved);
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
return saved;
}
private void synchronizeLegacyDepartment(User teacher,
List<TeacherDepartmentAssignment> primaryHistory,
LocalDate date) {
resolveDepartmentIdAtDate(primaryHistory, date).ifPresent(departmentId -> {
if (!Objects.equals(teacher.getDepartmentId(), departmentId)) {
teacher.setDepartmentId(departmentId);
userRepository.save(teacher);
}
});
}
private TeacherDepartmentAssignment saveAssignment(TeacherDepartmentAssignment assignment) {
try {
return assignmentRepository.saveAndFlush(assignment);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(
"Период основной кафедры пересекается с другим назначением преподавателя"
);
}
}
private boolean isActiveAt(TeacherDepartmentAssignment assignment, LocalDate date) {
return assignment.getValidFrom() != null
&& !assignment.getValidFrom().isAfter(date)
&& (assignment.getValidTo() == null || !assignment.getValidTo().isBefore(date));
}
private String normalizeComment(String comment) {
return comment == null || comment.isBlank() ? null : comment.trim();
}
}

View File

@@ -0,0 +1,161 @@
package com.magistr.app.service;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.stream.Stream;
@Service
public class TimeSlotService {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final String SLOT_CONFLICT_MESSAGE =
"Номер пары или интервал времени уже занят в выбранной сетке";
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
public TimeSlotService(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
}
@Transactional
public TimeSlot create(TimeSlotDto request) {
validate(request);
TimeSlotScope targetScope = lockScope(request.scopeId());
ensureUniqueAndNonOverlapping(request, null);
TimeSlot timeSlot = new TimeSlot();
apply(timeSlot, targetScope, request);
return save(timeSlot);
}
@Transactional
public TimeSlot update(Long id, TimeSlotDto request) {
validate(request);
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
Long currentScopeId = timeSlot.getTimeSlotScope().getId();
List<Long> scopeIds = Stream.of(currentScopeId, request.scopeId())
.filter(Objects::nonNull)
.distinct()
.sorted()
.toList();
TimeSlotScope targetScope = null;
for (Long scopeId : scopeIds) {
TimeSlotScope lockedScope = lockScope(scopeId);
if (scopeId.equals(request.scopeId())) {
targetScope = lockedScope;
}
}
if (targetScope == null) {
throw new NoSuchElementException("Сетка времени не найдена");
}
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)
&& !APPLY_MODE_DEFAULT.equals(targetScope.getApplyMode())) {
throw new IllegalArgumentException(
"Слот базовой сетки, используемый в правилах расписания, нельзя перенести в другую сетку"
);
}
ensureUniqueAndNonOverlapping(request, id);
apply(timeSlot, targetScope, request);
return save(timeSlot);
}
@Transactional
public void delete(Long id) {
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)) {
throw new IllegalArgumentException(
"Нельзя удалить слот, который используется в правилах расписания"
);
}
try {
timeSlotRepository.delete(timeSlot);
timeSlotRepository.flush();
} catch (DataIntegrityViolationException exception) {
throw new IllegalArgumentException(
"Нельзя удалить слот, который используется в расписании",
exception
);
}
}
private void validate(TimeSlotDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные временного слота обязательны");
}
if (request.orderNumber() == null || request.orderNumber() <= 0) {
throw new IllegalArgumentException("Номер пары должен быть больше нуля");
}
if (request.scopeId() == null) {
throw new IllegalArgumentException("Выберите сетку времени");
}
if (request.startTime() == null || request.endTime() == null) {
throw new IllegalArgumentException("Время начала и окончания обязательно");
}
if (!request.startTime().isBefore(request.endTime())) {
throw new IllegalArgumentException("Время начала должно быть раньше времени окончания");
}
if (Duration.between(request.startTime(), request.endTime()).toMinutes() < 1) {
throw new IllegalArgumentException("Длительность временного слота должна быть не меньше одной минуты");
}
}
private TimeSlotScope lockScope(Long scopeId) {
return timeSlotScopeRepository.findByIdForUpdate(scopeId)
.orElseThrow(() -> new NoSuchElementException("Сетка времени не найдена"));
}
private void ensureUniqueAndNonOverlapping(TimeSlotDto request, Long currentId) {
boolean duplicateOrder = currentId == null
? timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumber(
request.scopeId(), request.orderNumber())
: timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumberAndIdNot(
request.scopeId(), request.orderNumber(), currentId);
boolean overlapping = currentId == null
? timeSlotRepository.existsOverlapping(
request.scopeId(), request.startTime(), request.endTime())
: timeSlotRepository.existsOverlappingExcluding(
request.scopeId(), currentId, request.startTime(), request.endTime());
if (duplicateOrder || overlapping) {
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
}
}
private void apply(TimeSlot timeSlot, TimeSlotScope scope, TimeSlotDto request) {
timeSlot.setOrderNumber(request.orderNumber());
timeSlot.setTimeSlotScope(scope);
timeSlot.setStartTime(request.startTime());
timeSlot.setEndTime(request.endTime());
timeSlot.setDurationMinutes((int) Duration.between(request.startTime(), request.endTime()).toMinutes());
}
private TimeSlot save(TimeSlot timeSlot) {
try {
return timeSlotRepository.saveAndFlush(timeSlot);
} catch (DataIntegrityViolationException exception) {
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
}
}
}

View File

@@ -4,12 +4,13 @@ import com.magistr.app.model.SemesterType;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.ZoneId;
@Service
public class CourseAndSemesterCalculator {
public static int getActualCourse(Integer yearStartStudy) {
return getActualCourse(yearStartStudy, LocalDate.now());
return getActualCourse(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
}
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
@@ -22,7 +23,7 @@ public class CourseAndSemesterCalculator {
}
public static int getActualSemester(Integer yearStartStudy) {
return getActualSemester(yearStartStudy, LocalDate.now());
return getActualSemester(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
}
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {

View File

@@ -10,6 +10,11 @@ spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
spring.jackson.time-zone=UTC
# Единая зона календарных бизнес-дат. Абсолютные timestamps хранятся в UTC.
app.time.business-zone=${BUSINESS_TIME_ZONE:Europe/Moscow}
# Мультитенантность
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
@@ -33,5 +38,24 @@ 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}
app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false}
app.jwt.refresh-cleanup.enabled=${JWT_REFRESH_CLEANUP_ENABLED:true}
app.jwt.refresh-cleanup.retention=${JWT_REFRESH_CLEANUP_RETENTION:30d}
app.jwt.refresh-cleanup.batch-size=${JWT_REFRESH_CLEANUP_BATCH_SIZE:500}
app.jwt.refresh-cleanup.max-batches-per-tenant=${JWT_REFRESH_CLEANUP_MAX_BATCHES:20}
app.jwt.refresh-cleanup.interval-ms=${JWT_REFRESH_CLEANUP_INTERVAL_MS:3600000}
app.jwt.refresh-cleanup.initial-delay-ms=${JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS:60000}
# Защита входа. X-Forwarded-For принимается только от перечисленных proxy-сетей.
app.login-security.max-failures=${LOGIN_RATE_MAX_FAILURES:5}
app.login-security.attempt-window=${LOGIN_RATE_ATTEMPT_WINDOW:15m}
app.login-security.base-block-duration=${LOGIN_RATE_BASE_BLOCK_DURATION:1m}
app.login-security.max-block-duration=${LOGIN_RATE_MAX_BLOCK_DURATION:15m}
app.login-security.audit-cleanup.enabled=${LOGIN_AUDIT_CLEANUP_ENABLED:true}
app.login-security.audit-cleanup.retention=${LOGIN_AUDIT_RETENTION:90d}
app.login-security.audit-cleanup.batch-size=${LOGIN_AUDIT_CLEANUP_BATCH_SIZE:500}
app.login-security.audit-cleanup.max-batches-per-tenant=${LOGIN_AUDIT_CLEANUP_MAX_BATCHES:20}
app.login-security.audit-cleanup.interval-ms=${LOGIN_AUDIT_CLEANUP_INTERVAL_MS:86400000}
app.login-security.audit-cleanup.initial-delay-ms=${LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS:120000}
app.http.trusted-proxy-cidrs=${TRUSTED_PROXY_CIDRS:}
#logging.level.root=DEBUG

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
ALTER TABLE subgroups
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
ON subgroups (group_id, lower(name))
WHERE status <> 'ARCHIVED';

View File

@@ -1,94 +0,0 @@
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;

View File

@@ -1,97 +0,0 @@
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
);

View File

@@ -0,0 +1,51 @@
package com.magistr.app.config.auth;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ClientIpResolverTest {
@Test
void ignoresForwardedHeaderFromUntrustedClient() {
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16");
MockHttpServletRequest request = request("203.0.113.7", "192.0.2.10");
assertThat(resolver.resolve(request)).isEqualTo("203.0.113.7");
}
@Test
void selectsFirstUntrustedAddressFromRightBehindTrustedProxyChain() {
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16, 172.16.0.0/12");
MockHttpServletRequest request = request(
"10.42.1.15",
"192.0.2.99, 198.51.100.23, 172.18.0.4"
);
assertThat(resolver.resolve(request)).isEqualTo("198.51.100.23");
}
@Test
void rejectsEntireForwardedHeaderWhenItContainsInvalidAddress() {
ClientIpResolver resolver = new ClientIpResolver("10.42.0.0/16");
MockHttpServletRequest request = request("10.42.1.15", "198.51.100.23, attacker.example");
assertThat(resolver.resolve(request)).isEqualTo("10.42.1.15");
}
@Test
void rejectsInvalidTrustedProxyConfigurationAtStartup() {
assertThatThrownBy(() -> new ClientIpResolver("10.42.0.0/99"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Некорректная маска");
}
private MockHttpServletRequest request(String remoteAddress, String forwardedFor) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr(remoteAddress);
request.addHeader("X-Forwarded-For", forwardedFor);
return request;
}
}

View File

@@ -0,0 +1,200 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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.sql.Timestamp;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
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 = LoginRateLimitConcurrencyIntegrationTest.TestApplication.class,
properties = "management.endpoint.health.validate-group-membership=false"
)
class LoginRateLimitConcurrencyIntegrationTest {
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
@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 AuthLoginRateLimitRepository rateLimitRepository;
@Autowired
private AuthLoginAttemptAuditRepository auditRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
private ExecutorService executor;
@BeforeEach
void clearLoginSecurityTables() {
auditRepository.deleteAll();
rateLimitRepository.deleteAll();
}
@AfterEach
void stopExecutor() throws InterruptedException {
if (executor != null) {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
@Test
void twoServiceInstancesShareAtomicPostgreSqlCounter() throws Exception {
LoginSecurityProperties properties = new LoginSecurityProperties();
properties.setMaxFailures(2);
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4);
LoginRateLimitService firstPod = service(properties, encoder);
LoginRateLimitService secondPod = service(properties, encoder);
String username = "missing-" + UUID.randomUUID();
String clientIp = "198.51.100.42";
executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<LoginRateLimitService.AuthenticationDecision>> futures = List.of(
executor.submit(() -> authenticateAfterSignal(firstPod, username, clientIp, ready, start)),
executor.submit(() -> authenticateAfterSignal(secondPod, username, clientIp, ready, start))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<LoginRateLimitService.AuthenticationDecision.Status> statuses = List.of(
futures.get(0).get(10, TimeUnit.SECONDS).status(),
futures.get(1).get(10, TimeUnit.SECONDS).status()
);
assertThat(statuses).containsExactlyInAnyOrder(
LoginRateLimitService.AuthenticationDecision.Status.REJECTED,
LoginRateLimitService.AuthenticationDecision.Status.BLOCKED
);
Integer failureCount = jdbcTemplate.queryForObject("""
SELECT failure_count
FROM auth_login_rate_limits
WHERE tenant = ? AND username_normalized = ? AND client_ip = ?
""", Integer.class, "tenant", username, clientIp);
assertThat(failureCount).isEqualTo(2);
assertThat(auditRepository.count()).isEqualTo(2);
}
@Test
void cleanupDeletesOnlyExpiredAuditAndInactiveCounters() {
Instant now = NOW;
Instant cutoff = now.minus(90, java.time.temporal.ChronoUnit.DAYS);
jdbcTemplate.update("""
INSERT INTO auth_login_attempt_audit
(tenant, username_normalized, client_ip, outcome, occurred_at)
VALUES
('tenant', 'old', '198.51.100.1', 'FAILURE', ?),
('tenant', 'fresh', '198.51.100.2', 'FAILURE', ?)
""", timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)));
jdbcTemplate.update("""
INSERT INTO auth_login_rate_limits
(tenant, username_normalized, client_ip, failure_count,
window_started_at, blocked_until, updated_at)
VALUES
('tenant', 'old', '198.51.100.1', 1, ?, NULL, ?),
('tenant', 'blocked', '198.51.100.2', 5, ?, ?, ?),
('tenant', 'fresh', '198.51.100.3', 1, ?, NULL, ?)
""",
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.plusSeconds(300)),
timestamp(now.minus(91, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)),
timestamp(now.minus(1, java.time.temporal.ChronoUnit.DAYS)));
assertThat(auditRepository.deleteCleanupBatch(cutoff, 100)).isEqualTo(1);
assertThat(rateLimitRepository.deleteStaleBatch(cutoff, now, 100)).isEqualTo(1);
assertThat(auditRepository.count()).isEqualTo(1);
assertThat(rateLimitRepository.count()).isEqualTo(2);
}
private Timestamp timestamp(Instant value) {
return Timestamp.from(value);
}
private LoginRateLimitService.AuthenticationDecision authenticateAfterSignal(
LoginRateLimitService service,
String username,
String clientIp,
CountDownLatch ready,
CountDownLatch start
) throws InterruptedException {
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Не удалось синхронно запустить попытки входа");
}
return transactionTemplate.execute(status ->
service.authenticate("tenant", username, "неверный-пароль", clientIp)
);
}
private LoginRateLimitService service(LoginSecurityProperties properties,
BCryptPasswordEncoder encoder) {
return new LoginRateLimitService(
rateLimitRepository,
auditRepository,
userRepository,
encoder,
properties,
Clock.fixed(NOW, ZoneOffset.UTC)
);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = AuthLoginRateLimit.class)
@EnableJpaRepositories(basePackageClasses = AuthLoginRateLimitRepository.class)
static class TestApplication {
}
}

View File

@@ -0,0 +1,149 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LoginRateLimitServiceTest {
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
private AuthLoginRateLimitRepository rateLimitRepository;
private AuthLoginAttemptAuditRepository auditRepository;
private UserRepository userRepository;
private BCryptPasswordEncoder passwordEncoder;
private LoginSecurityProperties properties;
@BeforeEach
void setUp() {
rateLimitRepository = mock(AuthLoginRateLimitRepository.class);
auditRepository = mock(AuthLoginAttemptAuditRepository.class);
userRepository = mock(UserRepository.class);
passwordEncoder = new BCryptPasswordEncoder(4);
properties = new LoginSecurityProperties();
}
@Test
void fifthFailureBlocksKeyAndWritesAuditWithoutPassword() {
AuthLoginRateLimit state = state(4, NOW, null);
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
.thenReturn(Optional.of(state));
when(userRepository.findByUsername("Admin")).thenReturn(Optional.empty());
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "Admin", "секретный-пароль", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
assertThat(decision.retryAfterSeconds()).isEqualTo(60);
assertThat(state.getFailureCount()).isEqualTo(5);
assertThat(state.getBlockedUntil()).isEqualTo(NOW.plusSeconds(60));
ArgumentCaptor<AuthLoginAttemptAudit> auditCaptor =
ArgumentCaptor.forClass(AuthLoginAttemptAudit.class);
verify(auditRepository).save(auditCaptor.capture());
assertThat(auditCaptor.getValue().getOutcome()).isEqualTo("BLOCKED");
assertThat(auditCaptor.getValue().getRetryAfterSeconds()).isEqualTo(60);
assertThat(auditCaptor.getValue().getUsernameNormalized()).isEqualTo("admin");
}
@Test
void activeBlockDoesNotQueryUserAndReturnsRemainingDelay() {
AuthLoginRateLimit state = state(5, NOW, NOW.plusSeconds(37));
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
.thenReturn(Optional.of(state));
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "admin", "любой", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
assertThat(decision.retryAfterSeconds()).isEqualTo(37);
verify(userRepository, never()).findByUsername(any());
}
@Test
void archivedUserIsRejectedLikeUnknownUser() {
AuthLoginRateLimit state = state(0, NOW, null);
when(rateLimitRepository.findForUpdate("tenant", "archived", "198.51.100.5"))
.thenReturn(Optional.of(state));
User user = new User();
user.setPassword(passwordEncoder.encode("верный-пароль"));
user.archive("Тест");
when(userRepository.findByUsername("archived")).thenReturn(Optional.of(user));
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "archived", "верный-пароль", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.REJECTED);
assertThat(state.getFailureCount()).isEqualTo(1);
}
@Test
void successfulLoginRemovesPreviousFailureState() {
AuthLoginRateLimit state = state(2, NOW, null);
when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
.thenReturn(Optional.of(state));
User user = new User();
user.setPassword(passwordEncoder.encode("верный-пароль"));
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
LoginRateLimitService service = service();
LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
"tenant", "admin", "верный-пароль", "198.51.100.5"
);
assertThat(decision.status())
.isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.AUTHENTICATED);
assertThat(decision.user()).isSameAs(user);
verify(rateLimitRepository).delete(state);
verify(auditRepository, never()).save(any());
}
private LoginRateLimitService service() {
return new LoginRateLimitService(
rateLimitRepository,
auditRepository,
userRepository,
passwordEncoder,
properties,
Clock.fixed(NOW, ZoneOffset.UTC)
);
}
private AuthLoginRateLimit state(int failureCount,
Instant windowStartedAt,
Instant blockedUntil) {
AuthLoginRateLimit state = new AuthLoginRateLimit();
state.setFailureCount(failureCount);
state.setWindowStartedAt(windowStartedAt);
state.setBlockedUntil(blockedUntil);
return state;
}
}

View File

@@ -0,0 +1,195 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.*;
@DisplayName("Tenant-aware очистка refresh-сессий")
class RefreshTokenCleanupJobTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Moscow");
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-18T09:00:00Z"), ZONE);
private static final Instant NOW = CLOCK.instant();
private static final Duration RETENTION = Duration.ofDays(30);
private ExecutorService executor;
@AfterEach
void cleanup() throws InterruptedException {
TenantContext.clear();
if (executor != null) {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
@Test
@DisplayName("два pod удаляют старые строки идемпотентно и сохраняют active/fresh audit")
void twoPodsKeepActiveAndFreshAuditRows() throws Exception {
TenantRoutingDataSource routingDataSource = routing("alpha", "beta");
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
TokenStore store = new TokenStore("alpha", "beta");
when(repository.deleteCleanupBatch(any(Instant.class), anyInt()))
.thenAnswer(invocation -> store.deleteBatch(
TenantContext.getCurrentTenant(),
invocation.getArgument(0),
invocation.getArgument(1)
));
RefreshTokenCleanupJob firstPod = job(routingDataSource, repository, true);
RefreshTokenCleanupJob secondPod = job(routingDataSource, repository, true);
executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
Future<RefreshTokenCleanupJob.CleanupSummary> first = executor.submit(() -> {
start.await(5, TimeUnit.SECONDS);
return firstPod.cleanupNow();
});
Future<RefreshTokenCleanupJob.CleanupSummary> second = executor.submit(() -> {
start.await(5, TimeUnit.SECONDS);
return secondPod.cleanupNow();
});
start.countDown();
var firstSummary = first.get(5, TimeUnit.SECONDS);
var secondSummary = second.get(5, TimeUnit.SECONDS);
assertThat(firstSummary.deletedCount() + secondSummary.deletedCount()).isEqualTo(4);
assertThat(firstSummary.failedTenantCount() + secondSummary.failedTenantCount()).isZero();
assertThat(store.labels("alpha")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
assertThat(store.labels("beta")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
var repeated = firstPod.cleanupNow();
assertThat(repeated.deletedCount()).isZero();
assertThat(store.labels("alpha")).hasSize(3);
assertThat(store.labels("beta")).hasSize(3);
}
@Test
@DisplayName("повторный локальный запуск пропускается, пока текущий проход не завершён")
void overlappingRunInsidePodIsSkipped() throws Exception {
TenantRoutingDataSource routingDataSource = routing("alpha");
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
CountDownLatch enteredRepository = new CountDownLatch(1);
CountDownLatch releaseRepository = new CountDownLatch(1);
when(repository.deleteCleanupBatch(any(Instant.class), anyInt())).thenAnswer(invocation -> {
enteredRepository.countDown();
if (!releaseRepository.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Тест не разрешил завершить очистку");
}
return 0;
});
RefreshTokenCleanupJob job = job(routingDataSource, repository, true);
executor = Executors.newSingleThreadExecutor();
Future<RefreshTokenCleanupJob.CleanupSummary> running = executor.submit(job::cleanupNow);
assertThat(enteredRepository.await(5, TimeUnit.SECONDS)).isTrue();
var overlapping = job.cleanupNow();
assertThat(overlapping.skippedBecauseAlreadyRunning()).isTrue();
releaseRepository.countDown();
assertThat(running.get(5, TimeUnit.SECONDS).skippedBecauseAlreadyRunning()).isFalse();
}
@Test
@DisplayName("отключённое расписание не обращается к tenant-БД")
void disabledScheduleDoesNothing() {
TenantRoutingDataSource routingDataSource = routing("alpha");
AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
RefreshTokenCleanupJob job = job(routingDataSource, repository, false);
job.cleanupScheduled();
verifyNoInteractions(repository);
}
private RefreshTokenCleanupJob job(TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
boolean enabled) {
return new RefreshTokenCleanupJob(
routingDataSource,
repository,
enabled,
RETENTION,
2,
10,
CLOCK
);
}
private TenantRoutingDataSource routing(String... tenants) {
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
Map<String, TenantConfig> configs = new LinkedHashMap<>();
for (String tenant : tenants) {
configs.put(tenant, new TenantConfig(tenant, tenant, "jdbc:test:" + tenant, "user", "password"));
}
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.copyOf(configs));
return routingDataSource;
}
private static final class TokenStore {
private final Map<String, List<TokenRow>> rowsByTenant = new LinkedHashMap<>();
private TokenStore(String... tenants) {
for (String tenant : tenants) {
rowsByTenant.put(tenant, new ArrayList<>(List.of(
new TokenRow("old-expired", minusDays(NOW, 31), null),
new TokenRow("old-revoked", plusDays(NOW, 7), minusDays(NOW, 31)),
new TokenRow("fresh-expired", minusDays(NOW, 1), null),
new TokenRow("fresh-revoked", plusDays(NOW, 7), minusDays(NOW, 1)),
new TokenRow("active", plusDays(NOW, 7), null)
)));
}
}
private synchronized int deleteBatch(String tenant, Instant cutoff, int batchSize) {
assertThat(tenant).isNotBlank();
List<TokenRow> rows = rowsByTenant.get(tenant);
List<TokenRow> candidates = rows.stream()
.filter(row -> row.expiresAt().isBefore(cutoff)
|| row.revokedAt() != null && row.revokedAt().isBefore(cutoff))
.limit(batchSize)
.toList();
rows.removeAll(candidates);
return candidates.size();
}
private synchronized List<String> labels(String tenant) {
return rowsByTenant.get(tenant).stream().map(TokenRow::label).toList();
}
}
private static Instant minusDays(Instant value, long days) {
return value.minus(days, java.time.temporal.ChronoUnit.DAYS);
}
private static Instant plusDays(Instant value, long days) {
return value.plus(days, java.time.temporal.ChronoUnit.DAYS);
}
private record TokenRow(String label, Instant expiresAt, Instant revokedAt) {
}
}

View File

@@ -28,6 +28,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -131,6 +133,82 @@ class RefreshTokenServiceConcurrencyIntegrationTest {
assertThat(storedTokens).filteredOn(token -> token.getRotatedToTokenHash() != null).hasSize(1);
}
@Test
void concurrentCleanupKeepsActiveAndFreshAuditRows() throws Exception {
User user = createTestUser();
Instant now = Instant.now();
Instant cutoff = now.minus(30, ChronoUnit.DAYS);
AuthRefreshToken oldExpired = token(user, "old-expired", now.minus(31, ChronoUnit.DAYS), null);
AuthRefreshToken oldRevoked = token(user, "old-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(31, ChronoUnit.DAYS));
AuthRefreshToken freshExpired = token(user, "fresh-expired", now.minus(1, ChronoUnit.DAYS), null);
AuthRefreshToken freshRevoked = token(user, "fresh-revoked", now.plus(7, ChronoUnit.DAYS), now.minus(1, ChronoUnit.DAYS));
AuthRefreshToken active = token(user, "active", now.plus(7, ChronoUnit.DAYS), null);
tokenRepository.saveAllAndFlush(List.of(
oldExpired,
oldRevoked,
freshExpired,
freshRevoked,
active
));
List<String> relevantHashes = List.of(
oldExpired.getTokenHash(),
oldRevoked.getTokenHash(),
freshExpired.getTokenHash(),
freshRevoked.getTokenHash(),
active.getTokenHash()
);
executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<Integer>> futures = List.of(
executor.submit(() -> cleanupAfterSignal(cutoff, ready, start)),
executor.submit(() -> cleanupAfterSignal(cutoff, ready, start))
);
awaitReady(ready);
start.countDown();
int deleted = futures.get(0).get(10, TimeUnit.SECONDS)
+ futures.get(1).get(10, TimeUnit.SECONDS);
assertThat(deleted).isEqualTo(2);
List<AuthRefreshToken> remaining = tokenRepository.findAll().stream()
.filter(token -> relevantHashes.contains(token.getTokenHash()))
.toList();
assertThat(remaining)
.extracting(AuthRefreshToken::getTokenHash)
.containsExactlyInAnyOrder(
freshExpired.getTokenHash(),
freshRevoked.getTokenHash(),
active.getTokenHash()
);
assertThat(tokenRepository.deleteCleanupBatch(cutoff, 100)).isZero();
}
private int cleanupAfterSignal(Instant cutoff,
CountDownLatch ready,
CountDownLatch start) throws InterruptedException {
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Не удалось синхронно запустить очистку refresh-сессий");
}
return tokenRepository.deleteCleanupBatch(cutoff, 100);
}
private AuthRefreshToken token(User user,
String marker,
Instant expiresAt,
Instant revokedAt) {
AuthRefreshToken token = new AuthRefreshToken();
token.setUser(user);
token.setTenant(TENANT);
token.setTokenHash(service.hashToken(marker + "-" + UUID.randomUUID()));
token.setIssuedAt(Instant.now().minus(40, ChronoUnit.DAYS));
token.setExpiresAt(expiresAt);
token.setRevokedAt(revokedAt);
return token;
}
private Optional<RefreshTokenRotation> rotateAfterSignal(
String rawToken,
CountDownLatch ready,

View File

@@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@@ -35,7 +36,7 @@ class RefreshTokenServiceTest {
assertThat(stored.getTokenHash()).isNotEqualTo(rawToken);
assertThat(stored.getTokenHash()).hasSize(64);
assertThat(stored.getTenant()).isEqualTo("magistr");
assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now());
assertThat(stored.getExpiresAt()).isAfter(Instant.now());
}
@Test
@@ -96,8 +97,8 @@ class RefreshTokenServiceTest {
token.setUser(user());
token.setTenant("magistr");
token.setTokenHash(hash);
token.setIssuedAt(LocalDateTime.now().minusMinutes(1));
token.setExpiresAt(LocalDateTime.now().plusDays(1));
token.setIssuedAt(Instant.now().minus(1, ChronoUnit.MINUTES));
token.setExpiresAt(Instant.now().plus(1, ChronoUnit.DAYS));
return token;
}

View File

@@ -2,20 +2,30 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.config.auth.ClientIpResolver;
import com.magistr.app.config.auth.LoginRateLimitService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.model.Role;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AuthControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
TenantContext.clear();
}
@Test
@@ -35,4 +45,35 @@ class AuthControllerTest {
assertThat(body).containsKey("departmentId");
assertThat(body.get("departmentId")).isNull();
}
@Test
void loginReturnsTooManyRequestsAndRetryAfterWhenKeyIsBlocked() {
LoginRateLimitService rateLimitService = mock(LoginRateLimitService.class);
ClientIpResolver clientIpResolver = mock(ClientIpResolver.class);
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
LoginRequest loginRequest = new LoginRequest();
loginRequest.setUsername("admin");
loginRequest.setPassword("неверный-пароль");
TenantContext.setCurrentTenant("tenant");
when(clientIpResolver.resolve(servletRequest)).thenReturn("198.51.100.7");
when(rateLimitService.authenticate(
"tenant", "admin", "неверный-пароль", "198.51.100.7"
)).thenReturn(new LoginRateLimitService.AuthenticationDecision(
LoginRateLimitService.AuthenticationDecision.Status.BLOCKED,
null,
73
));
AuthController controller = new AuthController(
rateLimitService, clientIpResolver, null, null, null
);
ResponseEntity<LoginResponse> response = controller.login(loginRequest, servletRequest);
assertThat(response.getStatusCode().value()).isEqualTo(429);
assertThat(response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("73");
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().isSuccess()).isFalse();
assertThat(response.getBody().getMessage())
.isEqualTo("Слишком много попыток входа. Повторите позже");
}
}

View File

@@ -16,6 +16,8 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.SubjectImportService;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -51,7 +53,9 @@ class DepartmentWorkspaceControllerTest {
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
mock(ScheduleQueryService.class),
mock(TeacherDepartmentService.class),
mock(SubjectImportService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -61,14 +65,13 @@ class DepartmentWorkspaceControllerTest {
}
@Test
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
void teachersUseDatedAssignmentsWithoutLegacyFallback() {
UserRepository userRepository = mock(UserRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
when(teacherDepartmentService.findTeachersForDepartmentAtDate(1L, LocalDate.now()))
.thenReturn(List.of(directTeacher, assignedTeacher));
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
@@ -77,13 +80,15 @@ class DepartmentWorkspaceControllerTest {
mock(DepartmentRepository.class),
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
mock(ScheduleQueryService.class),
teacherDepartmentService,
mock(SubjectImportService.class)
);
List<UserResponse> teachers = controller.getTeachers(1L);
assertThat(teachers).extracting(UserResponse::getFullName)
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
.containsExactlyInAnyOrder("Петров Препод Петрович", "Препод Тест Тестович");
}
@Test
@@ -103,7 +108,9 @@ class DepartmentWorkspaceControllerTest {
departmentRepository,
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
mock(ScheduleQueryService.class),
mock(TeacherDepartmentService.class),
mock(SubjectImportService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));

View File

@@ -0,0 +1,119 @@
package com.magistr.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import java.util.Optional;
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.delete;
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.status;
class EducationOfficeRoleMatrixTest {
private static final String TENANT = "tenant-role-matrix";
private static final String TOKEN = "education-office-token";
private MockMvc mockMvc;
@BeforeEach
void setUp() {
TenantContext.clear();
TenantContext.setCurrentTenant(TENANT);
JwtTokenService jwtTokenService = mock(JwtTokenService.class);
when(jwtTokenService.authenticate(TOKEN, TENANT)).thenReturn(Optional.of(
new AuthenticatedUser(10L, "учебный.отдел", Role.EDUCATION_OFFICE, null)
));
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
when(specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of());
when(specialtiesRepository.existsById(1L)).thenReturn(true);
when(profileRepository.findBySpecialityIdOrderByNameAsc(1L)).thenReturn(List.of());
SpecialityController specialityController = new SpecialityController(
specialtiesRepository,
profileRepository
);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
GroupRepository groupRepository = mock(GroupRepository.class);
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
when(educationFormRepository.findAllByOrderByNameAsc()).thenReturn(List.of());
when(educationFormRepository.findByName("Очная")).thenReturn(Optional.empty());
when(educationFormRepository.save(any(EducationForm.class))).thenAnswer(invocation -> {
EducationForm form = invocation.getArgument(0);
form.setId(20L);
return form;
});
when(educationFormRepository.existsById(20L)).thenReturn(true);
when(groupRepository.findByEducationFormId(20L)).thenReturn(List.of());
when(calendarRepository.countByStudyFormId(20L)).thenReturn(0L);
EducationFormController educationFormController = new EducationFormController(
educationFormRepository,
groupRepository,
calendarRepository
);
mockMvc = MockMvcBuilders
.standaloneSetup(specialityController, educationFormController)
.addInterceptors(new AuthorizationInterceptor(jwtTokenService, new ObjectMapper()))
.build();
}
@AfterEach
void clearContext() {
TenantContext.clear();
}
@Test
void educationOfficeInitializesCalendarAndManagesEducationFormsWithoutForbidden() throws Exception {
mockMvc.perform(get("/api/specialties").header("Authorization", bearer()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/specialties/1/profiles").header("Authorization", bearer()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/education-forms").header("Authorization", bearer()))
.andExpect(status().isOk());
mockMvc.perform(post("/api/education-forms")
.header("Authorization", bearer())
.contentType("application/json")
.content("{\"name\":\"Очная\"}"))
.andExpect(status().isOk());
mockMvc.perform(delete("/api/education-forms/20").header("Authorization", bearer()))
.andExpect(status().isOk());
}
@Test
void educationOfficeStillCannotModifySpecialties() throws Exception {
mockMvc.perform(post("/api/specialties")
.header("Authorization", bearer())
.contentType("application/json")
.content("{\"specialityName\":\"Тест\",\"specialityCode\":\"01\"}"))
.andExpect(status().isForbidden());
}
private String bearer() {
return "Bearer " + TOKEN;
}
}

View File

@@ -0,0 +1,111 @@
package com.magistr.app.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.mock.web.MockHttpServletRequest;
import java.sql.SQLException;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Безопасное преобразование нарушений целостности БД")
class GlobalExceptionHandlerDataIntegrityTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
@DisplayName("известный CHECK возвращает русский 400 без JDBC details")
void knownCheckConstraintReturnsSafeBadRequest() {
String technicalMessage = "ERROR: new row violates check constraint "
+ "\"chk_time_slots_time_range\" Detail: failing row contains secret-value";
var response = handler.handleDataIntegrityViolation(
violation(technicalMessage, "23514"),
request("POST", "/api/admin/time-slots")
);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getBody())
.containsEntry("error", "Некорректный запрос")
.containsEntry("message", "Время начала пары должно быть раньше времени окончания");
assertNoTechnicalDetails(response.getBody());
}
@Test
@DisplayName("известный UNIQUE возвращает русский 409 без имени ограничения")
void knownUniqueConstraintReturnsSafeConflict() {
String technicalMessage = "duplicate key value violates unique constraint "
+ "\"uq_subjects_name_ci\" Detail: Key (lower(name))=(secret-value) already exists";
var response = handler.handleDataIntegrityViolation(
violation(technicalMessage, "23505"),
request("POST", "/api/subjects")
);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody())
.containsEntry("error", "Конфликт")
.containsEntry("message", "Дисциплина с таким названием уже существует");
assertNoTechnicalDetails(response.getBody());
}
@Test
@DisplayName("неизвестный constraint получает общий 409 без сырого исключения")
void unknownConstraintReturnsGenericSafeConflict() {
String technicalMessage = "duplicate key violates constraint \"tenant_private_constraint\" "
+ "jdbc:postgresql://db/private?password=secret-value";
var response = handler.handleDataIntegrityViolation(
violation(technicalMessage, "23505"),
request("PUT", "/api/private")
);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody())
.containsEntry("error", "Конфликт")
.containsEntry("message", "Такая запись уже существует");
assertNoTechnicalDetails(response.getBody());
}
@Test
@DisplayName("неизвестная integrity-ошибка не превращается в 500 и не раскрывает cause")
void unknownIntegrityFailureReturnsGenericConflict() {
var exception = new DataIntegrityViolationException(
"could not execute statement; jdbc:postgresql://db/private",
new RuntimeException("password=secret-value")
);
var response = handler.handleDataIntegrityViolation(
exception,
request("DELETE", "/api/private/1")
);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody())
.containsEntry("message", "Операция нарушает ограничения целостности данных");
assertNoTechnicalDetails(response.getBody());
}
private DataIntegrityViolationException violation(String message, String sqlState) {
return new DataIntegrityViolationException(message, new SQLException(message, sqlState));
}
private MockHttpServletRequest request(String method, String path) {
MockHttpServletRequest request = new MockHttpServletRequest(method, path);
request.setRequestURI(path);
return request;
}
private void assertNoTechnicalDetails(Map<String, Object> body) {
assertThat(body).isNotNull();
String rendered = body.toString().toLowerCase();
assertThat(rendered)
.doesNotContain("constraint")
.doesNotContain("jdbc:")
.doesNotContain("password")
.doesNotContain("secret-value")
.doesNotContain("sqlstate");
}
}

View File

@@ -0,0 +1,78 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeacherSubjectControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void departmentCanManageTeacherThroughAdditionalActiveAssignment() {
TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
UserRepository userRepository = mock(UserRepository.class);
SubjectRepository subjectRepository = mock(SubjectRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
User teacher = teacher();
Subject subject = new Subject();
subject.setId(20L);
subject.setName("Высшая математика");
subject.setDepartmentId(1L);
when(userRepository.findById(teacher.getId())).thenReturn(Optional.of(teacher));
when(subjectRepository.findById(subject.getId())).thenReturn(Optional.of(subject));
when(teacherDepartmentService.hasAssignmentAtDate(
teacher.getId(),
1L,
LocalDate.now()
)).thenReturn(true);
TeacherSubjectController controller = new TeacherSubjectController(
teacherSubjectRepository,
userRepository,
subjectRepository,
teacherDepartmentService
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 1L));
var response = controller.create(Map.of("userId", teacher.getId(), "subjectId", subject.getId()));
assertThat(response.getStatusCode().value()).isEqualTo(200);
ArgumentCaptor<TeacherSubject> captor = ArgumentCaptor.forClass(TeacherSubject.class);
verify(teacherSubjectRepository).save(captor.capture());
assertThat(captor.getValue().getUserId()).isEqualTo(teacher.getId());
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
}
private User teacher() {
User teacher = new User();
teacher.setId(10L);
teacher.setUsername("teacher");
teacher.setRole(Role.TEACHER);
teacher.setFullName("Петров Пётр Петрович");
teacher.setJobTitle("Доцент");
teacher.setDepartmentId(2L);
return teacher;
}
}

View File

@@ -8,6 +8,7 @@ import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -24,16 +25,19 @@ class UserControllerTest {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
UserController controller = new UserController(
userRepository,
null,
departmentRepository,
assignmentRepository
assignmentRepository,
teacherDepartmentService
);
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
when(teacherDepartmentService.findTeachersForDepartmentAtDate(
org.mockito.ArgumentMatchers.eq(2L),
org.mockito.ArgumentMatchers.any()
))
.thenReturn(List.of(teacher));
when(departmentRepository.findById(2L))
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));

View File

@@ -1,24 +1,87 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.TeacherDepartmentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
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.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
class WorkloadControllerTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void teacherWorkloadUsesDepartmentAtEachLessonDate() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
LocalDate startDate = LocalDate.of(2026, 1, 1);
LocalDate endDate = LocalDate.of(2026, 3, 31);
RenderedLessonDto oldLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 1, 15), 1L);
RenderedLessonDto newLesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 3, 15), 2L);
List<TeacherDepartmentAssignment> assignments = List.of(
assignment(10L, 1L, LocalDate.of(2025, 9, 1), LocalDate.of(2026, 2, 28)),
assignment(10L, 2L, LocalDate.of(2026, 3, 1), null)
);
when(scheduleQueryService.searchForAggregation(any(), any(), eq(startDate), eq(endDate)))
.thenReturn(List.of(oldLesson, newLesson));
when(teacherDepartmentService.findAssignmentsForTeachersBetween(
eq(java.util.Set.of(10L)),
eq(startDate),
eq(endDate)
)).thenReturn(Map.of(10L, assignments));
when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, oldLesson.date()))
.thenReturn(Optional.of(1L));
when(teacherDepartmentService.resolveDepartmentIdAtDate(assignments, newLesson.date()))
.thenReturn(Optional.of(2L));
when(departmentRepository.findById(1L))
.thenReturn(Optional.of(new Department(1L, "Кафедра ИБ", 1L)));
when(departmentRepository.findById(2L))
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
departmentRepository,
teacherDepartmentService
);
authenticate(Role.ADMIN, null);
List<WorkloadSummaryDto> result = controller.teacherWorkload(startDate, endDate, null);
assertThat(result).hasSize(2);
assertThat(result).extracting(WorkloadSummaryDto::departmentId)
.containsExactly(1L, 2L);
assertThat(result).extracting(WorkloadSummaryDto::lessonCount)
.containsOnly(1L);
}
@Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
@@ -26,19 +89,97 @@ class WorkloadControllerTest {
WorkloadController controller = new WorkloadController(
scheduleQueryService,
classroomRepository,
mock(UserRepository.class),
mock(DepartmentRepository.class)
mock(DepartmentRepository.class),
mock(TeacherDepartmentService.class)
);
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
when(scheduleQueryService.searchForAggregation(any(), eq(1L), any(), any()))
.thenReturn(List.of());
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
authenticate(Role.SCHEDULE_VIEWER, null);
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
assertThat(result).extracting("id").containsExactly(3L);
}
@Test
void departmentRoleUsesOwnDepartmentForEveryWorkloadEndpoint() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
classroomRepository,
departmentRepository,
teacherDepartmentService
);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
LocalDate date = LocalDate.of(2026, 5, 15);
when(scheduleQueryService.searchForAggregation(any(), any(), any(), any()))
.thenReturn(List.of());
when(departmentRepository.findAll()).thenReturn(List.of());
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)).thenReturn(List.of());
authenticate(Role.DEPARTMENT, 7L);
controller.teacherWorkload(startDate, endDate, null);
controller.classroomWorkload(startDate, endDate, 7L);
controller.departmentWorkload(startDate, endDate, null);
controller.timeSlotWorkload(startDate, endDate, 7L);
controller.freeClassrooms(date, 3L);
verify(scheduleQueryService, times(4))
.searchForAggregation(eq(7L), isNull(), eq(startDate), eq(endDate));
verify(scheduleQueryService)
.searchForAggregation(eq(7L), eq(3L), eq(date), eq(date));
}
@Test
void departmentRoleCannotRequestAnotherDepartment() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentService.class)
);
authenticate(Role.DEPARTMENT, 7L);
assertThatThrownBy(() -> controller.teacherWorkload(
LocalDate.of(2026, 5, 1),
LocalDate.of(2026, 5, 31),
8L
))
.isInstanceOfSatisfying(ResponseStatusException.class, exception -> {
assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(exception.getReason()).isEqualTo("Нельзя просматривать нагрузку другой кафедры");
});
verifyNoInteractions(scheduleQueryService);
}
@Test
void privilegedRoleKeepsGlobalScope() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentService.class)
);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
when(scheduleQueryService.searchForAggregation(any(), any(), any(), any()))
.thenReturn(List.of());
authenticate(Role.EDUCATION_OFFICE, null);
controller.timeSlotWorkload(startDate, endDate, null);
verify(scheduleQueryService)
.searchForAggregation(isNull(), isNull(), eq(startDate), eq(endDate));
}
private Classroom classroom(Long id, Boolean available) {
Classroom classroom = new Classroom();
classroom.setId(id);
@@ -47,4 +188,36 @@ class WorkloadControllerTest {
classroom.setIsAvailable(available);
return classroom;
}
private RenderedLessonDto lesson(Long teacherId,
String teacherName,
LocalDate date,
Long timeSlotId) {
RenderedLessonDto lesson = mock(RenderedLessonDto.class);
when(lesson.teacherId()).thenReturn(teacherId);
when(lesson.teacherName()).thenReturn(teacherName);
when(lesson.date()).thenReturn(date);
when(lesson.timeSlotId()).thenReturn(timeSlotId);
return lesson;
}
private TeacherDepartmentAssignment assignment(Long teacherId,
Long departmentId,
LocalDate validFrom,
LocalDate validTo) {
User teacher = new User();
teacher.setId(teacherId);
Department department = new Department(departmentId, "Кафедра " + departmentId, departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(validFrom);
assignment.setValidTo(validTo);
assignment.setPrimaryAssignment(true);
return assignment;
}
private void authenticate(Role role, Long departmentId) {
AuthContext.setCurrentUser(new AuthenticatedUser(100L, "test", role, departmentId));
}
}

View File

@@ -0,0 +1,292 @@
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.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.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Инварианты учебных периодов базовой схемы")
class AcademicPeriodInvariantMigrationIntegrationTest {
private static final String YEAR_OVERLAP_CONSTRAINT = "ex_academic_years_no_overlap";
private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")
void migrationEnforcesAcademicPeriodInvariants() throws SQLException {
Flyway flyway = flyway("1");
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
long academicYearId = insertAcademicYear(
connection,
"2026-2027",
LocalDate.of(2026, 7, 1),
LocalDate.of(2027, 6, 30)
);
long autumnId = insertSemester(
connection,
academicYearId,
"autumn",
LocalDate.of(2026, 7, 1),
LocalDate.of(2027, 1, 31)
);
assertSqlState(
() -> insertAcademicYear(
connection,
"2027-overlap",
LocalDate.of(2027, 6, 30),
LocalDate.of(2028, 6, 30)
),
"23P01",
YEAR_OVERLAP_CONSTRAINT
);
assertSqlState(
() -> insertSemester(
connection,
academicYearId,
"spring",
LocalDate.of(2026, 6, 30),
LocalDate.of(2027, 6, 30)
),
"23514",
"Семестр должен полностью находиться в границах учебного года"
);
long springId = insertSemester(
connection,
academicYearId,
"spring",
LocalDate.of(2027, 2, 1),
LocalDate.of(2027, 6, 30)
);
assertSqlState(
() -> updateDate(
connection,
"UPDATE semesters SET start_date = ? WHERE id = ?",
LocalDate.of(2027, 1, 31),
springId
),
"23P01",
SEMESTER_OVERLAP_CONSTRAINT
);
assertSqlState(
() -> updateDate(
connection,
"UPDATE academic_years SET start_date = ? WHERE id = ?",
LocalDate.of(2026, 7, 2),
academicYearId
),
"23514",
"Новые границы учебного года не включают все его семестры"
);
assertThat(autumnId).isPositive();
assertThat(constraintCount(connection, "academic_years", YEAR_OVERLAP_CONSTRAINT)).isOne();
assertThat(constraintCount(connection, "semesters", SEMESTER_OVERLAP_CONSTRAINT)).isOne();
}
}
@Test
@DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся учебных годов")
void migrationProtectsConcurrentAcademicYearWrites() throws Exception {
Flyway flyway = flyway("1");
flyway.clean();
flyway.migrate();
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentYearInsert(
"2090-2091-a",
LocalDate.of(2090, 9, 1),
LocalDate.of(2091, 6, 30),
ready,
start
)),
executor.submit(() -> concurrentYearInsert(
"2091-2092-b",
LocalDate.of(2091, 6, 30),
LocalDate.of(2092, 6, 30),
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
if ("23P01".equals(outcome.sqlState())) {
assertThat(outcome.message()).contains(YEAR_OVERLAP_CONSTRAINT);
}
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private InsertOutcome concurrentYearInsert(String title,
LocalDate startDate,
LocalDate endDate,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertAcademicYear(connection, title, startDate, endDate);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private Flyway flyway(String targetVersion) {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion(targetVersion))
.cleanDisabled(false)
.load();
}
private long insertAcademicYear(Connection connection,
String title,
LocalDate startDate,
LocalDate endDate) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES (?, ?, ?)
RETURNING id
""")) {
statement.setString(1, title);
statement.setDate(2, Date.valueOf(startDate));
statement.setDate(3, Date.valueOf(endDate));
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private long insertSemester(Connection connection,
long academicYearId,
String semesterType,
LocalDate startDate,
LocalDate endDate) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO semesters (
academic_year_id,
semester_type,
start_date,
end_date
) VALUES (?, ?, ?, ?)
RETURNING id
""")) {
statement.setLong(1, academicYearId);
statement.setString(2, semesterType);
statement.setDate(3, Date.valueOf(startDate));
statement.setDate(4, Date.valueOf(endDate));
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void updateDate(Connection connection, String sql, LocalDate value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setDate(1, Date.valueOf(value));
statement.setLong(2, id);
statement.executeUpdate();
}
}
private long constraintCount(Connection connection,
String tableName,
String constraintName) throws SQLException {
return queryLong(
connection,
"SELECT count(*) FROM pg_constraint WHERE conrelid = (?::TEXT)::regclass AND conname = ?",
tableName,
constraintName
);
}
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 void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -0,0 +1,393 @@
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.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.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Инварианты зависимостей календарных графиков базовой схемы")
class AcademicStructureInvariantIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы")
void baselineProtectsCalendarDependencies() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
long groupId = queryLong(
connection,
"SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"
);
long assignmentId = queryLong(
connection,
"SELECT id FROM student_group_calendar_assignments WHERE group_id = ?",
groupId
);
long calendarId = queryLong(
connection,
"SELECT calendar_id FROM student_group_calendar_assignments WHERE id = ?",
assignmentId
);
long otherFormId = queryLong(
connection,
"SELECT id FROM education_forms WHERE name = 'Специалитет'"
);
assertThat(incompatibleAssignmentCount(connection)).isZero();
assertSqlState(
() -> updateLong(
connection,
"UPDATE academic_calendars SET study_form_id = ? WHERE id = ?",
otherFormId,
calendarId
),
"23514",
"сохранённые назначения групп станут несовместимыми"
);
assertSqlState(
() -> updateLong(
connection,
"UPDATE student_groups SET education_form_id = ? WHERE id = ?",
otherFormId,
groupId
),
"23514",
"сохранённые назначения календарных графиков станут несовместимыми"
);
assertSqlState(
() -> updateInt(
connection,
"UPDATE academic_calendars SET course_count = ? WHERE id = ?",
3,
calendarId
),
"23514",
"сохранённая сетка выходит за новые измерения"
);
assertSqlState(
() -> insertOutOfRangeCalendarDay(connection, calendarId),
"23514",
"Строка сетки выходит за измерения календарного графика"
);
assertSqlState(
() -> insertOutOfRangeCalendarSubject(connection, calendarId),
"23514",
"Номер семестра дисциплины выходит за измерения календарного графика"
);
assertThat(queryLong(
connection,
"SELECT count(*) FROM student_group_calendar_assignments WHERE id = ?",
assignmentId
)).isOne();
assertThat(queryLong(
connection,
"SELECT course_count FROM academic_calendars WHERE id = ?",
calendarId
)).isEqualTo(4);
assertThat(incompatibleAssignmentCount(connection)).isZero();
}
}
@Test
@DisplayName("V1 не допускает гонку между назначением графика и изменением параметров группы")
void baselineProtectsConcurrentGroupAndAssignmentWrites() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long groupId;
long calendarId;
long academicYearId;
long otherFormId;
try (Connection connection = POSTGRES.createConnection("")) {
groupId = insertCompatibleGroup(connection);
calendarId = queryLong(connection, """
SELECT calendar.id
FROM academic_calendars calendar
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN specialties specialty ON specialty.id = calendar.specialty_id
JOIN education_forms study_form ON study_form.id = calendar.study_form_id
WHERE academic_year.title = '2025-2026'
AND specialty.specialty_code = '09.03.01'
AND study_form.name = 'Бакалавриат'
ORDER BY calendar.id
LIMIT 1
""");
academicYearId = queryLong(
connection,
"SELECT id FROM academic_years WHERE title = '2025-2026'"
);
otherFormId = queryLong(
connection,
"SELECT id FROM education_forms WHERE name = 'Специалитет'"
);
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
long finalGroupId = groupId;
long finalCalendarId = calendarId;
long finalAcademicYearId = academicYearId;
long finalOtherFormId = otherFormId;
List<Future<WriteOutcome>> futures = List.of(
executor.submit(() -> concurrentGroupUpdate(
finalGroupId,
finalOtherFormId,
ready,
start
)),
executor.submit(() -> concurrentAssignmentInsert(
finalGroupId,
finalAcademicYearId,
finalCalendarId,
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<WriteOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(WriteOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isEqualTo("23514");
assertThat(outcome.message()).containsAnyOf(
"Календарный график не соответствует",
"сохранённые назначения календарных графиков станут несовместимыми"
);
});
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(incompatibleAssignmentCount(connection)).isZero();
assertThat(queryLong(
connection,
"SELECT count(*) FROM student_group_calendar_assignments WHERE group_id = ?",
groupId
)).isLessThanOrEqualTo(1);
}
} finally {
start.countDown();
executor.shutdownNow();
}
}
private WriteOutcome concurrentGroupUpdate(long groupId,
long educationFormId,
CountDownLatch ready,
CountDownLatch start) {
return concurrentWrite(
"UPDATE student_groups SET education_form_id = ? WHERE id = ?",
List.of(educationFormId, groupId),
ready,
start
);
}
private WriteOutcome concurrentAssignmentInsert(long groupId,
long academicYearId,
long calendarId,
CountDownLatch ready,
CountDownLatch start) {
return concurrentWrite(
"""
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
VALUES (?, ?, ?)
""",
List.of(groupId, academicYearId, calendarId),
ready,
start
);
}
private WriteOutcome concurrentWrite(String sql,
List<Long> parameters,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new WriteOutcome(false, null, "Не получен общий сигнал запуска");
}
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < parameters.size(); index++) {
statement.setLong(index + 1, parameters.get(index));
}
statement.executeUpdate();
connection.commit();
return new WriteOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new WriteOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new WriteOutcome(false, null, exception.getMessage());
}
}
private Flyway baselineFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
private long insertCompatibleGroup(Connection connection) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO student_groups (
name,
group_size,
education_form_id,
department_id,
specialty_id,
specialty_profile_id,
year_start_study
)
SELECT
'КОНКУРЕНТНАЯ-ГРУППА',
20,
study_form.id,
department.id,
specialty.id,
profile.id,
2025
FROM education_forms study_form
CROSS JOIN departments department
CROSS JOIN specialties specialty
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
WHERE study_form.name = 'Бакалавриат'
AND department.code = 1
AND specialty.specialty_code = '09.03.01'
AND profile.name = 'Без профиля'
RETURNING id
""")) {
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days (
calendar_id,
course_number,
date,
week_number,
day_of_week,
activity_type_id
) VALUES (?, 5, DATE '2025-09-01', 1, 1, (
SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'
))
""")) {
statement.setLong(1, calendarId);
statement.executeUpdate();
}
}
private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id)
VALUES (?, 9, (SELECT min(id) FROM subjects))
""")) {
statement.setLong(1, calendarId);
statement.executeUpdate();
}
}
private long incompatibleAssignmentCount(Connection connection) throws SQLException {
return queryLong(connection, """
SELECT count(*)
FROM student_group_calendar_assignments assignment
JOIN student_groups student_group ON student_group.id = assignment.group_id
JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
WHERE assignment.academic_year_id <> calendar.academic_year_id
OR student_group.specialty_id <> calendar.specialty_id
OR student_group.specialty_profile_id <> calendar.specialty_profile_id
OR student_group.education_form_id <> calendar.study_form_id
""");
}
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private void updateInt(Connection connection, String sql, int value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
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 void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record WriteOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -14,31 +14,21 @@ 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("Миграция инвариантов точечных изменений расписания")
@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 принимает допустимые варианты и отклоняет недопустимые данные")
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")
void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
Flyway flyway = targetThreeFlyway();
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
@@ -46,8 +36,8 @@ class ScheduleOverrideMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V3")
.isEqualTo("3");
.as("Последней успешно применённой миграцией должна быть V1")
.isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
@@ -106,81 +96,11 @@ class ScheduleOverrideMigrationIntegrationTest {
}
}
@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() {
private Flyway baselineFlyway() {
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"))
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
@@ -276,19 +196,6 @@ class ScheduleOverrideMigrationIntegrationTest {
}
}
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) {
}
}

View File

@@ -12,15 +12,12 @@ 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("Миграция чётности академических часов правил расписания")
@DisplayName("Инварианты правил расписания базовой схемы")
class ScheduleRuleHoursMigrationIntegrationTest {
private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
@@ -30,9 +27,9 @@ class ScheduleRuleHoursMigrationIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
void migrationEnforcesEvenAcademicHours() throws SQLException {
Flyway flyway = targetFourFlyway();
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
@@ -40,8 +37,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V4")
.isEqualTo("4");
.as("Последней успешно применённой миграцией должна быть V1")
.isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
@@ -63,7 +60,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
""",
HOURS_CONSTRAINT
))
.as("Ограничение чётности V4 должно быть провалидировано")
.as("Ограничение чётности V1 должно быть провалидировано")
.isOne();
assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
@@ -73,113 +70,11 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
@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() {
private Flyway baselineFlyway() {
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"))
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
@@ -291,20 +186,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
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++) {
@@ -319,19 +200,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
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) {
}
}

View File

@@ -0,0 +1,146 @@
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.SQLException;
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.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Уникальность владения дисциплинами базовой схемы")
class SubjectOwnershipInvariantIntegrationTest {
private static final String UNIQUE_INDEX = "uq_subjects_name_ci";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра")
void baselineEnforcesCaseInsensitiveGlobalSubjectName() throws SQLException {
migrateBaseline();
try (Connection connection = POSTGRES.createConnection("")) {
long anotherDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
Throwable failure = catchThrowable(() -> insertSubject(
connection,
"информатика",
anotherDepartmentId
));
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo("23505");
assertThat(sqlFailure.getMessage()).contains(UNIQUE_INDEX);
}
}
@Test
@DisplayName("V1 конкурентно принимает только одну дисциплину с одинаковым названием")
void baselineProtectsConcurrentSubjectCreation() throws Exception {
migrateBaseline();
long firstDepartmentId;
long secondDepartmentId;
try (Connection connection = POSTGRES.createConnection("")) {
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentInsert("Теория систем", firstDepartmentId, ready, start)),
executor.submit(() -> concurrentInsert("теория систем", secondDepartmentId, ready, start))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isEqualTo("23505");
assertThat(outcome.message()).contains(UNIQUE_INDEX);
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private void migrateBaseline() {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
flyway.clean();
flyway.migrate();
}
private InsertOutcome concurrentInsert(String name,
long departmentId,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertSubject(connection, name, departmentId);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private void insertSubject(Connection connection, String name, long departmentId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO subjects (name, department_id)
VALUES (?, ?)
""")) {
statement.setString(1, name);
statement.setLong(2, departmentId);
statement.executeUpdate();
}
}
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
var resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -0,0 +1,257 @@
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.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.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Исторические назначения преподавателей базовой схемы")
class TeacherDepartmentInvariantIntegrationTest {
private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
void baselineEnforcesTeacherDepartmentTimeline() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
long teacherId = seedTeacherId(connection);
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));
insertAssignment(
connection,
teacherId,
secondDepartmentId,
LocalDate.of(2026, 8, 1),
null,
true
);
insertAssignment(
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 1),
null,
false
);
assertSqlState(
() -> insertAssignment(
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 1),
true
),
"23P01",
OVERLAP_CONSTRAINT
);
assertThat(queryLong(
connection,
"SELECT count(*) FROM pg_constraint WHERE conname = ?",
OVERLAP_CONSTRAINT
)).isOne();
}
}
@Test
@DisplayName("V1 отклоняет одну из двух конкурентных пересекающихся основных кафедр")
void baselineProtectsConcurrentPrimaryAssignments() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long teacherId;
long firstDepartmentId;
long secondDepartmentId;
try (Connection connection = POSTGRES.createConnection("")) {
teacherId = seedTeacherId(connection);
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentInsert(
teacherId,
firstDepartmentId,
LocalDate.of(2090, 1, 1),
LocalDate.of(2090, 12, 31),
ready,
start
)),
executor.submit(() -> concurrentInsert(
teacherId,
secondDepartmentId,
LocalDate.of(2090, 6, 1),
LocalDate.of(2091, 5, 31),
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
if ("23P01".equals(outcome.sqlState())) {
assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
}
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private InsertOutcome concurrentInsert(long teacherId,
long departmentId,
LocalDate validFrom,
LocalDate validTo,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertAssignment(connection, teacherId, departmentId, validFrom, validTo, true);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private Flyway baselineFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
}
private long seedTeacherId(Connection connection) throws SQLException {
return queryLong(
connection,
"SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"
);
}
private void updateOpenPrimaryEnd(Connection connection,
long teacherId,
LocalDate validTo) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
UPDATE teacher_department_assignments
SET valid_to = ?
WHERE teacher_id = ?
AND is_primary = TRUE
AND valid_to IS NULL
""")) {
statement.setDate(1, Date.valueOf(validTo));
statement.setLong(2, teacherId);
assertThat(statement.executeUpdate()).isEqualTo(1);
}
}
private void insertAssignment(Connection connection,
long teacherId,
long departmentId,
LocalDate validFrom,
LocalDate validTo,
boolean primary) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO teacher_department_assignments (
teacher_id,
department_id,
valid_from,
valid_to,
is_primary
) VALUES (?, ?, ?, ?, ?)
""")) {
statement.setLong(1, teacherId);
statement.setLong(2, departmentId);
statement.setDate(3, Date.valueOf(validFrom));
statement.setObject(4, validTo == null ? null : Date.valueOf(validTo));
statement.setBoolean(5, primary);
statement.executeUpdate();
}
}
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()).isTrue();
return resultSet.getLong(1);
}
}
}
private void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -0,0 +1,316 @@
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.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.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Инварианты временных слотов базовой схемы")
class TimeSlotInvariantMigrationIntegrationTest {
private static final String DURATION_CONSTRAINT = "chk_time_slots_duration_matches_range";
private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
@Test
@DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
void migrationEnforcesAllTimeSlotInvariants() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
long manualScopeId = insertManualScope(connection, "migration_checks");
long firstSlotId = insertTimeSlot(connection, manualScopeId, 101, "08:00", "09:30", 90);
insertTimeSlot(connection, manualScopeId, 102, "09:30", "11:00", 90);
assertSqlState(
() -> insertTimeSlot(connection, manualScopeId, 103, "09:00", "10:00", 60),
"23P01",
OVERLAP_CONSTRAINT
);
assertSqlState(
() -> insertTimeSlot(connection, manualScopeId, 104, "11:10", "12:10", 30),
"23514",
DURATION_CONSTRAINT
);
long usedBaseSlotId = queryLong(
connection,
"SELECT time_slot_id FROM schedule_rule_slots ORDER BY id LIMIT 1"
);
assertSqlState(
() -> updateLong(
connection,
"UPDATE time_slots SET time_slot_scope_id = ? WHERE id = ?",
manualScopeId,
usedBaseSlotId
),
"23514",
"нельзя переносить из базовой сетки"
);
assertSqlState(
() -> copyRuleSlotWithTimeSlot(connection, firstSlotId),
"23514",
"только слоты базовой сетки"
);
long defaultScopeId = queryLong(
connection,
"SELECT id FROM time_slot_scopes WHERE apply_mode = 'DEFAULT'"
);
assertSqlState(
() -> updateString(
connection,
"UPDATE time_slot_scopes SET apply_mode = ? WHERE id = ?",
"MANUAL",
defaultScopeId
),
"23514",
"нельзя сделать небазовой"
);
assertThat(constraintCount(connection, DURATION_CONSTRAINT)).isOne();
assertThat(constraintCount(connection, OVERLAP_CONSTRAINT)).isOne();
}
}
@Test
@DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся интервалов")
void migrationProtectsConcurrentWrites() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long manualScopeId;
try (Connection connection = POSTGRES.createConnection("")) {
manualScopeId = insertManualScope(connection, "concurrent_slots");
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
List<Future<InsertOutcome>> futures = List.of(
executor.submit(() -> concurrentInsert(
manualScopeId, 301, "12:00", "13:30", ready, start)),
executor.submit(() -> concurrentInsert(
manualScopeId, 302, "13:00", "14:30", ready, start))
);
assertThat(ready.await(5, TimeUnit.SECONDS))
.as("Обе конкурентные транзакции должны подготовиться")
.isTrue();
start.countDown();
List<InsertOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isIn("23P01", "40P01");
if ("23P01".equals(outcome.sqlState())) {
assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
}
});
} finally {
start.countDown();
executor.shutdownNow();
}
}
private InsertOutcome concurrentInsert(long scopeId,
int orderNumber,
String startTime,
String endTime,
CountDownLatch ready,
CountDownLatch start) {
try (Connection connection = POSTGRES.createConnection("")) {
connection.setAutoCommit(false);
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) {
return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
}
try {
insertTimeSlot(connection, scopeId, orderNumber, startTime, endTime, 90);
connection.commit();
return new InsertOutcome(true, null, null);
} catch (SQLException exception) {
connection.rollback();
return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
}
} catch (Exception exception) {
return new InsertOutcome(false, null, exception.getMessage());
}
}
private Flyway baselineFlyway() {
return flyway("1");
}
private Flyway flyway(String targetVersion) {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion(targetVersion))
.cleanDisabled(false)
.load();
}
private long insertManualScope(Connection connection, String code) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO time_slot_scopes (
code,
name,
apply_mode,
system_scope,
display_order
) VALUES (?, ?, 'MANUAL', FALSE, 900)
RETURNING id
""")) {
statement.setString(1, code);
statement.setString(2, "Тестовая ручная сетка");
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private long insertTimeSlot(Connection connection,
long scopeId,
int orderNumber,
String startTime,
String endTime,
int durationMinutes) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO time_slots (
time_slot_scope_id,
order_number,
start_time,
end_time,
duration_minutes
) VALUES (?, ?, ?::TIME, ?::TIME, ?)
RETURNING id
""")) {
statement.setLong(1, scopeId);
statement.setInt(2, orderNumber);
statement.setString(3, startTime);
statement.setString(4, endTime);
statement.setInt(5, durationMinutes);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void copyRuleSlotWithTimeSlot(Connection connection, long timeSlotId) 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,
?,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
FROM schedule_rule_slots
ORDER BY id
LIMIT 1
""")) {
statement.setLong(1, timeSlotId);
statement.executeUpdate();
}
}
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private void updateString(Connection connection, String sql, String value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, value);
statement.setLong(2, id);
statement.executeUpdate();
}
}
private long constraintCount(Connection connection, String constraintName) throws SQLException {
return queryLong(
connection,
"SELECT count(*) FROM pg_constraint WHERE conrelid = 'time_slots'::regclass AND conname = ?",
constraintName
);
}
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 void assertSqlState(ThrowingSqlRunnable operation,
String expectedSqlState,
String expectedMessagePart) {
Throwable failure = catchThrowable(operation::run);
assertThat(failure).isInstanceOf(SQLException.class);
SQLException sqlFailure = (SQLException) failure;
assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
}
@FunctionalInterface
private interface ThrowingSqlRunnable {
void run() throws SQLException;
}
private record InsertOutcome(boolean success, String sqlState, String message) {
}
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.model;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
@@ -37,4 +38,17 @@ class LifecycleEntityTest {
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
}
@Test
void archiveUsesExplicitBusinessDateAndUtcMoment() {
Subject subject = new Subject();
LocalDate businessDate = LocalDate.of(2026, 1, 2);
Instant archiveMoment = Instant.parse("2026-01-01T21:00:00Z");
subject.archive("Тестовая архивация", businessDate, archiveMoment);
assertThat(subject.getActiveTo()).isEqualTo(businessDate);
assertThat(subject.getArchivedAt()).isEqualTo(archiveMoment);
assertThat(subject.getArchiveReason()).isEqualTo("Тестовая архивация");
}
}

View File

@@ -0,0 +1,245 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.SemesterRepository;
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.dao.DataIntegrityViolationException;
import java.time.LocalDate;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AcademicPeriodServiceTest {
private static final long YEAR_ID = 10L;
private static final long SEMESTER_ID = 20L;
private static final LocalDate YEAR_START = LocalDate.of(2026, 9, 1);
private static final LocalDate YEAR_END = LocalDate.of(2027, 6, 30);
@Mock
private AcademicYearRepository academicYearRepository;
@Mock
private SemesterRepository semesterRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private AcademicPeriodService service;
private AcademicYear year;
@BeforeEach
void setUp() {
service = new AcademicPeriodService(
academicYearRepository,
semesterRepository,
scheduleGeneratorService
);
year = year();
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(semesterRepository.saveAndFlush(any(Semester.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void createsNonOverlappingYearWithTrimmedTitle() {
AcademicYear saved = service.createYear(yearRequest(
" 2026-2027 ",
YEAR_START,
YEAR_END
));
assertThat(saved.getTitle()).isEqualTo("2026-2027");
assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsDuplicateTitleOrOverlappingYear() {
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
assertThatThrownBy(() -> service.createYear(yearRequest(
"2026-2027",
YEAR_START,
YEAR_END
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом");
verify(academicYearRepository, never()).saveAndFlush(any());
}
@Test
void rejectsYearUpdateThatWouldExcludeExistingSemester() {
Semester autumn = semester(
SEMESTER_ID,
SemesterType.autumn,
YEAR_START,
LocalDate.of(2027, 1, 31)
);
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
.thenReturn(List.of(autumn));
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest(
"2026-2027",
YEAR_START.plusDays(1),
YEAR_END
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Новые границы учебного года не включают все его семестры");
verify(academicYearRepository, never()).saveAndFlush(any());
}
@Test
void rejectsSemesterOutsideAcademicYear() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest(
SemesterType.autumn,
YEAR_START.minusDays(1),
LocalDate.of(2027, 1, 31)
)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Семестр должен полностью находиться в границах учебного года");
verify(semesterRepository, never()).saveAndFlush(any());
}
@Test
void rejectsDuplicateTypeOrOverlappingSemester() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.existsOverlapping(
YEAR_ID,
YEAR_START,
LocalDate.of(2027, 2, 1)
)).thenReturn(true);
assertThatThrownBy(() -> service.createSemester(YEAR_ID, semesterRequest(
SemesterType.autumn,
YEAR_START,
LocalDate.of(2027, 2, 1)
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года");
verify(semesterRepository, never()).saveAndFlush(any());
}
@Test
void acceptsAdjacentSemesters() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
Semester saved = service.createSemester(YEAR_ID, semesterRequest(
SemesterType.spring,
LocalDate.of(2027, 2, 1),
YEAR_END
));
assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1));
assertThat(saved.getAcademicYear()).isSameAs(year);
verify(scheduleGeneratorService).clearCache();
}
@Test
void validatesDuplicateSemesterTypeDuringUpdate() {
Semester existing = semester(
SEMESTER_ID,
SemesterType.spring,
LocalDate.of(2027, 2, 1),
YEAR_END
);
when(semesterRepository.findById(SEMESTER_ID)).thenReturn(Optional.of(existing));
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(existing));
when(semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
YEAR_ID,
SemesterType.autumn,
SEMESTER_ID
)).thenReturn(true);
assertThatThrownBy(() -> service.updateSemester(SEMESTER_ID, semesterRequest(
SemesterType.autumn,
LocalDate.of(2027, 2, 1),
YEAR_END
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Тип или период семестра конфликтует с существующим семестром учебного года");
verify(semesterRepository, never()).saveAndFlush(any());
}
@Test
void mapsDatabaseRaceToSafeConflict() {
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenThrow(new DataIntegrityViolationException("ex_academic_years_no_overlap"));
assertThatThrownBy(() -> service.createYear(yearRequest(
"2026-2027",
YEAR_START,
YEAR_END
)))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом")
.hasNoCause();
}
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
return new AcademicYearDto(null, title, startDate, endDate, List.of());
}
private SemesterDto semesterRequest(SemesterType semesterType,
LocalDate startDate,
LocalDate endDate) {
return new SemesterDto(
null,
YEAR_ID,
"2026-2027",
semesterType,
startDate,
endDate
);
}
private AcademicYear year() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(YEAR_ID);
academicYear.setTitle("2026-2027");
academicYear.setStartDate(YEAR_START);
academicYear.setEndDate(YEAR_END);
return academicYear;
}
private Semester semester(Long id,
SemesterType semesterType,
LocalDate startDate,
LocalDate endDate) {
Semester semester = new Semester();
semester.setId(id);
semester.setAcademicYear(year);
semester.setSemesterType(semesterType);
semester.setStartDate(startDate);
semester.setEndDate(endDate);
return semester;
}
}

View File

@@ -0,0 +1,335 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
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 java.time.LocalDate;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AcademicStructureServiceTest {
private static final long YEAR_ID = 10L;
private static final long SPECIALITY_ID = 20L;
private static final long PROFILE_ID = 30L;
private static final long FORM_ID = 40L;
private static final long OTHER_FORM_ID = 41L;
private static final long CALENDAR_ID = 50L;
private static final long GROUP_ID = 60L;
@Mock
private AcademicCalendarRepository calendarRepository;
@Mock
private AcademicCalendarDayRepository calendarDayRepository;
@Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock
private AcademicYearRepository academicYearRepository;
@Mock
private SpecialtiesRepository specialtiesRepository;
@Mock
private SpecialtyProfileRepository profileRepository;
@Mock
private EducationFormRepository educationFormRepository;
@Mock
private GroupRepository groupRepository;
@Mock
private StudentGroupCalendarAssignmentRepository assignmentRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private AcademicStructureService service;
private AcademicYear academicYear;
private Speciality speciality;
private SpecialtyProfile profile;
private EducationForm studyForm;
private EducationForm otherStudyForm;
private AcademicCalendar calendar;
private StudentGroup group;
@BeforeEach
void setUp() {
service = new AcademicStructureService(
calendarRepository,
calendarDayRepository,
calendarSubjectRepository,
academicYearRepository,
specialtiesRepository,
profileRepository,
educationFormRepository,
groupRepository,
assignmentRepository,
scheduleGeneratorService
);
academicYear = academicYear();
speciality = speciality();
profile = profile(speciality);
studyForm = studyForm(FORM_ID, "Бакалавриат");
otherStudyForm = studyForm(OTHER_FORM_ID, "Магистратура");
calendar = calendar();
group = group(2025);
when(calendarRepository.findByIdWithDetailsForUpdate(CALENDAR_ID))
.thenReturn(Optional.of(calendar));
when(academicYearRepository.findById(YEAR_ID)).thenReturn(Optional.of(academicYear));
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(academicYear));
when(specialtiesRepository.findById(SPECIALITY_ID)).thenReturn(Optional.of(speciality));
when(profileRepository.findById(PROFILE_ID)).thenReturn(Optional.of(profile));
when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm));
when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm));
when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group));
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))
.thenReturn(List.of(assignment(group, calendar)));
assertThatThrownBy(() -> service.updateCalendar(
CALENDAR_ID,
calendarRequest(4, OTHER_FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя изменить график: сохранённые назначения групп станут несовместимыми");
assertThat(calendar.getStudyForm()).isSameAs(studyForm);
verify(calendarRepository, never()).saveAndFlush(any());
}
@Test
void rejectsCourseCountBelowExistingGrid() {
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
CALENDAR_ID, 3)).thenReturn(true);
assertThatThrownBy(() -> service.updateCalendar(
CALENDAR_ID,
calendarRequest(3, FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя уменьшить количество курсов: в сетке есть данные старших курсов");
assertThat(calendar.getCourseCount()).isEqualTo(4);
}
@Test
void rejectsCourseCountBelowExistingSubjects() {
when(calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
CALENDAR_ID, 6)).thenReturn(true);
assertThatThrownBy(() -> service.updateCalendar(
CALENDAR_ID,
calendarRequest(3, FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров");
}
@Test
void rejectsGroupDimensionChangeAndKeepsAssignment() {
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
.thenReturn(List.of(assignment(group, calendar)));
assertThatThrownBy(() -> service.updateGroup(
GROUP_ID,
groupRequest(2025, OTHER_FORM_ID)
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessage("Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми");
assertThat(group.getEducationForm()).isSameAs(studyForm);
verify(groupRepository, never()).saveAndFlush(any());
verify(assignmentRepository, never()).delete(any());
}
@Test
void acceptsCompatibleGroupUpdateAndClearsCache() {
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
.thenReturn(List.of(assignment(group, calendar)));
StudentGroup saved = service.updateGroup(GROUP_ID, groupRequest(2025, FORM_ID));
assertThat(saved.getName()).isEqualTo("ИВТ-26");
assertThat(saved.getEducationForm()).isSameAs(studyForm);
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() {
group.setYearStartStudy(2020);
assertThatThrownBy(() -> service.saveAssignment(
GROUP_ID,
assignmentRequest()
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Календарный график не соответствует учебному году, параметрам или курсу группы");
verify(assignmentRepository, never()).saveAndFlush(any());
}
@Test
void savesCompatibleAssignmentWithoutDeletingExistingData() {
when(assignmentRepository.findByGroupIdAndAcademicYearIdForUpdate(GROUP_ID, YEAR_ID))
.thenReturn(Optional.empty());
StudentGroupCalendarAssignment saved = service.saveAssignment(
GROUP_ID,
assignmentRequest()
);
assertThat(saved.getStudentGroup()).isSameAs(group);
assertThat(saved.getAcademicCalendar()).isSameAs(calendar);
verify(assignmentRepository, never()).delete(any());
verify(scheduleGeneratorService).clearCache();
}
private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) {
return new AcademicCalendarDto(
CALENDAR_ID,
"График 2026",
YEAR_ID,
null,
null,
null,
SPECIALITY_ID,
null,
null,
PROFILE_ID,
null,
studyFormId,
null,
courseCount
);
}
private CreateGroupRequest groupRequest(int yearStartStudy, long studyFormId) {
CreateGroupRequest request = new CreateGroupRequest();
request.setName("ИВТ-26");
request.setGroupSize(25L);
request.setEducationFormId(studyFormId);
request.setDepartmentId(1L);
request.setYearStartStudy(yearStartStudy);
request.setSpecialtyId(SPECIALITY_ID);
request.setSpecialtyProfileId(PROFILE_ID);
return request;
}
private GroupCalendarAssignmentDto assignmentRequest() {
return new GroupCalendarAssignmentDto(
null,
GROUP_ID,
null,
YEAR_ID,
null,
CALENDAR_ID,
null,
List.of()
);
}
private AcademicYear academicYear() {
AcademicYear year = new AcademicYear();
year.setId(YEAR_ID);
year.setTitle("2025-2026");
year.setStartDate(LocalDate.of(2025, 9, 1));
year.setEndDate(LocalDate.of(2026, 6, 30));
return year;
}
private Speciality speciality() {
Speciality value = new Speciality();
value.setId(SPECIALITY_ID);
value.setSpecialityCode("09.03.01");
value.setSpecialityName("Информатика");
return value;
}
private SpecialtyProfile profile(Speciality owner) {
SpecialtyProfile value = new SpecialtyProfile();
value.setId(PROFILE_ID);
value.setName("Без профиля");
value.setSpeciality(owner);
return value;
}
private EducationForm studyForm(long id, String name) {
EducationForm value = new EducationForm();
value.setId(id);
value.setName(name);
return value;
}
private AcademicCalendar calendar() {
AcademicCalendar value = new AcademicCalendar();
value.setId(CALENDAR_ID);
value.setTitle("Исходный график");
value.setAcademicYear(academicYear);
value.setSpeciality(speciality);
value.setSpecialtyProfile(profile);
value.setStudyForm(studyForm);
value.setCourseCount(4);
return value;
}
private StudentGroup group(int yearStartStudy) {
StudentGroup value = new StudentGroup();
value.setId(GROUP_ID);
value.setName("ИВТ-25");
value.setGroupSize(25L);
value.setEducationForm(studyForm);
value.setDepartmentId(1L);
value.setYearStartStudy(yearStartStudy);
value.setSpeciality(speciality);
value.setSpecialtyProfile(profile);
return value;
}
private StudentGroupCalendarAssignment assignment(StudentGroup assignedGroup,
AcademicCalendar assignedCalendar) {
StudentGroupCalendarAssignment value = new StudentGroupCalendarAssignment();
value.setId(70L);
value.setStudentGroup(assignedGroup);
value.setAcademicYear(academicYear);
value.setAcademicCalendar(assignedCalendar);
return value;
}
}

View File

@@ -0,0 +1,36 @@
package com.magistr.app.service;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
class BusinessTimeServiceTest {
private static final ZoneId MOSCOW = ZoneId.of("Europe/Moscow");
@Test
void keepsBusinessDateBeforeMoscowMidnight() {
BusinessTimeService service = new BusinessTimeService(
Clock.fixed(Instant.parse("2026-01-01T20:59:59Z"), ZoneId.of("UTC")),
MOSCOW
);
assertThat(service.today()).isEqualTo(LocalDate.of(2026, 1, 1));
assertThat(service.now()).isEqualTo(Instant.parse("2026-01-01T20:59:59Z"));
}
@Test
void changesBusinessDateExactlyAtMoscowMidnight() {
BusinessTimeService service = new BusinessTimeService(
Clock.fixed(Instant.parse("2026-01-01T21:00:00Z"), ZoneId.of("UTC")),
MOSCOW
);
assertThat(service.today()).isEqualTo(LocalDate.of(2026, 1, 2));
}
}

View File

@@ -0,0 +1,265 @@
package com.magistr.app.service;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.HashSet;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@DisplayName("Число запросов генератора расписания")
class ScheduleGenerationQueryCountTest {
private static final LocalDate START_DATE = LocalDate.of(2026, 9, 1);
private static final LocalDate END_DATE = LocalDate.of(2026, 12, 29);
private static final int GROUP_COUNT = 30;
private static final int RULE_COUNT = 20;
@Test
@DisplayName("120 дат, десятки групп и правил обрабатываются семью batch-запросами")
void queryCountDoesNotGrowAsDatesTimesGroupsTimesRules() {
SemesterRepository semesterRepository = mock(SemesterRepository.class);
StudentGroupCalendarAssignmentRepository assignmentRepository =
mock(StudentGroupCalendarAssignmentRepository.class);
AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class);
ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class);
TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class);
TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class);
TimeSlotRepository timeSlotRepository = mock(TimeSlotRepository.class);
GroupRepository groupRepository = mock(GroupRepository.class);
UserRepository userRepository = mock(UserRepository.class);
AcademicYear academicYear = academicYear();
Semester semester = semester(academicYear);
AcademicCalendar calendar = calendar(academicYear);
List<StudentGroup> groups = groups();
List<StudentGroupCalendarAssignment> assignments = groups.stream()
.map(group -> assignment(group, academicYear, calendar))
.toList();
List<AcademicCalendarDay> calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1))
.map(date -> calendarDay(calendar, date))
.toList();
List<ScheduleRule> rules = IntStream.rangeClosed(1, RULE_COUNT)
.mapToObj(index -> rule(index, semester, groups))
.toList();
TimeSlotScope defaultScope = timeSlotScope(40L, "DEFAULT", null);
TimeSlotScope weekdayScope = timeSlotScope(41L, "WEEKDAY", 2);
TimeSlot baseSlot = timeSlot(50L, defaultScope, LocalTime.of(8, 0), LocalTime.of(9, 30));
TimeSlot effectiveSlot = timeSlot(51L, weekdayScope, LocalTime.of(8, 15), LocalTime.of(9, 45));
addRenderedLecture(rules.get(0), baseSlot);
when(semesterRepository.findOverlappingForSchedule(START_DATE, END_DATE))
.thenReturn(List.of(semester));
when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection()))
.thenReturn(assignments);
when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE)))
.thenReturn(calendarDays);
when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()))
.thenReturn(rules);
when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of());
when(scopeRepository.findByApplyModeOrderByDisplayOrderAsc("WEEKDAY"))
.thenReturn(List.of(weekdayScope));
when(timeSlotRepository.findAllForSchedule()).thenReturn(List.of(baseSlot, effectiveSlot));
AcademicDateService academicDateService = new AcademicDateService(
semesterRepository,
calendarDayRepository,
assignmentRepository,
false
);
ScheduleGeneratorService generator = new ScheduleGeneratorService(
ruleRepository,
groupRepository,
userRepository,
academicDateService,
timeSlotRepository,
scopeRepository,
dateAssignmentRepository
);
var result = generator.buildScheduleForGroups(groups, START_DATE, END_DATE);
assertThat(START_DATE.datesUntil(END_DATE.plusDays(1)).count()).isEqualTo(120);
assertThat(groups).hasSize(GROUP_COUNT);
assertThat(rules).hasSize(RULE_COUNT);
assertThat(result).hasSize(GROUP_COUNT).allSatisfy(lesson -> {
assertThat(lesson.date()).isEqualTo(START_DATE);
assertThat(lesson.timeSlotId()).isEqualTo(effectiveSlot.getId());
assertThat(lesson.startTime()).isEqualTo(effectiveSlot.getStartTime());
assertThat(lesson.remainingLessonTypeAcademicHoursAfterLesson()).isZero();
assertThat(lesson.groupIds()).hasSize(1);
});
verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE);
verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection());
verify(calendarDayRepository).findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE));
verify(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection());
verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE);
verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY");
verify(timeSlotRepository).findAllForSchedule();
verify(semesterRepository, never())
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any());
verify(assignmentRepository, never()).findForSchedule(any(), any());
verify(calendarDayRepository, never())
.findByCalendarIdAndCourseNumberAndDate(any(), any(), any());
verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any());
verify(dateAssignmentRepository, never()).findByDate(any());
verify(scopeRepository, never()).findByApplyModeAndDayOfWeek(any(), any());
verify(timeSlotRepository, never()).findByTimeSlotScopeIdAndOrderNumber(any(), any());
int repositoryCalls = Stream.of(
semesterRepository,
assignmentRepository,
calendarDayRepository,
ruleRepository,
dateAssignmentRepository,
scopeRepository,
timeSlotRepository,
groupRepository,
userRepository
)
.mapToInt(repository -> mockingDetails(repository).getInvocations().size())
.sum();
assertThat(repositoryCalls).isEqualTo(7);
}
private AcademicYear academicYear() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(1L);
academicYear.setTitle("2026/2027");
academicYear.setStartDate(START_DATE);
academicYear.setEndDate(LocalDate.of(2027, 8, 31));
return academicYear;
}
private Semester semester(AcademicYear academicYear) {
Semester semester = new Semester();
semester.setId(10L);
semester.setAcademicYear(academicYear);
semester.setSemesterType(SemesterType.autumn);
semester.setStartDate(START_DATE);
semester.setEndDate(END_DATE);
return semester;
}
private AcademicCalendar calendar(AcademicYear academicYear) {
AcademicCalendar calendar = new AcademicCalendar();
calendar.setId(20L);
calendar.setTitle("Основной учебный график");
calendar.setAcademicYear(academicYear);
calendar.setCourseCount(4);
return calendar;
}
private List<StudentGroup> groups() {
return IntStream.rangeClosed(1, GROUP_COUNT)
.mapToObj(index -> {
StudentGroup group = new StudentGroup();
group.setId((long) index);
group.setName("Группа " + index);
group.setYearStartStudy(2026);
return group;
})
.toList();
}
private StudentGroupCalendarAssignment assignment(StudentGroup group,
AcademicYear academicYear,
AcademicCalendar calendar) {
StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
assignment.setId(100L + group.getId());
assignment.setStudentGroup(group);
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
return assignment;
}
private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) {
AcademicCalendarActivityType theory = new AcademicCalendarActivityType();
theory.setId(30L);
theory.setCode("Т");
theory.setName("Теоретическое обучение");
theory.setAllowSchedule(true);
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(1);
day.setDate(date);
day.setActivityType(theory);
return day;
}
private ScheduleRule rule(int index, Semester semester, List<StudentGroup> groups) {
Subject subject = new Subject((long) index, "Дисциплина " + index, "D" + index, 1L);
ScheduleRule rule = new ScheduleRule();
rule.setId((long) index);
rule.setSubject(subject);
rule.setSemester(semester);
rule.setGroups(new HashSet<>(groups));
return rule;
}
private TimeSlotScope timeSlotScope(Long id, String applyMode, Integer dayOfWeek) {
TimeSlotScope scope = new TimeSlotScope();
scope.setId(id);
scope.setCode(applyMode + "-" + id);
scope.setName("Сетка " + id);
scope.setApplyMode(applyMode);
scope.setDayOfWeek(dayOfWeek);
scope.setDisplayOrder(id.intValue());
return scope;
}
private TimeSlot timeSlot(Long id, TimeSlotScope scope, LocalTime startTime, LocalTime endTime) {
TimeSlot slot = new TimeSlot();
slot.setId(id);
slot.setOrderNumber(1);
slot.setTimeSlotScope(scope);
slot.setStartTime(startTime);
slot.setEndTime(endTime);
slot.setDurationMinutes(90);
return slot;
}
private void addRenderedLecture(ScheduleRule rule, TimeSlot timeSlot) {
User teacher = new User();
teacher.setId(60L);
teacher.setUsername("teacher");
teacher.setFullName("Тестовый преподаватель");
teacher.setRole(Role.TEACHER);
Classroom classroom = new Classroom();
classroom.setId(70L);
classroom.setName("А-101");
classroom.setIsAvailable(true);
LessonType lessonType = new LessonType();
lessonType.setId(80L);
lessonType.setLessonType("Лекция");
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setId(90L);
slot.setScheduleRule(rule);
slot.setDayOfWeek(2);
slot.setParity(ScheduleParity.BOTH);
slot.setTimeSlot(timeSlot);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
slot.setLessonFormat("Очно");
rule.setLectureAcademicHours(2);
rule.setSlots(new HashSet<>(List.of(slot)));
}
}

View File

@@ -20,6 +20,8 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -67,7 +69,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
assertThat(sourceSchedule).isEmpty();
verify(overrideRepository, times(2)).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
verifyBaseDayBatch();
}
@Test
@@ -85,7 +87,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
assertThat(result).isEmpty();
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
verifyBaseDayBatch();
}
@Test
@@ -109,7 +111,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
.containsExactly(101L, 102L);
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
verifyBaseDayBatch();
}
@Test
@@ -128,7 +130,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll();
verify(generatorService).buildScheduleForGroup(GROUP_ID, DATE, DATE);
verifyBaseDayBatch();
}
@Test
@@ -146,7 +148,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
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))
when(generatorService.buildScheduleForGroups(any(), eq(DATE), eq(DATE)))
.thenReturn(List.of(cancelledLesson, movedLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
.thenReturn(List.of(cancellation, move));
@@ -173,7 +175,15 @@ class ScheduleQueryServiceTeacherOverrideTest {
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);
when(generatorService.buildScheduleForGroups(any(), eq(DATE), eq(DATE))).thenReturn(lessons);
}
private void verifyBaseDayBatch() {
verify(generatorService).buildScheduleForGroups(
argThat(groups -> groups.size() == 1 && groups.iterator().next().getId().equals(GROUP_ID)),
eq(DATE),
eq(DATE)
);
}
private StudentGroup group() {

View File

@@ -4,13 +4,18 @@ import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.IntStream;
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.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -68,6 +73,40 @@ class ScheduleQueryServiceTest {
verify(groupRepository, never()).findAll();
}
@ParameterizedTest
@ValueSource(ints = {51, 100})
void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 7);
when(groupRepository.findAll())
.thenReturn(IntStream.rangeClosed(1, groupCount)
.mapToObj(this::group)
.toList());
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
when(generatorService.buildScheduleForGroups(any(), any(), any())).thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService
);
var result = service.searchForAggregation(null, null, startDate, endDate);
assertThat(result).isEmpty();
verify(generatorService).buildScheduleForGroups(
argThat(groups -> groups.size() == groupCount),
eq(startDate),
eq(endDate)
);
verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate);
}
private StudentGroup group(int id) {
StudentGroup group = new StudentGroup();
group.setId((long) id);

Some files were not shown because too many files have changed in this diff Show More