баг-фикс 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) {