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

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

3
.gitignore vendored
View File

@@ -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
skills-lock.json

View File

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

View File

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

View File

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

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 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<String, Object> 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) {

View File

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

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

View File

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

View File

@@ -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<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
try {
List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(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();
logger.info("Получено {} пользователей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
throw e;
}
List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> 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<UserResponse> getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
try {
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();
logger.info("Получено {} преподавателей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
throw e;
}
List<UserResponse> 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<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
Role.TEACHER,
departmentId,
LifecycleEntity.STATUS_ARCHIVED
);
List<User> 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<UserResponse> userResponses = users.stream()
.map( user -> {
return new UserResponse(
user.getId(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
}).toList();
return ResponseEntity.ok(userResponses);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка преподавателей");
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(),

View File

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

View File

@@ -71,6 +71,10 @@ public class UserResponse {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public String getStatus() {
return status;
}

View File

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

View File

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

View File

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

View File

@@ -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<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
@@ -58,8 +56,10 @@ public class ScheduleGeneratorService {
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> warnedAcademicYears = new HashSet<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
@@ -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<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
Map<String, Integer> consumedHoursProgress = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
@@ -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<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,
StudentGroup targetGroup,
Long targetTeacherId,
Map<String, Integer> consumedHoursProgress,
List<RenderedLessonDto> 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<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
@@ -193,8 +229,6 @@ public class ScheduleGeneratorService {
return;
}
Map<String, Integer> 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<ActiveLessonScope> 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<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
Map<Long, Subgroup> 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<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
List<Subgroup> 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<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
Map<Long, Subgroup> 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<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
List<Subgroup> 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<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 String consumptionProgressKey(ScheduleRule rule,
StudentGroup targetGroup,
ScheduleLessonCategory category,
Long subgroupId) {
Long groupId = targetGroup == null ? null : targetGroup.getId();
return rule.getId()
+ ":" + consumptionBucket(category, subgroupId)
+ ":" + (groupId == null ? "all" : groupId);
}
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
ScheduleLessonCategory category,
List<StudentGroup> eligibleGroups) {

View File

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

View File

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

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

4
backend/tenants.json → backend/tenants.example.json Executable file → Normal file
View File

@@ -4,6 +4,6 @@
"domain": "default",
"url": "jdbc:postgresql://db:5432/app_db",
"username": "myuser",
"password": "supersecretpassword"
"password": "replace-with-local-password"
}
]
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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