исправил ошибки и проблемы безопасности
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
db/data/
|
||||
.env
|
||||
backend/tenants.json
|
||||
|
||||
# Игнорируем временные файлы сборки (на будущее)
|
||||
backend/target/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class TenantWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||
private final AuthorizationInterceptor authorizationInterceptor;
|
||||
|
||||
public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
|
||||
AuthorizationInterceptor authorizationInterceptor) {
|
||||
this.tenantRoutingDataSource = tenantRoutingDataSource;
|
||||
this.authorizationInterceptor = authorizationInterceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TenantInterceptor tenantInterceptor() {
|
||||
TenantInterceptor interceptor = new TenantInterceptor();
|
||||
interceptor.setRoutingDataSource(tenantRoutingDataSource);
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import 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) {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
|
||||
HttpServletRequest request) {
|
||||
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||
? "Некорректный запрос"
|
||||
: exception.getMessage();
|
||||
return error(HttpStatus.BAD_REQUEST, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoSuchElementException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException exception,
|
||||
HttpServletRequest request) {
|
||||
String message = exception.getMessage() == null || exception.getMessage().isBlank()
|
||||
? "Запрошенная запись не найдена"
|
||||
: exception.getMessage();
|
||||
return error(HttpStatus.NOT_FOUND, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
HttpMessageNotReadableException.class,
|
||||
MethodArgumentTypeMismatchException.class
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> handleBadRequest(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
|
||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now().toString());
|
||||
body.put("status", status.value());
|
||||
body.put("error", reason(status));
|
||||
body.put("message", message);
|
||||
body.put("path", request.getRequestURI());
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private String reason(HttpStatus status) {
|
||||
return switch (status) {
|
||||
case BAD_REQUEST -> "Некорректный запрос";
|
||||
case NOT_FOUND -> "Не найдено";
|
||||
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||
default -> status.name();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -42,8 +41,7 @@ public class ScheduleController {
|
||||
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
||||
}
|
||||
|
||||
try {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(
|
||||
return ResponseEntity.ok(scheduleQueryService.search(
|
||||
groupId,
|
||||
teacherId,
|
||||
null,
|
||||
@@ -54,15 +52,6 @@ public class ScheduleController {
|
||||
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", "Произошла ошибка при генерации расписания"));
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +39,6 @@ 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,
|
||||
@@ -53,11 +51,5 @@ public class ScheduleSearchController {
|
||||
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", "Произошла ошибка при поиске расписания"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,75 +51,31 @@ 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;
|
||||
})
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} пользователей", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@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;
|
||||
})
|
||||
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} преподавателей", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -134,32 +89,15 @@ public class UserController {
|
||||
}
|
||||
|
||||
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("Произошла ошибка при получении списка преподавателей");
|
||||
}
|
||||
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);
|
||||
@@ -193,7 +131,7 @@ public class UserController {
|
||||
Role role;
|
||||
try {
|
||||
role = Role.valueOf(request.getRole());
|
||||
} catch (Exception e) {
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||
}
|
||||
@@ -221,13 +159,7 @@ public class UserController {
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -71,6 +71,10 @@ public class UserResponse {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
+ "\" должен быть лекцией, практикой или лабораторной работой");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,6 +264,7 @@ public class ScheduleGeneratorService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
||||
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
||||
activeScopes.forEach(scope -> {
|
||||
@@ -252,9 +284,10 @@ public class ScheduleGeneratorService {
|
||||
|
||||
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,60 +308,18 @@ public class ScheduleGeneratorService {
|
||||
.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,
|
||||
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);
|
||||
return rule.getId()
|
||||
+ ":" + consumptionBucket(category, subgroupId)
|
||||
+ ":" + (groupId == null ? "all" : groupId);
|
||||
}
|
||||
|
||||
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
||||
|
||||
@@ -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<RenderedLessonDto> generatedLessons;
|
||||
if (groupId == null && departmentId == null && teacherId != null) {
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
} else {
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
|
||||
List<RenderedLessonDto> generatedLessons = groups.stream()
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TenantConfigWatcherTest {
|
||||
|
||||
@Test
|
||||
void configHashUsesSha256Hex() {
|
||||
String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
|
||||
|
||||
assertThat(hash).hasSize(64);
|
||||
assertThat(hash).matches("[0-9a-f]{64}");
|
||||
assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void configHashChangesWhenContentChanges() {
|
||||
assertThat(TenantConfigWatcher.configHash("tenant-a"))
|
||||
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||
import com.magistr.app.model.Role;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuthControllerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearAuthContext() {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void meAllowsNullDepartmentId() {
|
||||
AuthController controller = new AuthController(null, null, null, null, null);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(10L, "admin", Role.ADMIN, null));
|
||||
|
||||
ResponseEntity<?> response = controller.me();
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isInstanceOf(Map.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> body = (Map<String, Object>) response.getBody();
|
||||
assertThat(body).containsEntry("userId", 10L);
|
||||
assertThat(body).containsEntry("username", "admin");
|
||||
assertThat(body).containsEntry("role", "ADMIN");
|
||||
assertThat(body).containsKey("departmentId");
|
||||
assertThat(body.get("departmentId")).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.repository.SubjectCommentRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DepartmentWorkspaceControllerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearAuthContext() {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void commentsRejectSubjectWithoutDepartmentForDepartmentUserWithoutNpe() {
|
||||
SubjectRepository subjectRepository = mock(SubjectRepository.class);
|
||||
Subject subject = new Subject();
|
||||
subject.setId(10L);
|
||||
subject.setDepartmentId(null);
|
||||
when(subjectRepository.findById(10L)).thenReturn(Optional.of(subject));
|
||||
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||
subjectRepository,
|
||||
mock(SubjectCommentRepository.class),
|
||||
mock(UserRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||
|
||||
var response = controller.getComments(10L);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void teachersUseUnifiedUserResponseMapping() {
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||
UserController controller = new UserController(
|
||||
userRepository,
|
||||
null,
|
||||
departmentRepository,
|
||||
mock(TeacherDepartmentAssignmentRepository.class)
|
||||
);
|
||||
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
|
||||
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
|
||||
.thenReturn(List.of(teacher));
|
||||
when(departmentRepository.findById(2L))
|
||||
.thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
|
||||
|
||||
var response = controller.getTeachersByDepartmentId(2L);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<UserResponse> body = (List<UserResponse>) response.getBody();
|
||||
assertThat(body).hasSize(1);
|
||||
assertThat(body.get(0).getUsername()).isEqualTo("teacher");
|
||||
assertThat(body.get(0).getDepartmentId()).isEqualTo(2L);
|
||||
assertThat(body.get(0).getDepartmentName()).isEqualTo("Кафедра ВТ");
|
||||
assertThat(body.get(0).getStatus()).isEqualTo(LifecycleEntity.STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
private User user(Long id, String username, Role role, Long departmentId) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername(username);
|
||||
user.setRole(role);
|
||||
user.setFullName("Тестовый пользователь");
|
||||
user.setJobTitle("Преподаватель");
|
||||
user.setDepartmentId(departmentId);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class WorkloadControllerTest {
|
||||
|
||||
@Test
|
||||
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
|
||||
WorkloadController controller = new WorkloadController(
|
||||
scheduleQueryService,
|
||||
classroomRepository,
|
||||
mock(UserRepository.class),
|
||||
mock(DepartmentRepository.class)
|
||||
);
|
||||
when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||
.thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
|
||||
|
||||
var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
|
||||
|
||||
assertThat(result).extracting("id").containsExactly(3L);
|
||||
}
|
||||
|
||||
private Classroom classroom(Long id, Boolean available) {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setId(id);
|
||||
classroom.setName("Аудитория " + id);
|
||||
classroom.setCapacity(30);
|
||||
classroom.setIsAvailable(available);
|
||||
return classroom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LifecycleEntityTest {
|
||||
|
||||
@Test
|
||||
void archivedEntityIsNotActiveOnDateWithoutActiveTo() {
|
||||
Subject subject = new Subject();
|
||||
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
assertThat(subject.isActiveOn(LocalDate.now())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeEntityUsesDateRange() {
|
||||
Subject subject = new Subject();
|
||||
subject.setActiveFrom(LocalDate.of(2026, 1, 10));
|
||||
subject.setActiveTo(LocalDate.of(2026, 1, 20));
|
||||
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 9))).isFalse();
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 10))).isTrue();
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ScheduleLessonCategoryTest {
|
||||
|
||||
@Test
|
||||
void mapsCanonicalLessonTypes() {
|
||||
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лекция")))
|
||||
.isEqualTo(ScheduleLessonCategory.LECTURE);
|
||||
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Практика")))
|
||||
.isEqualTo(ScheduleLessonCategory.PRACTICE);
|
||||
assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лабораторная работа")))
|
||||
.isEqualTo(ScheduleLessonCategory.LABORATORY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAmbiguousLessonType() {
|
||||
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Лабораторно-практическое занятие")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("неоднозначен");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedLessonType() {
|
||||
assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Консультация")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("должен быть лекцией");
|
||||
}
|
||||
|
||||
private LessonType lessonType(String name) {
|
||||
LessonType lessonType = new LessonType();
|
||||
lessonType.setLessonType(name);
|
||||
return lessonType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ScheduleRuleTest {
|
||||
|
||||
@Test
|
||||
void activeOnUsesStatusAndValidityRange() {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setValidFrom(LocalDate.of(2026, 2, 1));
|
||||
rule.setValidTo(LocalDate.of(2026, 2, 10));
|
||||
|
||||
assertThat(rule.isActiveOn(LocalDate.of(2026, 1, 31))).isFalse();
|
||||
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 1))).isTrue();
|
||||
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 10))).isTrue();
|
||||
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 11))).isFalse();
|
||||
|
||||
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 5))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveAndRestoreToggleStatus() {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
|
||||
rule.archive();
|
||||
|
||||
assertThat(rule.isArchivedRecord()).isTrue();
|
||||
assertThat(rule.isActiveOn(LocalDate.now())).isFalse();
|
||||
|
||||
rule.restore();
|
||||
|
||||
assertThat(rule.isActiveRecord()).isTrue();
|
||||
assertThat(rule.getValidTo()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleQueryServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsTooBroadGroupSearch() {
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
when(groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||
.thenReturn(IntStream.rangeClosed(1, 51)
|
||||
.mapToObj(this::group)
|
||||
.toList());
|
||||
ScheduleQueryService service = new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
groupRepository,
|
||||
mock(ScheduleOverrideRepository.class)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.search(null, null, null, null,
|
||||
null, null, null, null,
|
||||
LocalDate.of(2026, 5, 1),
|
||||
LocalDate.of(2026, 5, 7)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Уточните группу или кафедру");
|
||||
}
|
||||
|
||||
@Test
|
||||
void teacherSearchDoesNotLoadAllGroups() {
|
||||
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||
ScheduleQueryService service = new ScheduleQueryService(generatorService, groupRepository, overrideRepository);
|
||||
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||
LocalDate endDate = LocalDate.of(2026, 5, 7);
|
||||
when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
|
||||
|
||||
service.search(null, 10L, null, null,
|
||||
null, null, null, null,
|
||||
startDate,
|
||||
endDate);
|
||||
|
||||
verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
|
||||
verify(groupRepository, never()).findByStatusNot(any());
|
||||
}
|
||||
|
||||
private StudentGroup group(int id) {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId((long) id);
|
||||
group.setName("Группа " + id);
|
||||
return group;
|
||||
}
|
||||
}
|
||||
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
@@ -4,6 +4,6 @@
|
||||
"domain": "default",
|
||||
"url": "jdbc:postgresql://db:5432/app_db",
|
||||
"username": "myuser",
|
||||
"password": "supersecretpassword"
|
||||
"password": "replace-with-local-password"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
28
docs/API.md
28
docs/API.md
@@ -2,6 +2,20 @@
|
||||
|
||||
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
||||
|
||||
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-05-27T19:47:54",
|
||||
"status": 400,
|
||||
"error": "Некорректный запрос",
|
||||
"message": "Некорректные параметры запроса",
|
||||
"path": "/api/schedule"
|
||||
}
|
||||
```
|
||||
|
||||
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
@@ -105,6 +119,8 @@ Refresh-токен ротируется при каждом успешном о
|
||||
}
|
||||
```
|
||||
|
||||
`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
|
||||
|
||||
---
|
||||
|
||||
## Пользователи
|
||||
@@ -116,18 +132,20 @@ Refresh-токен ротируется при каждом успешном о
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
|
||||
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
|
||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
|
||||
{ "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
|
||||
]
|
||||
```
|
||||
|
||||
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
||||
|
||||
### `GET /api/users/teachers`
|
||||
|
||||
Список только преподавателей (роль `TEACHER`).
|
||||
|
||||
### `GET /api/users/teachers/{departmentId}`
|
||||
|
||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
|
||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||||
|
||||
### `POST /api/users`
|
||||
|
||||
@@ -446,6 +464,8 @@ CRUD доступен по:
|
||||
| `timeSlotId` | Временной слот |
|
||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||
|
||||
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
|
||||
|
||||
Пример:
|
||||
|
||||
```http
|
||||
@@ -939,7 +959,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| `200` | Успех |
|
||||
| `400` | Ошибка валидации (с `message` в теле) |
|
||||
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||||
| `401` | Неверные учётные данные |
|
||||
| `404` | Ресурс / тенант не найден |
|
||||
| `500` | Внутренняя ошибка сервера |
|
||||
|
||||
@@ -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 на асинхронных участках.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
@@ -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` запрещает новые назначения, но не удаляет историю.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
@@ -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` — Группы правила
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user