From e9575cf2ce614501b4b4840e4715e4ac2c79245a Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 27 May 2026 20:29:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC=D1=8B=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- .../app/config/tenant/ConfigMapUpdater.java | 14 +- .../config/tenant/TenantConfigWatcher.java | 38 +-- .../config/tenant/TenantDataSourceConfig.java | 30 +- .../app/config/tenant/TenantWebMvcConfig.java | 33 +++ .../app/controller/AuthController.java | 13 +- .../DepartmentWorkspaceController.java | 3 +- .../controller/GlobalExceptionHandler.java | 76 +++++ .../app/controller/ScheduleController.java | 35 +-- .../controller/ScheduleSearchController.java | 32 +-- .../app/controller/UserController.java | 269 +++++++----------- .../app/controller/WorkloadController.java | 2 +- .../com/magistr/app/dto/UserResponse.java | 4 + .../magistr/app/model/LifecycleEntity.java | 3 + .../app/model/ScheduleLessonCategory.java | 29 +- .../com/magistr/app/model/ScheduleRule.java | 35 ++- .../app/service/ScheduleGeneratorService.java | 161 +++++------ .../app/service/ScheduleQueryService.java | 22 +- .../src/main/resources/application.properties | 2 +- .../tenant/TenantConfigWatcherTest.java | 23 ++ .../app/controller/AuthControllerTest.java | 38 +++ .../DepartmentWorkspaceControllerTest.java | 46 +++ .../app/controller/UserControllerTest.java | 60 ++++ .../controller/WorkloadControllerTest.java | 50 ++++ .../app/model/LifecycleEntityTest.java | 30 ++ .../app/model/ScheduleLessonCategoryTest.java | 39 +++ .../magistr/app/model/ScheduleRuleTest.java | 41 +++ .../app/service/ScheduleQueryServiceTest.java | 69 +++++ .../{tenants.json => tenants.example.json} | 4 +- compose.yaml | 8 +- docs/API.md | 28 +- docs/ARCHITECTURE.md | 9 +- docs/BUSINESS_LOGIC.md | 9 + docs/DATABASE.md | 2 + docs/DEVELOPMENT.md | 4 +- docs/INFRASTRUCTURE.md | 9 +- 36 files changed, 897 insertions(+), 376 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java create mode 100644 backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java create mode 100644 backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/UserControllerTest.java create mode 100644 backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java create mode 100644 backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java create mode 100644 backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java create mode 100644 backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java create mode 100644 backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java rename backend/{tenants.json => tenants.example.json} (74%) mode change 100755 => 100644 diff --git a/.gitignore b/.gitignore index 9647f9d..42d19e5 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ db/data/ .env +backend/tenants.json # Игнорируем временные файлы сборки (на будущее) backend/target/ @@ -10,4 +11,4 @@ frontend/dist/ .idea/ .vscode/ *.DS_Store -skills-lock.json \ No newline at end of file +skills-lock.json diff --git a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java index 023c734..a69042e 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java @@ -1,12 +1,10 @@ package com.magistr.app.config.tenant; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -43,7 +41,7 @@ public class ConfigMapUpdater { public ConfigMapUpdater() { this.runningInK8s = Files.exists(Path.of(TOKEN_PATH)); 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 tenants) { if (!runningInK8s) { - log.warn("Not in K8s, skipping ConfigMap update"); + log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap"); return true; } @@ -87,15 +85,15 @@ public class ConfigMapUpdater { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { - log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size()); + log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size()); return true; } else { - log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body()); + log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body()); return false; } } catch (Exception e) { - log.error("Error updating ConfigMap: {}", e.getMessage()); + log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage()); return false; } } @@ -120,7 +118,7 @@ public class ConfigMapUpdater { .sslContext(sslContext) .build(); } catch (Exception e) { - log.warn("Failed to create insecure client, using default: {}", e.getMessage()); + log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage()); return HttpClient.newHttpClient(); } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java index 6cff252..e27fb62 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java @@ -10,14 +10,11 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.sql.DataSource; -import java.io.BufferedReader; import java.io.File; -import java.io.InputStream; -import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.Statement; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; import java.util.*; import java.util.stream.Collectors; @@ -58,20 +55,20 @@ public class TenantConfigWatcher { if (!file.exists()) return; 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)) { return; // Ничего не изменилось } - log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash); + log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash); lastConfigHash = hash; List newTenants = objectMapper.readValue(content, new TypeReference<>() {}); syncTenants(newTenants); } 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); if (file.exists()) { 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) { - 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) { String domain = tenant.getDomain().toLowerCase(); if (!current.containsKey(domain)) { - log.info("Adding new tenant '{}' from ConfigMap update", domain); + log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain); routingDataSource.addTenant(tenant); // Инициализируем БД для нового тенанта initDatabaseForTenant(tenant); @@ -113,7 +119,7 @@ public class TenantConfigWatcher { // Удалить тенанты, которых больше нет в конфиге for (String existingDomain : new ArrayList<>(current.keySet())) { if (!newDomains.contains(existingDomain)) { - log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain); + log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain); routingDataSource.removeTenant(existingDomain); } } @@ -129,7 +135,7 @@ public class TenantConfigWatcher { try { TenantContext.setCurrentTenant(domain); - log.info("[{}] Starting Flyway migrations...", domain); + log.info("[{}] Запускаем миграции Flyway", domain); // Получаем DataSource конкретно для этого тенанта javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain); @@ -145,10 +151,10 @@ public class TenantConfigWatcher { .load(); flyway.migrate(); - log.info("[{}] Flyway migrations completed successfully", domain); + log.info("[{}] Миграции Flyway успешно выполнены", domain); } catch (Exception e) { - log.error("[{}] Flyway migration failed: {}", domain, e.getMessage()); + log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage()); } finally { TenantContext.clear(); } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java index 767f897..16d9ff6 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -2,7 +2,6 @@ package com.magistr.app.config.tenant; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.magistr.app.config.auth.AuthorizationInterceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.vendor.HibernateJpaVendorAdapter; 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 javax.sql.DataSource; @@ -30,7 +27,7 @@ import java.util.*; * как заглушку, чтобы Spring JPA мог инициализироваться. */ @Configuration -public class TenantDataSourceConfig implements WebMvcConfigurer { +public class TenantDataSourceConfig { private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class); @@ -68,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { try { routingDataSource.addTenant(tenant); } 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); } - @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 loadTenantsFromFile() { File file = new File(tenantsConfigPath); if (!file.exists()) { @@ -154,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath); return list; } catch (IOException e) { - log.error("Failed to read tenants config: {}", e.getMessage()); + log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage()); return new ArrayList<>(); } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java new file mode 100644 index 0000000..2473306 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java @@ -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/**"); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 0b5096d..1f29f48 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -19,6 +19,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; @@ -124,12 +125,12 @@ public class AuthController { if (user == null) { return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему")); } - return ResponseEntity.ok(Map.of( - "userId", user.id(), - "username", user.username(), - "role", user.role().name(), - "departmentId", user.departmentId() - )); + Map response = new LinkedHashMap<>(); + response.put("userId", user.id()); + response.put("username", user.username()); + response.put("role", user.role().name()); + response.put("departmentId", user.departmentId()); + return ResponseEntity.ok(response); } private LoginResponse loginResponse(User user, String token) { diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java index a59fb43..a107358 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Objects; @RestController @RequestMapping("/api/department") @@ -148,6 +149,6 @@ public class DepartmentWorkspaceController { var user = AuthContext.getCurrentUser(); return user == null || user.role() == Role.ADMIN - || (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId())); + || (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId())); } } diff --git a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..5b459d5 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java @@ -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> 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> 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> handleBadRequest(Exception exception, + HttpServletRequest request) { + log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage()); + return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception exception, + HttpServletRequest request) { + log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception); + return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request); + } + + private ResponseEntity> error(HttpStatus status, String message, HttpServletRequest request) { + Map 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(); + }; + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java index defcf6b..eb326af 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.List; import java.util.Map; @RestController @@ -42,27 +41,17 @@ public class ScheduleController { .body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId")); } - try { - List lessons = scheduleQueryService.search( - groupId, - teacherId, - null, - null, - null, - null, - null, - null, - startDate, - 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", "Произошла ошибка при генерации расписания")); - } + return ResponseEntity.ok(scheduleQueryService.search( + groupId, + teacherId, + null, + null, + null, + null, + null, + null, + startDate, + endDate + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java index 5d5c820..b83ebdd 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java @@ -10,7 +10,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.Map; @RestController @RequestMapping("/api/schedule/search") @@ -40,24 +39,17 @@ public class ScheduleSearchController { ) { logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}", groupId, teacherId, classroomId, departmentId, startDate, endDate); - try { - return ResponseEntity.ok(scheduleQueryService.search( - groupId, - teacherId, - classroomId, - departmentId, - subjectId, - lessonTypeId, - timeSlotId, - parity, - startDate, - 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", "Произошла ошибка при поиске расписания")); - } + return ResponseEntity.ok(scheduleQueryService.search( + groupId, + teacherId, + classroomId, + departmentId, + subjectId, + lessonTypeId, + timeSlotId, + parity, + startDate, + endDate + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index e034fd2..dc088d7 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -18,7 +18,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.transaction.annotation.Transactional; @@ -52,182 +51,115 @@ public class UserController { @GetMapping public List getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех пользователей"); - try { - List users = includeArchived - ? userRepository.findAll() - : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); - - List response = users.stream() - .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; - } - + List users = includeArchived + ? userRepository.findAll() + : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + List response = users.stream() + .map(this::toUserResponse) + .toList(); + logger.info("Получено {} пользователей", response.size()); + return response; } @GetMapping("/teachers") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public List getTeachers() { logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); - - try { - List users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED); - - List response = users.stream() - .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; - } + List response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream() + .map(this::toUserResponse) + .toList(); + logger.info("Получено {} преподавателей", response.size()); + return response; } @GetMapping("/teachers/{departmentId}") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getTeachersByDepartmentId(@PathVariable Long departmentId){ logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); - try { - List users = userRepository.findByRoleAndDepartmentIdAndStatusNot( - Role.TEACHER, - departmentId, - LifecycleEntity.STATUS_ARCHIVED - ); + List users = userRepository.findByRoleAndDepartmentIdAndStatusNot( + Role.TEACHER, + departmentId, + LifecycleEntity.STATUS_ARCHIVED + ); - if (users.isEmpty()) { - logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("Преподаватели для указанной кафедры не найдены"); - } - - logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); - - List 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("Произошла ошибка при получении списка преподавателей"); + if (users.isEmpty()) { + logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Преподаватели для указанной кафедры не найдены"); } + + logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); + return ResponseEntity.ok(users.stream() + .map(this::toUserResponse) + .toList()); } @PostMapping public ResponseEntity createUser(@RequestBody CreateUserRequest request) { logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId()); - try { - if (request.getUsername() == null || request.getUsername().isBlank()) { - String errorMessage = "Имя пользователя обязательно"; - logger.error("Ошибка валидации: {}", 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.getUsername() == null || request.getUsername().isBlank()) { + String errorMessage = "Имя пользователя обязательно"; + logger.error("Ошибка валидации: {}", 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 (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}") @@ -252,8 +184,7 @@ public class UserController { } user.restore(); userRepository.save(user); - return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), - user.getFullName(), user.getJobTitle(), user.getDepartmentId())); + return ResponseEntity.ok(toUserResponse(user)); } @GetMapping("/{id}/department-history") @@ -322,11 +253,29 @@ public class UserController { .stream() .map(TeacherDepartmentAssignment::getTeacher) .filter(User::isActiveRecord) - .map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), - user.getFullName(), user.getJobTitle(), user.getDepartmentId())) + .map(this::toUserResponse) .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) { return new TeacherDepartmentAssignmentDto( assignment.getId(), diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java index 7d27645..72c83fe 100644 --- a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java +++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java @@ -128,7 +128,7 @@ public class WorkloadController { .filter(Objects::nonNull) .collect(Collectors.toSet()); return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream() - .filter(Classroom::getIsAvailable) + .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable())) .filter(classroom -> !busyClassroomIds.contains(classroom.getId())) .map(this::toClassroomResponse) .toList(); diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java index fbcd2f3..76eca04 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -71,6 +71,10 @@ public class UserResponse { return departmentId; } + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + public String getStatus() { return status; } diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java index 4ae5483..cad97a4 100644 --- a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java +++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java @@ -83,6 +83,9 @@ public abstract class LifecycleEntity { if (date == null) { return isActiveRecord(); } + if (!isActiveRecord()) { + return false; + } boolean afterStart = activeFrom == null || !date.isBefore(activeFrom); boolean beforeEnd = activeTo == null || !date.isAfter(activeTo); return afterStart && beforeEnd; diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java index 65a9c12..4d5e4fe 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java @@ -21,16 +21,35 @@ public enum ScheduleLessonCategory { if (lessonType == null || lessonType.getLessonType() == null) { throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); } - String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU")); - if (name.contains("лекц")) { + String name = lessonType.getLessonType() + .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; } - if (name.contains("лаб")) { + if (laboratory) { return LABORATORY; } - if (name.contains("практ")) { + if (practice) { return PRACTICE; } - throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType() + + "\" должен быть лекцией, практикой или лабораторной работой"); } } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java index c0bbb1c..95d49b8 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -1,5 +1,6 @@ package com.magistr.app.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import java.time.LocalDate; @@ -89,7 +90,7 @@ public class ScheduleRule { } public void setStatus(String status) { - this.status = status; + this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status; } 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) { return valueOrDefault(value, 0); } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 9449bd6..c0215a1 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -16,7 +16,6 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Service @@ -34,7 +33,6 @@ public class ScheduleGeneratorService { private final TimeSlotRepository timeSlotRepository; private final TimeSlotScopeRepository timeSlotScopeRepository; private final TimeSlotDateAssignmentRepository dateAssignmentRepository; - private final Map consumedHoursCache = new ConcurrentHashMap<>(); public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, GroupRepository groupRepository, @@ -58,8 +56,10 @@ public class ScheduleGeneratorService { .orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); Map> rulesBySemester = new HashMap<>(); + Map consumedHoursProgress = new HashMap<>(); List result = new ArrayList<>(); Set warnedAcademicYears = new HashSet<>(); + Set primedSemesterIds = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { Optional semesterOpt = academicDateService.findSemester(date); @@ -81,9 +81,12 @@ public class ScheduleGeneratorService { semester.getId(), semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) ); + if (primedSemesterIds.add(semester.getId())) { + primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress); + } 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> rulesBySemester = new HashMap<>(); + Map consumedHoursProgress = new HashMap<>(); List result = new ArrayList<>(); + Set primedSemesterIds = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { Optional semesterOpt = academicDateService.findSemester(date); @@ -110,9 +115,12 @@ public class ScheduleGeneratorService { semester.getId(), semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) ); + if (primedSemesterIds.add(semester.getId())) { + primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress); + } 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() { - consumedHoursCache.clear(); + // Расход часов считается в контексте одного построения расписания. + // Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных. } private void validateRange(LocalDate startDate, LocalDate endDate) { @@ -152,10 +161,34 @@ public class ScheduleGeneratorService { .toList(); } - private void renderRuleForDate(ScheduleRule rule, + private void primeConsumedHoursBeforeRange(Semester semester, + List rules, + LocalDate rangeStart, + StudentGroup targetGroup, + Long targetTeacherId, + Map consumedHoursProgress) { + LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1)); + if (!semester.getStartDate().isBefore(endExclusive)) { + return; + } + + List 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, StudentGroup targetGroup, Long targetTeacherId, + Map consumedHoursProgress, List result) { if (rule.getLectureAcademicHours() == null || rule.getLaboratoryAcademicHours() == null @@ -170,6 +203,9 @@ public class ScheduleGeneratorService { if (!rule.getSubject().isActiveOn(date)) { return; } + if (!rule.isActiveOn(date)) { + return; + } Semester semester = semesterOpt.get(); List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); @@ -193,8 +229,6 @@ public class ScheduleGeneratorService { return; } - Map consumedHoursByBucket = new HashMap<>(); - for (ScheduleRuleSlot slot : slots) { if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) { continue; @@ -217,11 +251,8 @@ public class ScheduleGeneratorService { List activeScopes = new ArrayList<>(); for (LessonScope scope : lessonScopes) { - String consumptionBucket = consumptionBucket(category, scope.subgroupId()); - int consumedHours = consumedHoursByBucket.computeIfAbsent( - consumptionBucket, - key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId()) - ); + String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId()); + int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0); if (consumedHours >= totalHours) { continue; } @@ -233,28 +264,30 @@ public class ScheduleGeneratorService { continue; } - Map lessonGroupById = new LinkedHashMap<>(); - Map lessonSubgroupById = new LinkedHashMap<>(); - activeScopes.forEach(scope -> { - scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); - scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); - }); - List lessonGroups = new ArrayList<>(lessonGroupById.values()); - List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); - int consumedHours = activeScopes.stream() - .mapToInt(ActiveLessonScope::consumedHours) - .min() - .orElse(0); - int remainingAfterLesson = activeScopes.stream() - .mapToInt(ActiveLessonScope::remainingAfterLesson) - .min() - .orElse(0); + if (result != null) { + Map lessonGroupById = new LinkedHashMap<>(); + Map lessonSubgroupById = new LinkedHashMap<>(); + activeScopes.forEach(scope -> { + scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); + scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); + }); + List lessonGroups = new ArrayList<>(lessonGroupById.values()); + List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); + int consumedHours = activeScopes.stream() + .mapToInt(ActiveLessonScope::consumedHours) + .min() + .orElse(0); + int remainingAfterLesson = activeScopes.stream() + .mapToInt(ActiveLessonScope::remainingAfterLesson) + .min() + .orElse(0); - result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, - totalHours, consumedHours, remainingAfterLesson)); + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, + totalHours, consumedHours, remainingAfterLesson)); + } for (ActiveLessonScope scope : activeScopes) { - consumedHoursByBucket.put( - consumptionBucket(category, scope.scope().subgroupId()), + consumedHoursProgress.put( + consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()), scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT ); } @@ -275,62 +308,20 @@ public class ScheduleGeneratorService { .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 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) { 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 lessonScopesForSlot(ScheduleRuleSlot slot, ScheduleLessonCategory category, List eligibleGroups) { diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java index 348044f..312f048 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -15,6 +15,8 @@ import java.util.stream.Collectors; @Service public class ScheduleQueryService { + private static final int MAX_GROUPS_WITHOUT_SCOPE = 50; + private final ScheduleGeneratorService scheduleGeneratorService; private final GroupRepository groupRepository; private final ScheduleOverrideRepository scheduleOverrideRepository; @@ -37,10 +39,15 @@ public class ScheduleQueryService { String parity, LocalDate startDate, LocalDate endDate) { - List groups = resolveGroups(groupId, departmentId); - List generatedLessons = groups.stream() - .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) - .toList(); + List generatedLessons; + if (groupId == null && departmentId == null && teacherId != null) { + generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); + } else { + List groups = resolveGroups(groupId, departmentId); + generatedLessons = groups.stream() + .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) + .toList(); + } List lessons = applyOverrides(generatedLessons, startDate, endDate).stream() .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId)) .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) @@ -142,7 +149,12 @@ public class ScheduleQueryService { if (departmentId != null) { return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); } - return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + List 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 deduplicate(List lessons) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index a1d4852..ed1e51f 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -3,7 +3,7 @@ server.port=8080 # PostgreSQL (дефолтный — для локальной разработки через Docker Compose) spring.datasource.url=jdbc:postgresql://db:5432/app_db 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 # JPA diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java new file mode 100644 index 0000000..3820475 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java new file mode 100644 index 0000000..20e4841 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java @@ -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 body = (Map) 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(); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java new file mode 100644 index 0000000..37824a1 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java new file mode 100644 index 0000000..b751e4e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java @@ -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 body = (List) 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; + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java new file mode 100644 index 0000000..502f7ef --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java @@ -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; + } +} diff --git a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java new file mode 100644 index 0000000..8d78d5e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java b/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java new file mode 100644 index 0000000..8b47ec0 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java @@ -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; + } +} diff --git a/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java b/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java new file mode 100644 index 0000000..f6f0bfe --- /dev/null +++ b/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java new file mode 100644 index 0000000..def9e5b --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java @@ -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; + } +} diff --git a/backend/tenants.json b/backend/tenants.example.json old mode 100755 new mode 100644 similarity index 74% rename from backend/tenants.json rename to backend/tenants.example.json index 704573b..3f65553 --- a/backend/tenants.json +++ b/backend/tenants.example.json @@ -4,6 +4,6 @@ "domain": "default", "url": "jdbc:postgresql://db:5432/app_db", "username": "myuser", - "password": "supersecretpassword" + "password": "replace-with-local-password" } -] \ No newline at end of file +] diff --git a/compose.yaml b/compose.yaml index a5bd0a0..05cf62d 100755 --- a/compose.yaml +++ b/compose.yaml @@ -32,11 +32,11 @@ services: ports: - "5432:5432" environment: - POSTGRES_USER: myuser - POSTGRES_PASSWORD: supersecretpassword - POSTGRES_DB: app_db + POSTGRES_USER: ${POSTGRES_USER:-myuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env} + POSTGRES_DB: ${POSTGRES_DB:-app_db} healthcheck: - test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ] + test: [ "CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"" ] interval: 5s timeout: 5s retries: 5 diff --git a/docs/API.md b/docs/API.md index a7088c8..8692cd6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,6 +2,20 @@ Все эндпоинты имеют префикс `/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 [ - { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" }, - { "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" } + { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" }, + { "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" } ] ``` +`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`. + ### `GET /api/users/teachers` Список только преподавателей (роль `TEACHER`). ### `GET /api/users/teachers/{departmentId}` -Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). +Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`. ### `POST /api/users` @@ -446,6 +464,8 @@ CRUD доступен по: | `timeSlotId` | Временной слот | | `parity` | `BOTH`, `ODD`, `EVEN` | +Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру. + Пример: ```http @@ -939,7 +959,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 | Код | Описание | |-----|----------| | `200` | Успех | -| `400` | Ошибка валидации (с `message` в теле) | +| `400` | Ошибка валидации или некорректные параметры запроса | | `401` | Неверные учётные данные | | `404` | Ресурс / тенант не найден | | `500` | Внутренняя ошибка сервера | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 13d7ecb..ce6e9b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ graph TD - **Порт:** 8080 (внутренний) - **ORM:** Hibernate (JPA), `ddl-auto=none` - **Миграции:** Flyway (программный запуск при подключении тенанта) -- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей +- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей ### PostgreSQL - **Версия:** `postgres:alpine3.23` @@ -80,6 +80,7 @@ sequenceDiagram | `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта | | `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД | | `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | +| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое | | `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | | `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API | | `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` | @@ -99,7 +100,7 @@ sequenceDiagram ### Конфигурация тенантов Список тенантов хранится в JSON-файле: -- **Локально:** `backend/tenants.json` +- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`) - **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json` Формат: @@ -121,12 +122,16 @@ sequenceDiagram 2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты 3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap +`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии. + ### Fallback при отсутствии тенантов Если при запуске нет ни одного настроенного тенанта: 1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` 2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA +`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках. + --- ## Аутентификация diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 2fa695c..088bcdd 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -90,6 +90,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия. +Методическая проверка активности учитывает и период действия, и `status`: запись со статусом `ARCHIVED` не считается активной даже без заполненного `active_to`. + ### Перевод преподавателей между кафедрами Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`. @@ -136,6 +138,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. 11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах. +Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп. + +В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра. + +Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп. + Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. @@ -167,6 +175,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие - **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. - **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах. +- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации. - **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю. ### Точечные изменения расписания diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 240625c..133cf7b 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -644,6 +644,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `version_group_id` | BIGINT | Группа версий одного правила | | `change_reason` | TEXT | Причина изменения | +`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`. + #### `schedule_rule_groups` — Группы правила | Колонка | Тип | Описание | |---------|-----|----------| diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8e7be9d..e3a3a74 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -254,11 +254,13 @@ com.magistr.app/ │ ├── TenantContext.java # ThreadLocal текущего тенанта │ ├── TenantInterceptor.java # Определение тенанта из Host │ ├── TenantRoutingDataSource.java # Маршрутизация к БД -│ ├── TenantDataSourceConfig.java # Spring-конфигурация +│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA +│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов │ ├── TenantConfigWatcher.java # Периодическая синхронизация │ └── ConfigMapUpdater.java # Обновление K8s ConfigMap ├── controller/ # REST-контроллеры │ ├── AuthController.java +│ ├── GlobalExceptionHandler.java │ ├── ScheduleController.java │ ├── ClassroomController.java │ ├── DatabaseController.java diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index ad899bd..0a23cea 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -25,18 +25,21 @@ docker network create proxy ```env 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_ACCESS_TOKEN_TTL=15m 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) 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` ### Dockerfile (Frontend)