исправил ошибки и проблемы безопасности

This commit is contained in:
Zuev
2026-05-27 20:29:50 +03:00
parent c7cb5125f4
commit e9575cf2ce
36 changed files with 897 additions and 376 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
db/data/ db/data/
.env .env
backend/tenants.json
# Игнорируем временные файлы сборки (на будущее) # Игнорируем временные файлы сборки (на будущее)
backend/target/ backend/target/

View File

@@ -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();
} }
} }

View File

@@ -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();
} }

View File

@@ -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<>();
} }
} }

View File

@@ -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/**");
}
}

View File

@@ -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) {

View File

@@ -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()));
} }
} }

View File

@@ -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();
};
}
}

View File

@@ -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,8 +41,7 @@ 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,
@@ -54,15 +52,6 @@ public class ScheduleController {
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", "Произошла ошибка при генерации расписания"));
}
} }
} }

View File

@@ -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,7 +39,6 @@ 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,
@@ -53,11 +51,5 @@ public class ScheduleSearchController {
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", "Произошла ошибка при поиске расписания"));
}
} }
} }

View File

@@ -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,75 +51,31 @@ 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() List<UserResponse> response = users.stream()
.map(u -> { .map(this::toUserResponse)
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(); .toList();
logger.info("Получено {} пользователей", response.size()); logger.info("Получено {} пользователей", response.size());
return response; 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);
List<UserResponse> 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(); .toList();
logger.info("Получено {} преподавателей", response.size()); logger.info("Получено {} преподавателей", response.size());
return response; 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,
@@ -134,32 +89,15 @@ public class UserController {
} }
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
return ResponseEntity.ok(users.stream()
List<UserResponse> userResponses = users.stream() .map(this::toUserResponse)
.map( user -> { .toList());
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("Произошла ошибка при получении списка преподавателей");
}
} }
@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);
@@ -193,7 +131,7 @@ public class UserController {
Role role; Role role;
try { try {
role = Role.valueOf(request.getRole()); role = Role.valueOf(request.getRole());
} catch (Exception e) { } catch (IllegalArgumentException | NullPointerException e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage()); logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль")); return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
} }
@@ -221,13 +159,7 @@ public class UserController {
} }
logger.info("Пользователь успешно создан с ID: {}", user.getId()); logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(toUserResponse(user));
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()));
}
} }
@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(),

View File

@@ -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();

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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()
+ "\" должен быть лекцией, практикой или лабораторной работой");
} }
} }

View File

@@ -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);
} }

View File

@@ -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,6 +264,7 @@ public class ScheduleGeneratorService {
continue; continue;
} }
if (result != null) {
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>(); Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>(); Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
activeScopes.forEach(scope -> { activeScopes.forEach(scope -> {
@@ -252,9 +284,10 @@ public class ScheduleGeneratorService {
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,60 +308,18 @@ public class ScheduleGeneratorService {
.toList(); .toList();
} }
private int calculateConsumedHoursBeforeDate(ScheduleRule rule, private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
}
private String consumptionProgressKey(ScheduleRule rule,
StudentGroup targetGroup, StudentGroup targetGroup,
LocalDate targetDate,
ScheduleLessonCategory category, ScheduleLessonCategory category,
Long subgroupId) { Long subgroupId) {
Long groupId = targetGroup == null ? null : targetGroup.getId(); Long groupId = targetGroup == null ? null : targetGroup.getId();
String cacheKey = rule.getId() return rule.getId()
+ ":" + category.name() + ":" + consumptionBucket(category, subgroupId)
+ ":" + (subgroupId == null ? "whole" : subgroupId) + ":" + (groupId == null ? "all" : groupId);
+ ":" + (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) {
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
} }
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot, private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,

View File

@@ -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<RenderedLessonDto> generatedLessons;
if (groupId == null && departmentId == null && teacherId != null) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else {
List<StudentGroup> groups = resolveGroups(groupId, departmentId); List<StudentGroup> groups = resolveGroups(groupId, departmentId);
List<RenderedLessonDto> generatedLessons = groups.stream() generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList(); .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) {

View File

@@ -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

View File

@@ -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"));
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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
View 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"
} }
] ]

View File

@@ -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

View File

@@ -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` | Внутренняя ошибка сервера |

View File

@@ -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 на асинхронных участках.
--- ---
## Аутентификация ## Аутентификация

View File

@@ -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` запрещает новые назначения, но не удаляет историю.
### Точечные изменения расписания ### Точечные изменения расписания

View File

@@ -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` — Группы правила
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|

View File

@@ -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

View File

@@ -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)