исправил ошибки и проблемы безопасности
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
db/data/
|
db/data/
|
||||||
.env
|
.env
|
||||||
|
backend/tenants.json
|
||||||
|
|
||||||
# Игнорируем временные файлы сборки (на будущее)
|
# Игнорируем временные файлы сборки (на будущее)
|
||||||
backend/target/
|
backend/target/
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.magistr.app.config.tenant;
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
@@ -43,7 +41,7 @@ public class ConfigMapUpdater {
|
|||||||
public ConfigMapUpdater() {
|
public ConfigMapUpdater() {
|
||||||
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
||||||
if (!runningInK8s) {
|
if (!runningInK8s) {
|
||||||
log.info("Not running in K8s — ConfigMap updates will be skipped");
|
log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +51,7 @@ public class ConfigMapUpdater {
|
|||||||
*/
|
*/
|
||||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||||
if (!runningInK8s) {
|
if (!runningInK8s) {
|
||||||
log.warn("Not in K8s, skipping ConfigMap update");
|
log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +85,15 @@ public class ConfigMapUpdater {
|
|||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size());
|
log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error updating ConfigMap: {}", e.getMessage());
|
log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +118,7 @@ public class ConfigMapUpdater {
|
|||||||
.sslContext(sslContext)
|
.sslContext(sslContext)
|
||||||
.build();
|
.build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to create insecure client, using default: {}", e.getMessage());
|
log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
|
||||||
return HttpClient.newHttpClient();
|
return HttpClient.newHttpClient();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ import org.springframework.scheduling.annotation.Scheduled;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.sql.Connection;
|
import java.security.MessageDigest;
|
||||||
import java.sql.ResultSet;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.sql.Statement;
|
import java.util.HexFormat;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -58,20 +55,20 @@ public class TenantConfigWatcher {
|
|||||||
if (!file.exists()) return;
|
if (!file.exists()) return;
|
||||||
|
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
String hash = Integer.toHexString(content.hashCode());
|
String hash = configHash(content);
|
||||||
|
|
||||||
if (hash.equals(lastConfigHash)) {
|
if (hash.equals(lastConfigHash)) {
|
||||||
return; // Ничего не изменилось
|
return; // Ничего не изменилось
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash);
|
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
|
||||||
lastConfigHash = hash;
|
lastConfigHash = hash;
|
||||||
|
|
||||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||||
syncTenants(newTenants);
|
syncTenants(newTenants);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error watching tenants config: {}", e.getMessage());
|
log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +80,19 @@ public class TenantConfigWatcher {
|
|||||||
File file = new File(tenantsConfigPath);
|
File file = new File(tenantsConfigPath);
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
lastConfigHash = Integer.toHexString(content.hashCode());
|
lastConfigHash = configHash(content);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to refresh config hash: {}", e.getMessage());
|
log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String configHash(String content) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +109,7 @@ public class TenantConfigWatcher {
|
|||||||
for (TenantConfig tenant : newTenants) {
|
for (TenantConfig tenant : newTenants) {
|
||||||
String domain = tenant.getDomain().toLowerCase();
|
String domain = tenant.getDomain().toLowerCase();
|
||||||
if (!current.containsKey(domain)) {
|
if (!current.containsKey(domain)) {
|
||||||
log.info("Adding new tenant '{}' from ConfigMap update", domain);
|
log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
|
||||||
routingDataSource.addTenant(tenant);
|
routingDataSource.addTenant(tenant);
|
||||||
// Инициализируем БД для нового тенанта
|
// Инициализируем БД для нового тенанта
|
||||||
initDatabaseForTenant(tenant);
|
initDatabaseForTenant(tenant);
|
||||||
@@ -113,7 +119,7 @@ public class TenantConfigWatcher {
|
|||||||
// Удалить тенанты, которых больше нет в конфиге
|
// Удалить тенанты, которых больше нет в конфиге
|
||||||
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
||||||
if (!newDomains.contains(existingDomain)) {
|
if (!newDomains.contains(existingDomain)) {
|
||||||
log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain);
|
log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
|
||||||
routingDataSource.removeTenant(existingDomain);
|
routingDataSource.removeTenant(existingDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +135,7 @@ public class TenantConfigWatcher {
|
|||||||
try {
|
try {
|
||||||
TenantContext.setCurrentTenant(domain);
|
TenantContext.setCurrentTenant(domain);
|
||||||
|
|
||||||
log.info("[{}] Starting Flyway migrations...", domain);
|
log.info("[{}] Запускаем миграции Flyway", domain);
|
||||||
|
|
||||||
// Получаем DataSource конкретно для этого тенанта
|
// Получаем DataSource конкретно для этого тенанта
|
||||||
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||||
@@ -145,10 +151,10 @@ public class TenantConfigWatcher {
|
|||||||
.load();
|
.load();
|
||||||
|
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
log.info("[{}] Flyway migrations completed successfully", domain);
|
log.info("[{}] Миграции Flyway успешно выполнены", domain);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
|
log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
TenantContext.clear();
|
TenantContext.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.magistr.app.config.tenant;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -13,8 +12,6 @@ import org.springframework.orm.jpa.JpaTransactionManager;
|
|||||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||||
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|
||||||
|
|
||||||
import jakarta.persistence.EntityManagerFactory;
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
@@ -30,7 +27,7 @@ import java.util.*;
|
|||||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class TenantDataSourceConfig implements WebMvcConfigurer {
|
public class TenantDataSourceConfig {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
||||||
|
|
||||||
@@ -68,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
try {
|
try {
|
||||||
routingDataSource.addTenant(tenant);
|
routingDataSource.addTenant(tenant);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage());
|
log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,27 +117,6 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
return new JpaTransactionManager(emf);
|
return new JpaTransactionManager(emf);
|
||||||
}
|
}
|
||||||
|
|
||||||
@org.springframework.context.annotation.Lazy
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
|
||||||
private TenantRoutingDataSource tenantRoutingDataSource;
|
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
|
||||||
private AuthorizationInterceptor authorizationInterceptor;
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
|
|
||||||
TenantInterceptor interceptor = new TenantInterceptor();
|
|
||||||
interceptor.setRoutingDataSource(routingDataSource);
|
|
||||||
return interceptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
|
||||||
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
|
|
||||||
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
|
|
||||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TenantConfig> loadTenantsFromFile() {
|
private List<TenantConfig> loadTenantsFromFile() {
|
||||||
File file = new File(tenantsConfigPath);
|
File file = new File(tenantsConfigPath);
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
@@ -154,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
|||||||
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
||||||
return list;
|
return list;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to read tenants config: {}", e.getMessage());
|
log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class TenantWebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||||
|
private final AuthorizationInterceptor authorizationInterceptor;
|
||||||
|
|
||||||
|
public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
|
||||||
|
AuthorizationInterceptor authorizationInterceptor) {
|
||||||
|
this.tenantRoutingDataSource = tenantRoutingDataSource;
|
||||||
|
this.authorizationInterceptor = authorizationInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantInterceptor tenantInterceptor() {
|
||||||
|
TenantInterceptor interceptor = new TenantInterceptor();
|
||||||
|
interceptor.setRoutingDataSource(tenantRoutingDataSource);
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||||
|
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -124,12 +125,12 @@ public class AuthController {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(Map.of(
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
"userId", user.id(),
|
response.put("userId", user.id());
|
||||||
"username", user.username(),
|
response.put("username", user.username());
|
||||||
"role", user.role().name(),
|
response.put("role", user.role().name());
|
||||||
"departmentId", user.departmentId()
|
response.put("departmentId", user.departmentId());
|
||||||
));
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoginResponse loginResponse(User user, String token) {
|
private LoginResponse loginResponse(User user, String token) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/department")
|
@RequestMapping("/api/department")
|
||||||
@@ -148,6 +149,6 @@ public class DepartmentWorkspaceController {
|
|||||||
var user = AuthContext.getCurrentUser();
|
var user = AuthContext.getCurrentUser();
|
||||||
return user == null
|
return user == null
|
||||||
|| user.role() == Role.ADMIN
|
|| user.role() == Role.ADMIN
|
||||||
|| (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId()));
|
|| (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
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.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||||
|
? "Некорректный запрос"
|
||||||
|
: exception.getMessage();
|
||||||
|
return error(HttpStatus.BAD_REQUEST, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoSuchElementException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||||
|
? "Запрошенная запись не найдена"
|
||||||
|
: exception.getMessage();
|
||||||
|
return error(HttpStatus.NOT_FOUND, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({
|
||||||
|
HttpMessageNotReadableException.class,
|
||||||
|
MethodArgumentTypeMismatchException.class
|
||||||
|
})
|
||||||
|
public ResponseEntity<Map<String, Object>> handleBadRequest(Exception exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
|
||||||
|
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||||
|
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("status", status.value());
|
||||||
|
body.put("error", reason(status));
|
||||||
|
body.put("message", message);
|
||||||
|
body.put("path", request.getRequestURI());
|
||||||
|
return ResponseEntity.status(status).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String reason(HttpStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
case BAD_REQUEST -> "Некорректный запрос";
|
||||||
|
case NOT_FOUND -> "Не найдено";
|
||||||
|
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||||
|
default -> status.name();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -42,27 +41,17 @@ public class ScheduleController {
|
|||||||
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return ResponseEntity.ok(scheduleQueryService.search(
|
||||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(
|
groupId,
|
||||||
groupId,
|
teacherId,
|
||||||
teacherId,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
startDate,
|
||||||
startDate,
|
endDate
|
||||||
endDate
|
));
|
||||||
);
|
|
||||||
return ResponseEntity.ok(lessons);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e);
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("message", "Произошла ошибка при генерации расписания"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/schedule/search")
|
@RequestMapping("/api/schedule/search")
|
||||||
@@ -40,24 +39,17 @@ public class ScheduleSearchController {
|
|||||||
) {
|
) {
|
||||||
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
|
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
|
||||||
groupId, teacherId, classroomId, departmentId, startDate, endDate);
|
groupId, teacherId, classroomId, departmentId, startDate, endDate);
|
||||||
try {
|
return ResponseEntity.ok(scheduleQueryService.search(
|
||||||
return ResponseEntity.ok(scheduleQueryService.search(
|
groupId,
|
||||||
groupId,
|
teacherId,
|
||||||
teacherId,
|
classroomId,
|
||||||
classroomId,
|
departmentId,
|
||||||
departmentId,
|
subjectId,
|
||||||
subjectId,
|
lessonTypeId,
|
||||||
lessonTypeId,
|
timeSlotId,
|
||||||
timeSlotId,
|
parity,
|
||||||
parity,
|
startDate,
|
||||||
startDate,
|
endDate
|
||||||
endDate
|
));
|
||||||
));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.HttpStatusCode;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -52,182 +51,115 @@ public class UserController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
logger.info("Получен запрос на получение всех пользователей");
|
logger.info("Получен запрос на получение всех пользователей");
|
||||||
try {
|
List<User> users = includeArchived
|
||||||
List<User> users = includeArchived
|
? userRepository.findAll()
|
||||||
? userRepository.findAll()
|
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
List<UserResponse> response = users.stream()
|
||||||
|
.map(this::toUserResponse)
|
||||||
List<UserResponse> response = users.stream()
|
.toList();
|
||||||
.map(u -> {
|
logger.info("Получено {} пользователей", response.size());
|
||||||
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
return response;
|
||||||
.map(Department::getDepartmentName)
|
|
||||||
.orElse("Неизвестно");
|
|
||||||
|
|
||||||
UserResponse userResponse = new UserResponse(
|
|
||||||
u.getId(),
|
|
||||||
u.getUsername(),
|
|
||||||
u.getRole().name(),
|
|
||||||
u.getFullName(),
|
|
||||||
u.getJobTitle(),
|
|
||||||
departmentName);
|
|
||||||
userResponse.setStatus(u.getStatus());
|
|
||||||
return userResponse;
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
logger.info("Получено {} пользователей", response.size());
|
|
||||||
return response;
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public List<UserResponse> getTeachers() {
|
public List<UserResponse> getTeachers() {
|
||||||
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||||
|
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||||
try {
|
.map(this::toUserResponse)
|
||||||
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
|
.toList();
|
||||||
|
logger.info("Получено {} преподавателей", response.size());
|
||||||
List<UserResponse> response = users.stream()
|
return response;
|
||||||
.map(u -> {
|
|
||||||
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
|
||||||
.map(Department::getDepartmentName)
|
|
||||||
.orElse("Неизвестно");
|
|
||||||
|
|
||||||
UserResponse userResponse = new UserResponse(
|
|
||||||
u.getId(),
|
|
||||||
u.getUsername(),
|
|
||||||
u.getRole().name(),
|
|
||||||
u.getFullName(),
|
|
||||||
u.getJobTitle(),
|
|
||||||
departmentName);
|
|
||||||
userResponse.setStatus(u.getStatus());
|
|
||||||
return userResponse;
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
logger.info("Получено {} преподавателей", response.size());
|
|
||||||
return response;
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers/{departmentId}")
|
@GetMapping("/teachers/{departmentId}")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||||
try {
|
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
||||||
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
Role.TEACHER,
|
||||||
Role.TEACHER,
|
departmentId,
|
||||||
departmentId,
|
LifecycleEntity.STATUS_ARCHIVED
|
||||||
LifecycleEntity.STATUS_ARCHIVED
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (users.isEmpty()) {
|
if (users.isEmpty()) {
|
||||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body("Преподаватели для указанной кафедры не найдены");
|
.body("Преподаватели для указанной кафедры не найдены");
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
|
||||||
|
|
||||||
List<UserResponse> userResponses = users.stream()
|
|
||||||
.map( user -> {
|
|
||||||
|
|
||||||
return new UserResponse(
|
|
||||||
user.getId(),
|
|
||||||
user.getRole().name(),
|
|
||||||
user.getFullName(),
|
|
||||||
user.getJobTitle(),
|
|
||||||
user.getDepartmentId()
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return ResponseEntity.ok(userResponses);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
.body("Произошла ошибка при получении списка преподавателей");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||||
|
return ResponseEntity.ok(users.stream()
|
||||||
|
.map(this::toUserResponse)
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||||
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
||||||
|
|
||||||
try {
|
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
String errorMessage = "Имя пользователя обязательно";
|
||||||
String errorMessage = "Имя пользователя обязательно";
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
|
||||||
String errorMessage = "Пароль минимум 4 символа";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
|
||||||
String errorMessage = "Пользователь уже существует";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (request.getFullName() == null || request.getFullName().isBlank()) {
|
|
||||||
String errorMessage = "Имя пользователя обязательно";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
|
|
||||||
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
|
|
||||||
request.setJobTitle("Не указано");
|
|
||||||
}
|
|
||||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
|
||||||
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
|
||||||
logger.error("Ошибка валидации: {}", errorMessage);
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
|
||||||
}
|
|
||||||
|
|
||||||
Role role;
|
|
||||||
try {
|
|
||||||
role = Role.valueOf(request.getRole());
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
|
||||||
}
|
|
||||||
|
|
||||||
User user = new User();
|
|
||||||
user.setUsername(request.getUsername());
|
|
||||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
|
||||||
user.setRole(role);
|
|
||||||
user.setFullName(request.getFullName());
|
|
||||||
user.setJobTitle(request.getJobTitle());
|
|
||||||
user.setDepartmentId(request.getDepartmentId());
|
|
||||||
userRepository.save(user);
|
|
||||||
if (role == Role.TEACHER) {
|
|
||||||
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
|
||||||
if (department != null) {
|
|
||||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
|
||||||
assignment.setTeacher(user);
|
|
||||||
assignment.setDepartment(department);
|
|
||||||
assignment.setValidFrom(LocalDate.now());
|
|
||||||
assignment.setPrimaryAssignment(true);
|
|
||||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
|
||||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
|
||||||
teacherDepartmentAssignmentRepository.save(assignment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
|
||||||
} catch (Exception e ) {
|
|
||||||
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
||||||
|
String errorMessage = "Пароль минимум 4 символа";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||||
|
String errorMessage = "Пользователь уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getFullName() == null || request.getFullName().isBlank()) {
|
||||||
|
String errorMessage = "Имя пользователя обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
|
||||||
|
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
|
||||||
|
request.setJobTitle("Не указано");
|
||||||
|
}
|
||||||
|
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||||
|
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
Role role;
|
||||||
|
try {
|
||||||
|
role = Role.valueOf(request.getRole());
|
||||||
|
} catch (IllegalArgumentException | NullPointerException e) {
|
||||||
|
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(request.getUsername());
|
||||||
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setRole(role);
|
||||||
|
user.setFullName(request.getFullName());
|
||||||
|
user.setJobTitle(request.getJobTitle());
|
||||||
|
user.setDepartmentId(request.getDepartmentId());
|
||||||
|
userRepository.save(user);
|
||||||
|
if (role == Role.TEACHER) {
|
||||||
|
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
||||||
|
if (department != null) {
|
||||||
|
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||||
|
assignment.setTeacher(user);
|
||||||
|
assignment.setDepartment(department);
|
||||||
|
assignment.setValidFrom(LocalDate.now());
|
||||||
|
assignment.setPrimaryAssignment(true);
|
||||||
|
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||||
|
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||||
|
teacherDepartmentAssignmentRepository.save(assignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
||||||
|
return ResponseEntity.ok(toUserResponse(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@@ -252,8 +184,7 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
user.restore();
|
user.restore();
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
return ResponseEntity.ok(toUserResponse(user));
|
||||||
user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/department-history")
|
@GetMapping("/{id}/department-history")
|
||||||
@@ -322,11 +253,29 @@ public class UserController {
|
|||||||
.stream()
|
.stream()
|
||||||
.map(TeacherDepartmentAssignment::getTeacher)
|
.map(TeacherDepartmentAssignment::getTeacher)
|
||||||
.filter(User::isActiveRecord)
|
.filter(User::isActiveRecord)
|
||||||
.map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
.map(this::toUserResponse)
|
||||||
user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
|
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UserResponse toUserResponse(User user) {
|
||||||
|
String departmentName = user.getDepartmentId() == null
|
||||||
|
? null
|
||||||
|
: departmentRepository.findById(user.getDepartmentId())
|
||||||
|
.map(Department::getDepartmentName)
|
||||||
|
.orElse("Неизвестно");
|
||||||
|
UserResponse response = new UserResponse(
|
||||||
|
user.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getRole().name(),
|
||||||
|
user.getFullName(),
|
||||||
|
user.getJobTitle(),
|
||||||
|
departmentName
|
||||||
|
);
|
||||||
|
response.setDepartmentId(user.getDepartmentId());
|
||||||
|
response.setStatus(user.getStatus());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
|
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
|
||||||
return new TeacherDepartmentAssignmentDto(
|
return new TeacherDepartmentAssignmentDto(
|
||||||
assignment.getId(),
|
assignment.getId(),
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ public class WorkloadController {
|
|||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
|
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||||
.filter(Classroom::getIsAvailable)
|
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
|
||||||
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
|
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
|
||||||
.map(this::toClassroomResponse)
|
.map(this::toClassroomResponse)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ public class UserResponse {
|
|||||||
return departmentId;
|
return departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getStatus() {
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ public abstract class LifecycleEntity {
|
|||||||
if (date == null) {
|
if (date == null) {
|
||||||
return isActiveRecord();
|
return isActiveRecord();
|
||||||
}
|
}
|
||||||
|
if (!isActiveRecord()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
|
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
|
||||||
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
|
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
|
||||||
return afterStart && beforeEnd;
|
return afterStart && beforeEnd;
|
||||||
|
|||||||
@@ -21,16 +21,35 @@ public enum ScheduleLessonCategory {
|
|||||||
if (lessonType == null || lessonType.getLessonType() == null) {
|
if (lessonType == null || lessonType.getLessonType() == null) {
|
||||||
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
||||||
}
|
}
|
||||||
String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU"));
|
String name = lessonType.getLessonType()
|
||||||
if (name.contains("лекц")) {
|
.trim()
|
||||||
|
.toLowerCase(Locale.forLanguageTag("ru-RU"))
|
||||||
|
.replace('ё', 'е');
|
||||||
|
boolean lecture = name.equals("лекция") || name.equals("лекции") || name.startsWith("лекц");
|
||||||
|
boolean laboratory = name.equals("лабораторная работа")
|
||||||
|
|| name.equals("лабораторные работы")
|
||||||
|
|| name.startsWith("лаб")
|
||||||
|
|| name.contains("лаборатор");
|
||||||
|
boolean practice = name.equals("практика")
|
||||||
|
|| name.equals("практики")
|
||||||
|
|| name.startsWith("практ")
|
||||||
|
|| name.contains("практичес")
|
||||||
|
|| name.contains("семинар");
|
||||||
|
int matches = (lecture ? 1 : 0) + (laboratory ? 1 : 0) + (practice ? 1 : 0);
|
||||||
|
if (matches > 1) {
|
||||||
|
throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
|
||||||
|
+ "\" неоднозначен. Используйте отдельный тип: лекция, практика или лабораторная работа");
|
||||||
|
}
|
||||||
|
if (lecture) {
|
||||||
return LECTURE;
|
return LECTURE;
|
||||||
}
|
}
|
||||||
if (name.contains("лаб")) {
|
if (laboratory) {
|
||||||
return LABORATORY;
|
return LABORATORY;
|
||||||
}
|
}
|
||||||
if (name.contains("практ")) {
|
if (practice) {
|
||||||
return PRACTICE;
|
return PRACTICE;
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
|
||||||
|
+ "\" должен быть лекцией, практикой или лабораторной работой");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.magistr.app.model;
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -89,7 +90,7 @@ public class ScheduleRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setStatus(String status) {
|
public void setStatus(String status) {
|
||||||
this.status = status;
|
this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDate getValidFrom() {
|
public LocalDate getValidFrom() {
|
||||||
@@ -172,6 +173,38 @@ public class ScheduleRule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isActiveRecord() {
|
||||||
|
return !LifecycleEntity.STATUS_ARCHIVED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isArchivedRecord() {
|
||||||
|
return LifecycleEntity.STATUS_ARCHIVED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isActiveOn(LocalDate date) {
|
||||||
|
if (date == null) {
|
||||||
|
return isActiveRecord();
|
||||||
|
}
|
||||||
|
if (!isActiveRecord()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean afterStart = validFrom == null || !date.isBefore(validFrom);
|
||||||
|
boolean beforeEnd = validTo == null || !date.isAfter(validTo);
|
||||||
|
return afterStart && beforeEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void archive() {
|
||||||
|
status = LifecycleEntity.STATUS_ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void restore() {
|
||||||
|
status = LifecycleEntity.STATUS_ACTIVE;
|
||||||
|
validTo = null;
|
||||||
|
}
|
||||||
|
|
||||||
private int valueOrZero(Integer value) {
|
private int valueOrZero(Integer value) {
|
||||||
return valueOrDefault(value, 0);
|
return valueOrDefault(value, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import java.time.DayOfWeek;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.TextStyle;
|
import java.time.format.TextStyle;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -34,7 +33,6 @@ public class ScheduleGeneratorService {
|
|||||||
private final TimeSlotRepository timeSlotRepository;
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||||
GroupRepository groupRepository,
|
GroupRepository groupRepository,
|
||||||
@@ -58,8 +56,10 @@ public class ScheduleGeneratorService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
||||||
|
|
||||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||||
|
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||||
List<RenderedLessonDto> result = new ArrayList<>();
|
List<RenderedLessonDto> result = new ArrayList<>();
|
||||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||||
|
Set<Long> primedSemesterIds = new HashSet<>();
|
||||||
|
|
||||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||||
@@ -81,9 +81,12 @@ public class ScheduleGeneratorService {
|
|||||||
semester.getId(),
|
semester.getId(),
|
||||||
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
||||||
);
|
);
|
||||||
|
if (primedSemesterIds.add(semester.getId())) {
|
||||||
|
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
|
||||||
|
}
|
||||||
|
|
||||||
for (ScheduleRule rule : sortRules(rules)) {
|
for (ScheduleRule rule : sortRules(rules)) {
|
||||||
renderRuleForDate(rule, date, group, null, result);
|
processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +100,9 @@ public class ScheduleGeneratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||||
|
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||||
List<RenderedLessonDto> result = new ArrayList<>();
|
List<RenderedLessonDto> result = new ArrayList<>();
|
||||||
|
Set<Long> primedSemesterIds = new HashSet<>();
|
||||||
|
|
||||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||||
@@ -110,9 +115,12 @@ public class ScheduleGeneratorService {
|
|||||||
semester.getId(),
|
semester.getId(),
|
||||||
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
||||||
);
|
);
|
||||||
|
if (primedSemesterIds.add(semester.getId())) {
|
||||||
|
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
|
||||||
|
}
|
||||||
|
|
||||||
for (ScheduleRule rule : sortRules(rules)) {
|
for (ScheduleRule rule : sortRules(rules)) {
|
||||||
renderRuleForDate(rule, date, null, teacherId, result);
|
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +128,8 @@ public class ScheduleGeneratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void clearCache() {
|
public void clearCache() {
|
||||||
consumedHoursCache.clear();
|
// Расход часов считается в контексте одного построения расписания.
|
||||||
|
// Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных.
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||||
@@ -152,10 +161,34 @@ public class ScheduleGeneratorService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderRuleForDate(ScheduleRule rule,
|
private void primeConsumedHoursBeforeRange(Semester semester,
|
||||||
|
List<ScheduleRule> rules,
|
||||||
|
LocalDate rangeStart,
|
||||||
|
StudentGroup targetGroup,
|
||||||
|
Long targetTeacherId,
|
||||||
|
Map<String, Integer> consumedHoursProgress) {
|
||||||
|
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
||||||
|
if (!semester.getStartDate().isBefore(endExclusive)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate minDate(LocalDate first, LocalDate second) {
|
||||||
|
return first.isBefore(second) ? first : second;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processRuleForDate(ScheduleRule rule,
|
||||||
LocalDate date,
|
LocalDate date,
|
||||||
StudentGroup targetGroup,
|
StudentGroup targetGroup,
|
||||||
Long targetTeacherId,
|
Long targetTeacherId,
|
||||||
|
Map<String, Integer> consumedHoursProgress,
|
||||||
List<RenderedLessonDto> result) {
|
List<RenderedLessonDto> result) {
|
||||||
if (rule.getLectureAcademicHours() == null
|
if (rule.getLectureAcademicHours() == null
|
||||||
|| rule.getLaboratoryAcademicHours() == null
|
|| rule.getLaboratoryAcademicHours() == null
|
||||||
@@ -170,6 +203,9 @@ public class ScheduleGeneratorService {
|
|||||||
if (!rule.getSubject().isActiveOn(date)) {
|
if (!rule.getSubject().isActiveOn(date)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!rule.isActiveOn(date)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Semester semester = semesterOpt.get();
|
Semester semester = semesterOpt.get();
|
||||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||||
@@ -193,8 +229,6 @@ public class ScheduleGeneratorService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
|
|
||||||
|
|
||||||
for (ScheduleRuleSlot slot : slots) {
|
for (ScheduleRuleSlot slot : slots) {
|
||||||
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -217,11 +251,8 @@ public class ScheduleGeneratorService {
|
|||||||
|
|
||||||
List<ActiveLessonScope> activeScopes = new ArrayList<>();
|
List<ActiveLessonScope> activeScopes = new ArrayList<>();
|
||||||
for (LessonScope scope : lessonScopes) {
|
for (LessonScope scope : lessonScopes) {
|
||||||
String consumptionBucket = consumptionBucket(category, scope.subgroupId());
|
String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId());
|
||||||
int consumedHours = consumedHoursByBucket.computeIfAbsent(
|
int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0);
|
||||||
consumptionBucket,
|
|
||||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
|
|
||||||
);
|
|
||||||
if (consumedHours >= totalHours) {
|
if (consumedHours >= totalHours) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -233,28 +264,30 @@ public class ScheduleGeneratorService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
if (result != null) {
|
||||||
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
||||||
activeScopes.forEach(scope -> {
|
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
||||||
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
|
activeScopes.forEach(scope -> {
|
||||||
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
|
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
|
||||||
});
|
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
|
||||||
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
|
});
|
||||||
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
|
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
|
||||||
int consumedHours = activeScopes.stream()
|
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
|
||||||
.mapToInt(ActiveLessonScope::consumedHours)
|
int consumedHours = activeScopes.stream()
|
||||||
.min()
|
.mapToInt(ActiveLessonScope::consumedHours)
|
||||||
.orElse(0);
|
.min()
|
||||||
int remainingAfterLesson = activeScopes.stream()
|
.orElse(0);
|
||||||
.mapToInt(ActiveLessonScope::remainingAfterLesson)
|
int remainingAfterLesson = activeScopes.stream()
|
||||||
.min()
|
.mapToInt(ActiveLessonScope::remainingAfterLesson)
|
||||||
.orElse(0);
|
.min()
|
||||||
|
.orElse(0);
|
||||||
|
|
||||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
||||||
totalHours, consumedHours, remainingAfterLesson));
|
totalHours, consumedHours, remainingAfterLesson));
|
||||||
|
}
|
||||||
for (ActiveLessonScope scope : activeScopes) {
|
for (ActiveLessonScope scope : activeScopes) {
|
||||||
consumedHoursByBucket.put(
|
consumedHoursProgress.put(
|
||||||
consumptionBucket(category, scope.scope().subgroupId()),
|
consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()),
|
||||||
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
|
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,62 +308,20 @@ public class ScheduleGeneratorService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
|
|
||||||
StudentGroup targetGroup,
|
|
||||||
LocalDate targetDate,
|
|
||||||
ScheduleLessonCategory category,
|
|
||||||
Long subgroupId) {
|
|
||||||
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
|
||||||
String cacheKey = rule.getId()
|
|
||||||
+ ":" + category.name()
|
|
||||||
+ ":" + (subgroupId == null ? "whole" : subgroupId)
|
|
||||||
+ ":" + (groupId == null ? "all" : groupId)
|
|
||||||
+ ":" + targetDate;
|
|
||||||
return consumedHoursCache.computeIfAbsent(cacheKey,
|
|
||||||
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private int calculateConsumedHours(ScheduleRule rule,
|
|
||||||
StudentGroup targetGroup,
|
|
||||||
LocalDate targetDate,
|
|
||||||
ScheduleLessonCategory category,
|
|
||||||
Long subgroupId) {
|
|
||||||
int consumed = 0;
|
|
||||||
for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
|
|
||||||
Semester semester = rule.getSemester();
|
|
||||||
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
|
||||||
if (weekNumber < rule.startWeekFor(category)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
|
||||||
if (eligibleGroups.isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int dayOfWeek = date.getDayOfWeek().getValue();
|
|
||||||
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
|
||||||
long activeSlots = rule.getSlots().stream()
|
|
||||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
|
||||||
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
|
||||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
|
||||||
.flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
|
|
||||||
.filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
|
|
||||||
.count();
|
|
||||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
|
||||||
if (consumed >= rule.academicHoursFor(category)) {
|
|
||||||
return consumed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return consumed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
||||||
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String consumptionProgressKey(ScheduleRule rule,
|
||||||
|
StudentGroup targetGroup,
|
||||||
|
ScheduleLessonCategory category,
|
||||||
|
Long subgroupId) {
|
||||||
|
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
||||||
|
return rule.getId()
|
||||||
|
+ ":" + consumptionBucket(category, subgroupId)
|
||||||
|
+ ":" + (groupId == null ? "all" : groupId);
|
||||||
|
}
|
||||||
|
|
||||||
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
||||||
ScheduleLessonCategory category,
|
ScheduleLessonCategory category,
|
||||||
List<StudentGroup> eligibleGroups) {
|
List<StudentGroup> eligibleGroups) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class ScheduleQueryService {
|
public class ScheduleQueryService {
|
||||||
|
|
||||||
|
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
|
||||||
|
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||||
@@ -37,10 +39,15 @@ public class ScheduleQueryService {
|
|||||||
String parity,
|
String parity,
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
LocalDate endDate) {
|
LocalDate endDate) {
|
||||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
|
List<RenderedLessonDto> generatedLessons;
|
||||||
List<RenderedLessonDto> generatedLessons = groups.stream()
|
if (groupId == null && departmentId == null && teacherId != null) {
|
||||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||||
.toList();
|
} else {
|
||||||
|
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
|
||||||
|
generatedLessons = groups.stream()
|
||||||
|
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
|
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
|
||||||
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
||||||
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
|
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
|
||||||
@@ -142,7 +149,12 @@ public class ScheduleQueryService {
|
|||||||
if (departmentId != null) {
|
if (departmentId != null) {
|
||||||
return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||||
}
|
}
|
||||||
return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
List<StudentGroup> groups = groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
if (groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||||
|
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
||||||
|
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
|
private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ server.port=8080
|
|||||||
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
|
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
|
||||||
spring.datasource.url=jdbc:postgresql://db:5432/app_db
|
spring.datasource.url=jdbc:postgresql://db:5432/app_db
|
||||||
spring.datasource.username=${POSTGRES_USER:myuser}
|
spring.datasource.username=${POSTGRES_USER:myuser}
|
||||||
spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword}
|
spring.datasource.password=${POSTGRES_PASSWORD:}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
# JPA
|
# JPA
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TenantConfigWatcherTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configHashUsesSha256Hex() {
|
||||||
|
String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
|
||||||
|
|
||||||
|
assertThat(hash).hasSize(64);
|
||||||
|
assertThat(hash).matches("[0-9a-f]{64}");
|
||||||
|
assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configHashChangesWhenContentChanges() {
|
||||||
|
assertThat(TenantConfigWatcher.configHash("tenant-a"))
|
||||||
|
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
|
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class AuthControllerTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearAuthContext() {
|
||||||
|
AuthContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void meAllowsNullDepartmentId() {
|
||||||
|
AuthController controller = new AuthController(null, null, null, null, null);
|
||||||
|
AuthContext.setCurrentUser(new AuthenticatedUser(10L, "admin", Role.ADMIN, null));
|
||||||
|
|
||||||
|
ResponseEntity<?> response = controller.me();
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isInstanceOf(Map.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> body = (Map<String, Object>) response.getBody();
|
||||||
|
assertThat(body).containsEntry("userId", 10L);
|
||||||
|
assertThat(body).containsEntry("username", "admin");
|
||||||
|
assertThat(body).containsEntry("role", "ADMIN");
|
||||||
|
assertThat(body).containsKey("departmentId");
|
||||||
|
assertThat(body.get("departmentId")).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
|
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
import com.magistr.app.model.Subject;
|
||||||
|
import com.magistr.app.repository.SubjectCommentRepository;
|
||||||
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class DepartmentWorkspaceControllerTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearAuthContext() {
|
||||||
|
AuthContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commentsRejectSubjectWithoutDepartmentForDepartmentUserWithoutNpe() {
|
||||||
|
SubjectRepository subjectRepository = mock(SubjectRepository.class);
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setId(10L);
|
||||||
|
subject.setDepartmentId(null);
|
||||||
|
when(subjectRepository.findById(10L)).thenReturn(Optional.of(subject));
|
||||||
|
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||||
|
subjectRepository,
|
||||||
|
mock(SubjectCommentRepository.class),
|
||||||
|
mock(UserRepository.class),
|
||||||
|
mock(ScheduleQueryService.class)
|
||||||
|
);
|
||||||
|
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||||
|
|
||||||
|
var response = controller.getComments(10L);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.UserResponse;
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
|
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.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class UserControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teachersUseUnifiedUserResponseMapping() {
|
||||||
|
UserRepository userRepository = mock(UserRepository.class);
|
||||||
|
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||||
|
UserController controller = new UserController(
|
||||||
|
userRepository,
|
||||||
|
null,
|
||||||
|
departmentRepository,
|
||||||
|
mock(TeacherDepartmentAssignmentRepository.class)
|
||||||
|
);
|
||||||
|
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
|
||||||
|
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
|
||||||
|
.thenReturn(List.of(teacher));
|
||||||
|
when(departmentRepository.findById(2L))
|
||||||
|
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
||||||
|
|
||||||
|
var response = controller.getTeachersByDepartmentId(2L);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<UserResponse> body = (List<UserResponse>) response.getBody();
|
||||||
|
assertThat(body).hasSize(1);
|
||||||
|
assertThat(body.get(0).getUsername()).isEqualTo("teacher");
|
||||||
|
assertThat(body.get(0).getDepartmentId()).isEqualTo(2L);
|
||||||
|
assertThat(body.get(0).getDepartmentName()).isEqualTo("Кафедра ВТ");
|
||||||
|
assertThat(body.get(0).getStatus()).isEqualTo(LifecycleEntity.STATUS_ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user(Long id, String username, Role role, Long departmentId) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setRole(role);
|
||||||
|
user.setFullName("Тестовый пользователь");
|
||||||
|
user.setJobTitle("Преподаватель");
|
||||||
|
user.setDepartmentId(departmentId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Classroom;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
|
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 org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class WorkloadControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
||||||
|
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||||
|
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
|
||||||
|
WorkloadController controller = new WorkloadController(
|
||||||
|
scheduleQueryService,
|
||||||
|
classroomRepository,
|
||||||
|
mock(UserRepository.class),
|
||||||
|
mock(DepartmentRepository.class)
|
||||||
|
);
|
||||||
|
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||||
|
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
|
||||||
|
|
||||||
|
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
|
||||||
|
|
||||||
|
assertThat(result).extracting("id").containsExactly(3L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Classroom classroom(Long id, Boolean available) {
|
||||||
|
Classroom classroom = new Classroom();
|
||||||
|
classroom.setId(id);
|
||||||
|
classroom.setName("Аудитория " + id);
|
||||||
|
classroom.setCapacity(30);
|
||||||
|
classroom.setIsAvailable(available);
|
||||||
|
return classroom;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LifecycleEntityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archivedEntityIsNotActiveOnDateWithoutActiveTo() {
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.now())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeEntityUsesDateRange() {
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setActiveFrom(LocalDate.of(2026, 1, 10));
|
||||||
|
subject.setActiveTo(LocalDate.of(2026, 1, 20));
|
||||||
|
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 9))).isFalse();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 10))).isTrue();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
|
||||||
|
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ScheduleLessonCategoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsCanonicalLessonTypes() {
|
||||||
|
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лекция")))
|
||||||
|
.isEqualTo(ScheduleLessonCategory.LECTURE);
|
||||||
|
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Практика")))
|
||||||
|
.isEqualTo(ScheduleLessonCategory.PRACTICE);
|
||||||
|
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лабораторная работа")))
|
||||||
|
.isEqualTo(ScheduleLessonCategory.LABORATORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAmbiguousLessonType() {
|
||||||
|
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Лабораторно-практическое занятие")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("неоднозначен");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedLessonType() {
|
||||||
|
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Консультация")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("должен быть лекцией");
|
||||||
|
}
|
||||||
|
|
||||||
|
private LessonType lessonType(String name) {
|
||||||
|
LessonType lessonType = new LessonType();
|
||||||
|
lessonType.setLessonType(name);
|
||||||
|
return lessonType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ScheduleRuleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeOnUsesStatusAndValidityRange() {
|
||||||
|
ScheduleRule rule = new ScheduleRule();
|
||||||
|
rule.setValidFrom(LocalDate.of(2026, 2, 1));
|
||||||
|
rule.setValidTo(LocalDate.of(2026, 2, 10));
|
||||||
|
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 1, 31))).isFalse();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 1))).isTrue();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 10))).isTrue();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 11))).isFalse();
|
||||||
|
|
||||||
|
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||||
|
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 5))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archiveAndRestoreToggleStatus() {
|
||||||
|
ScheduleRule rule = new ScheduleRule();
|
||||||
|
|
||||||
|
rule.archive();
|
||||||
|
|
||||||
|
assertThat(rule.isArchivedRecord()).isTrue();
|
||||||
|
assertThat(rule.isActiveOn(LocalDate.now())).isFalse();
|
||||||
|
|
||||||
|
rule.restore();
|
||||||
|
|
||||||
|
assertThat(rule.isActiveRecord()).isTrue();
|
||||||
|
assertThat(rule.getValidTo()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.repository.GroupRepository;
|
||||||
|
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ScheduleQueryServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTooBroadGroupSearch() {
|
||||||
|
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||||
|
when(groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||||
|
.thenReturn(IntStream.rangeClosed(1, 51)
|
||||||
|
.mapToObj(this::group)
|
||||||
|
.toList());
|
||||||
|
ScheduleQueryService service = new ScheduleQueryService(
|
||||||
|
mock(ScheduleGeneratorService.class),
|
||||||
|
groupRepository,
|
||||||
|
mock(ScheduleOverrideRepository.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.search(null, null, null, null,
|
||||||
|
null, null, null, null,
|
||||||
|
LocalDate.of(2026, 5, 1),
|
||||||
|
LocalDate.of(2026, 5, 7)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("Уточните группу или кафедру");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teacherSearchDoesNotLoadAllGroups() {
|
||||||
|
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||||
|
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||||
|
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||||
|
ScheduleQueryService service = new ScheduleQueryService(generatorService, groupRepository, overrideRepository);
|
||||||
|
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||||
|
LocalDate endDate = LocalDate.of(2026, 5, 7);
|
||||||
|
when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
|
||||||
|
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.search(null, 10L, null, null,
|
||||||
|
null, null, null, null,
|
||||||
|
startDate,
|
||||||
|
endDate);
|
||||||
|
|
||||||
|
verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
|
||||||
|
verify(groupRepository, never()).findByStatusNot(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroup group(int id) {
|
||||||
|
StudentGroup group = new StudentGroup();
|
||||||
|
group.setId((long) id);
|
||||||
|
group.setName("Группа " + id);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
@@ -4,6 +4,6 @@
|
|||||||
"domain": "default",
|
"domain": "default",
|
||||||
"url": "jdbc:postgresql://db:5432/app_db",
|
"url": "jdbc:postgresql://db:5432/app_db",
|
||||||
"username": "myuser",
|
"username": "myuser",
|
||||||
"password": "supersecretpassword"
|
"password": "replace-with-local-password"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -32,11 +32,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: myuser
|
POSTGRES_USER: ${POSTGRES_USER:-myuser}
|
||||||
POSTGRES_PASSWORD: supersecretpassword
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env}
|
||||||
POSTGRES_DB: app_db
|
POSTGRES_DB: ${POSTGRES_DB:-app_db}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
|
test: [ "CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
28
docs/API.md
28
docs/API.md
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
||||||
|
|
||||||
|
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-27T19:47:54",
|
||||||
|
"status": 400,
|
||||||
|
"error": "Некорректный запрос",
|
||||||
|
"message": "Некорректные параметры запроса",
|
||||||
|
"path": "/api/schedule"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
@@ -105,6 +119,8 @@ Refresh-токен ротируется при каждом успешном о
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Пользователи
|
## Пользователи
|
||||||
@@ -116,18 +132,20 @@ Refresh-токен ротируется при каждом успешном о
|
|||||||
**Ответ:**
|
**Ответ:**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
|
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
|
||||||
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
|
{ "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
||||||
|
|
||||||
### `GET /api/users/teachers`
|
### `GET /api/users/teachers`
|
||||||
|
|
||||||
Список только преподавателей (роль `TEACHER`).
|
Список только преподавателей (роль `TEACHER`).
|
||||||
|
|
||||||
### `GET /api/users/teachers/{departmentId}`
|
### `GET /api/users/teachers/{departmentId}`
|
||||||
|
|
||||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
|
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||||||
|
|
||||||
### `POST /api/users`
|
### `POST /api/users`
|
||||||
|
|
||||||
@@ -446,6 +464,8 @@ CRUD доступен по:
|
|||||||
| `timeSlotId` | Временной слот |
|
| `timeSlotId` | Временной слот |
|
||||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||||
|
|
||||||
|
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -939,7 +959,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
| Код | Описание |
|
| Код | Описание |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| `200` | Успех |
|
| `200` | Успех |
|
||||||
| `400` | Ошибка валидации (с `message` в теле) |
|
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||||||
| `401` | Неверные учётные данные |
|
| `401` | Неверные учётные данные |
|
||||||
| `404` | Ресурс / тенант не найден |
|
| `404` | Ресурс / тенант не найден |
|
||||||
| `500` | Внутренняя ошибка сервера |
|
| `500` | Внутренняя ошибка сервера |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ graph TD
|
|||||||
- **Порт:** 8080 (внутренний)
|
- **Порт:** 8080 (внутренний)
|
||||||
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
||||||
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
||||||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей
|
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
|
||||||
|
|
||||||
### PostgreSQL
|
### PostgreSQL
|
||||||
- **Версия:** `postgres:alpine3.23`
|
- **Версия:** `postgres:alpine3.23`
|
||||||
@@ -80,6 +80,7 @@ sequenceDiagram
|
|||||||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||||||
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
||||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||||
|
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
||||||
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
|
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
|
||||||
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||||||
@@ -99,7 +100,7 @@ sequenceDiagram
|
|||||||
### Конфигурация тенантов
|
### Конфигурация тенантов
|
||||||
|
|
||||||
Список тенантов хранится в JSON-файле:
|
Список тенантов хранится в JSON-файле:
|
||||||
- **Локально:** `backend/tenants.json`
|
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
|
||||||
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
|
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
|
||||||
|
|
||||||
Формат:
|
Формат:
|
||||||
@@ -121,12 +122,16 @@ sequenceDiagram
|
|||||||
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
||||||
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
|
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
|
||||||
|
|
||||||
|
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
|
||||||
|
|
||||||
### Fallback при отсутствии тенантов
|
### Fallback при отсутствии тенантов
|
||||||
|
|
||||||
Если при запуске нет ни одного настроенного тенанта:
|
Если при запуске нет ни одного настроенного тенанта:
|
||||||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||||||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||||||
|
|
||||||
|
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
|
|
||||||
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
||||||
|
|
||||||
|
Методическая проверка активности учитывает и период действия, и `status`: запись со статусом `ARCHIVED` не считается активной даже без заполненного `active_to`.
|
||||||
|
|
||||||
### Перевод преподавателей между кафедрами
|
### Перевод преподавателей между кафедрами
|
||||||
|
|
||||||
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
|
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
|
||||||
@@ -136,6 +138,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||||
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
||||||
|
|
||||||
|
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
|
||||||
|
|
||||||
|
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||||
|
|
||||||
|
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
|
||||||
|
|
||||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||||
|
|
||||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||||
@@ -167,6 +175,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||||
|
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
||||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||||
|
|
||||||
### Точечные изменения расписания
|
### Точечные изменения расписания
|
||||||
|
|||||||
@@ -644,6 +644,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
| `version_group_id` | BIGINT | Группа версий одного правила |
|
| `version_group_id` | BIGINT | Группа версий одного правила |
|
||||||
| `change_reason` | TEXT | Причина изменения |
|
| `change_reason` | TEXT | Причина изменения |
|
||||||
|
|
||||||
|
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
|
||||||
|
|
||||||
#### `schedule_rule_groups` — Группы правила
|
#### `schedule_rule_groups` — Группы правила
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
|
|||||||
@@ -254,11 +254,13 @@ com.magistr.app/
|
|||||||
│ ├── TenantContext.java # ThreadLocal текущего тенанта
|
│ ├── TenantContext.java # ThreadLocal текущего тенанта
|
||||||
│ ├── TenantInterceptor.java # Определение тенанта из Host
|
│ ├── TenantInterceptor.java # Определение тенанта из Host
|
||||||
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
|
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
|
||||||
│ ├── TenantDataSourceConfig.java # Spring-конфигурация
|
│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
|
||||||
|
│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
|
||||||
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
|
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
|
||||||
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
|
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
|
||||||
├── controller/ # REST-контроллеры
|
├── controller/ # REST-контроллеры
|
||||||
│ ├── AuthController.java
|
│ ├── AuthController.java
|
||||||
|
│ ├── GlobalExceptionHandler.java
|
||||||
│ ├── ScheduleController.java
|
│ ├── ScheduleController.java
|
||||||
│ ├── ClassroomController.java
|
│ ├── ClassroomController.java
|
||||||
│ ├── DatabaseController.java
|
│ ├── DatabaseController.java
|
||||||
|
|||||||
@@ -25,18 +25,21 @@ docker network create proxy
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
POSTGRES_USER=myuser
|
POSTGRES_USER=myuser
|
||||||
POSTGRES_PASSWORD=supersecretpassword
|
POSTGRES_PASSWORD=replace-with-local-password
|
||||||
|
POSTGRES_DB=app_db
|
||||||
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
|
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
|
||||||
JWT_ACCESS_TOKEN_TTL=15m
|
JWT_ACCESS_TOKEN_TTL=15m
|
||||||
JWT_REFRESH_TOKEN_TTL=7d
|
JWT_REFRESH_TOKEN_TTL=7d
|
||||||
```
|
```
|
||||||
|
|
||||||
`JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене он задаётся через Kubernetes Secret `app-secret`.
|
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
||||||
|
|
||||||
|
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
|
||||||
|
|
||||||
### Dockerfile (Backend)
|
### Dockerfile (Backend)
|
||||||
|
|
||||||
Backend собирается через multi-stage сборку Maven:
|
Backend собирается через multi-stage сборку Maven:
|
||||||
1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package`
|
1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
|
||||||
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
||||||
|
|
||||||
### Dockerfile (Frontend)
|
### Dockerfile (Frontend)
|
||||||
|
|||||||
Reference in New Issue
Block a user