diff --git a/.agents/skills/AutoUpdateDocs.md b/.agents/skills/AutoUpdateDocs.md new file mode 100644 index 0000000..8910bf4 --- /dev/null +++ b/.agents/skills/AutoUpdateDocs.md @@ -0,0 +1,85 @@ +--- +name: AutoUpdateDocs +description: Автоматическое обновление документации проекта после изменений в коде +--- + +# Скилл: Автоматическое обновление документации + +## Когда активировать + +Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих: + +- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md` +- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md` +- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md` +- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md` +- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md` +- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md` +- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md` +- **Общая структура проекта** → обновить `docs/README.md` + +## Карта соответствия «файл → документация» + +| Изменённый файл/директория | Файл документации | +|----------------------------|-------------------| +| `controller/*Controller.java` | `docs/API.md` | +| `db/migration/V*__.sql` | `docs/DATABASE.md` | +| `model/*.java` | `docs/DATABASE.md` | +| `dto/*.java` | `docs/API.md` | +| `config/tenant/*.java` | `docs/ARCHITECTURE.md` | +| `utils/*.java` | `docs/BUSINESS_LOGIC.md` | +| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` | +| `frontend/admin/css/*.css` | `docs/FRONTEND.md` | +| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` | +| `application.properties` | `docs/ARCHITECTURE.md` | + +## Пошаговая инструкция + +### 1. Определить затронутые файлы документации + +После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить. + +### 2. Прочитать текущую документацию + +Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить. + +### 3. Внести точечные изменения + +Обновить **только затронутые секции**, не переписывая весь файл. Примеры: + +#### Новый контроллер → `docs/API.md` +Добавить новую секцию с описанием эндпоинтов: +- Метод + URL +- Тело запроса (JSON пример) +- Ответ (JSON пример) +- Валидация + +#### Новая миграция → `docs/DATABASE.md` +- Добавить новую таблицу в ER-диаграмму (Mermaid) +- Добавить описание таблицы и колонок +- Добавить запись в таблицу «Текущие миграции» + +#### Новый view → `docs/FRONTEND.md` +- Добавить в дерево файлов +- Добавить в таблицу «Разделы админ-панели» + +### 4. Обновить AGENTS.md (при необходимости) + +Если изменения затрагивают: +- Структуру директорий → обновить дерево в `AGENTS.md` +- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила» + +### 5. Сообщить пользователю + +В конце ответа кратко упомянуть, какие файлы документации были обновлены: + +> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`) + +## Правила + +1. **Язык:** Вся документация на русском языке +2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода) +3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя +4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md` +5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения +6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения diff --git a/.codex/config.toml b/.codex/config.toml index 2ef8f23..86665bd 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -3,7 +3,7 @@ web_search = "live" developer_instructions = """ -Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент. +Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент. Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов. При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня. Используй проектные скиллы из .agents/skills, когда задача соответствует их description. diff --git a/.gitignore b/.gitignore index 9647f9d..42d19e5 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ db/data/ .env +backend/tenants.json # Игнорируем временные файлы сборки (на будущее) backend/target/ @@ -10,4 +11,4 @@ frontend/dist/ .idea/ .vscode/ *.DS_Store -skills-lock.json \ No newline at end of file +skills-lock.json diff --git a/AGENTS.md b/AGENTS.md index 4686c47..4b00695 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ docker compose logs -f backend ## Критические правила для агентов ### Flyway миграции -- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. +- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу) - Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/` - Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md) diff --git a/backend/pom.xml b/backend/pom.xml index 54b385f..6759af9 100755 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -50,6 +50,12 @@ spring-security-crypto + + + org.springframework.security + spring-security-oauth2-jose + + com.h2database @@ -63,6 +69,13 @@ opentelemetry-api 1.49.0 + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java b/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java deleted file mode 100644 index 3ccf675..0000000 --- a/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.magistr.app.config.auth; - -import com.magistr.app.model.User; -import org.springframework.stereotype.Service; - -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -@Service -public class AuthSessionService { - - private final Map sessions = new ConcurrentHashMap<>(); - - public String createSession(User user) { - String token = UUID.randomUUID().toString(); - sessions.put(token, new AuthenticatedUser( - user.getId(), - user.getUsername(), - user.getRole(), - user.getDepartmentId() - )); - return token; - } - - public Optional findByToken(String token) { - if (token == null || token.isBlank()) { - return Optional.empty(); - } - return Optional.ofNullable(sessions.get(token)); - } -} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java index d07ae1d..8dcd92f 100644 --- a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java @@ -15,11 +15,11 @@ import java.util.Map; @Component public class AuthorizationInterceptor implements HandlerInterceptor { - private final AuthSessionService sessionService; + private final JwtTokenService jwtTokenService; private final ObjectMapper objectMapper; - public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) { - this.sessionService = sessionService; + public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) { + this.jwtTokenService = jwtTokenService; this.objectMapper = objectMapper; } @@ -28,12 +28,15 @@ public class AuthorizationInterceptor implements HandlerInterceptor { if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } - if (request.getRequestURI().equals("/api/auth/login")) { + if (isPublicAuthEndpoint(request)) { return true; } String token = bearerToken(request.getHeader("Authorization")); - AuthenticatedUser user = sessionService.findByToken(token).orElse(null); + AuthenticatedUser user = jwtTokenService.authenticate( + token, + com.magistr.app.config.tenant.TenantContext.getCurrentTenant() + ).orElse(null); if (user == null) { writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему"); return false; @@ -49,6 +52,16 @@ public class AuthorizationInterceptor implements HandlerInterceptor { return true; } + private boolean isPublicAuthEndpoint(HttpServletRequest request) { + if (!request.getRequestURI().startsWith("/api/auth/")) { + return false; + } + return "POST".equalsIgnoreCase(request.getMethod()) + && (request.getRequestURI().equals("/api/auth/login") + || request.getRequestURI().equals("/api/auth/refresh") + || request.getRequestURI().equals("/api/auth/logout")); + } + @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { AuthContext.clear(); diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java b/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java new file mode 100644 index 0000000..aab5abd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java @@ -0,0 +1,66 @@ +package com.magistr.app.config.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Component +@ConfigurationProperties(prefix = "app.jwt") +public class JwtProperties { + + private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum"; + private Duration accessTtl = Duration.ofMinutes(15); + private Duration refreshTtl = Duration.ofDays(7); + private String refreshCookieName = "magistr_refresh"; + private boolean refreshCookieSecure = false; + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public Duration getAccessTtl() { + return accessTtl; + } + + public void setAccessTtl(Duration accessTtl) { + this.accessTtl = accessTtl; + } + + public Duration getRefreshTtl() { + return refreshTtl; + } + + public void setRefreshTtl(Duration refreshTtl) { + this.refreshTtl = refreshTtl; + } + + public String getRefreshCookieName() { + return refreshCookieName; + } + + public void setRefreshCookieName(String refreshCookieName) { + this.refreshCookieName = refreshCookieName; + } + + public boolean isRefreshCookieSecure() { + return refreshCookieSecure; + } + + public void setRefreshCookieSecure(boolean refreshCookieSecure) { + this.refreshCookieSecure = refreshCookieSecure; + } + + public byte[] secretBytes() { + byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт"); + } + return bytes; + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java b/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java new file mode 100644 index 0000000..49326af --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java @@ -0,0 +1,110 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; +import com.magistr.app.model.User; +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Service +public class JwtTokenService { + + private static final String ISSUER = "magistr"; + + private final JwtProperties properties; + private final JwtEncoder encoder; + private final JwtDecoder decoder; + + public JwtTokenService(JwtProperties properties) { + this.properties = properties; + SecretKey secretKey = new SecretKeySpec(properties.secretBytes(), "HmacSHA256"); + this.encoder = new NimbusJwtEncoder(new ImmutableSecret(secretKey)); + this.decoder = NimbusJwtDecoder + .withSecretKey(secretKey) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + } + + public String createAccessToken(User user, String tenant) { + return createAccessToken(new AuthenticatedUser( + user.getId(), + user.getUsername(), + user.getRole(), + user.getDepartmentId() + ), tenant); + } + + public String createAccessToken(AuthenticatedUser user, String tenant) { + Instant now = Instant.now(); + JwtClaimsSet.Builder claims = JwtClaimsSet.builder() + .issuer(ISSUER) + .issuedAt(now) + .expiresAt(now.plus(properties.getAccessTtl())) + .subject(String.valueOf(user.id())) + .id(UUID.randomUUID().toString()) + .claim("tenant", tenant) + .claim("userId", user.id()) + .claim("username", user.username()) + .claim("role", user.role().name()); + + if (user.departmentId() != null) { + claims.claim("departmentId", user.departmentId()); + } + + JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); + return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue(); + } + + public Optional authenticate(String token, String expectedTenant) { + if (token == null || token.isBlank() || expectedTenant == null || expectedTenant.isBlank()) { + return Optional.empty(); + } + + try { + Jwt jwt = decoder.decode(token); + String tenant = jwt.getClaimAsString("tenant"); + if (!expectedTenant.equalsIgnoreCase(tenant)) { + return Optional.empty(); + } + + Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject())); + String username = jwt.getClaimAsString("username"); + Role role = Role.valueOf(jwt.getClaimAsString("role")); + Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null); + + return Optional.of(new AuthenticatedUser(userId, username, role, departmentId)); + } catch (JwtException | IllegalArgumentException e) { + return Optional.empty(); + } + } + + private Optional asLong(Object value) { + if (value == null) { + return Optional.empty(); + } + if (value instanceof Number number) { + return Optional.of(number.longValue()); + } + try { + return Optional.of(Long.valueOf(String.valueOf(value))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java new file mode 100644 index 0000000..5f6d309 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java @@ -0,0 +1,6 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.User; + +public record RefreshTokenRotation(User user, String refreshToken) { +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java new file mode 100644 index 0000000..e4dff9e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java @@ -0,0 +1,132 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.AuthRefreshToken; +import com.magistr.app.model.User; +import com.magistr.app.repository.AuthRefreshTokenRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Optional; + +@Service +public class RefreshTokenService { + + private static final int TOKEN_BYTES = 32; + private static final int USER_AGENT_LIMIT = 512; + private static final int IP_LIMIT = 64; + + private final SecureRandom secureRandom = new SecureRandom(); + private final AuthRefreshTokenRepository repository; + private final JwtProperties properties; + + public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) { + this.repository = repository; + this.properties = properties; + } + + @Transactional + public String createSession(User user, String tenant, String userAgent, String ipAddress) { + String refreshToken = generateRawToken(); + AuthRefreshToken token = new AuthRefreshToken(); + LocalDateTime now = LocalDateTime.now(); + token.setUser(user); + token.setTenant(tenant); + token.setTokenHash(hashToken(refreshToken)); + token.setIssuedAt(now); + token.setExpiresAt(now.plus(properties.getRefreshTtl())); + token.setUserAgent(limit(userAgent, USER_AGENT_LIMIT)); + token.setIpAddress(limit(ipAddress, IP_LIMIT)); + repository.save(token); + return refreshToken; + } + + @Transactional + public Optional rotate(String rawToken, String tenant, String userAgent, String ipAddress) { + if (rawToken == null || rawToken.isBlank()) { + return Optional.empty(); + } + + LocalDateTime now = LocalDateTime.now(); + String currentHash = hashToken(rawToken); + Optional storedOpt = repository.findByTokenHash(currentHash); + if (storedOpt.isEmpty()) { + return Optional.empty(); + } + + AuthRefreshToken stored = storedOpt.get(); + if (!tenant.equalsIgnoreCase(stored.getTenant()) || !stored.isActive(now)) { + return Optional.empty(); + } + + User user = stored.getUser(); + if (user.isArchivedRecord()) { + stored.setRevokedAt(now); + repository.save(stored); + return Optional.empty(); + } + + String newRawToken = generateRawToken(); + String newHash = hashToken(newRawToken); + stored.setRevokedAt(now); + stored.setRotatedToTokenHash(newHash); + repository.save(stored); + + AuthRefreshToken next = new AuthRefreshToken(); + next.setUser(user); + next.setTenant(tenant); + next.setTokenHash(newHash); + next.setIssuedAt(now); + next.setExpiresAt(now.plus(properties.getRefreshTtl())); + next.setUserAgent(limit(userAgent, USER_AGENT_LIMIT)); + next.setIpAddress(limit(ipAddress, IP_LIMIT)); + repository.save(next); + + return Optional.of(new RefreshTokenRotation(user, newRawToken)); + } + + @Transactional + public void revoke(String rawToken, String tenant) { + if (rawToken == null || rawToken.isBlank()) { + return; + } + + repository.findByTokenHash(hashToken(rawToken)) + .filter(token -> tenant.equalsIgnoreCase(token.getTenant())) + .filter(token -> token.getRevokedAt() == null) + .ifPresent(token -> { + token.setRevokedAt(LocalDateTime.now()); + repository.save(token); + }); + } + + String hashToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 недоступен", e); + } + } + + private String generateRawToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String limit(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; + } + String trimmed = value.trim(); + return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java index 023c734..a69042e 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java @@ -1,12 +1,10 @@ package com.magistr.app.config.tenant; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -43,7 +41,7 @@ public class ConfigMapUpdater { public ConfigMapUpdater() { this.runningInK8s = Files.exists(Path.of(TOKEN_PATH)); if (!runningInK8s) { - log.info("Not running in K8s — ConfigMap updates will be skipped"); + log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено"); } } @@ -53,7 +51,7 @@ public class ConfigMapUpdater { */ public boolean updateTenantsConfig(List tenants) { if (!runningInK8s) { - log.warn("Not in K8s, skipping ConfigMap update"); + log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap"); return true; } @@ -87,15 +85,15 @@ public class ConfigMapUpdater { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { - log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size()); + log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size()); return true; } else { - log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body()); + log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body()); return false; } } catch (Exception e) { - log.error("Error updating ConfigMap: {}", e.getMessage()); + log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage()); return false; } } @@ -120,7 +118,7 @@ public class ConfigMapUpdater { .sslContext(sslContext) .build(); } catch (Exception e) { - log.warn("Failed to create insecure client, using default: {}", e.getMessage()); + log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage()); return HttpClient.newHttpClient(); } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java index 6cff252..e27fb62 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java @@ -10,14 +10,11 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.sql.DataSource; -import java.io.BufferedReader; import java.io.File; -import java.io.InputStream; -import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.Statement; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; import java.util.*; import java.util.stream.Collectors; @@ -58,20 +55,20 @@ public class TenantConfigWatcher { if (!file.exists()) return; String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - String hash = Integer.toHexString(content.hashCode()); + String hash = configHash(content); if (hash.equals(lastConfigHash)) { return; // Ничего не изменилось } - log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash); + log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash); lastConfigHash = hash; List newTenants = objectMapper.readValue(content, new TypeReference<>() {}); syncTenants(newTenants); } catch (Exception e) { - log.error("Error watching tenants config: {}", e.getMessage()); + log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage()); } } @@ -83,10 +80,19 @@ public class TenantConfigWatcher { File file = new File(tenantsConfigPath); if (file.exists()) { String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - lastConfigHash = Integer.toHexString(content.hashCode()); + lastConfigHash = configHash(content); } } catch (Exception e) { - log.warn("Failed to refresh config hash: {}", e.getMessage()); + log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage()); + } + } + + static String configHash(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e); } } @@ -103,7 +109,7 @@ public class TenantConfigWatcher { for (TenantConfig tenant : newTenants) { String domain = tenant.getDomain().toLowerCase(); if (!current.containsKey(domain)) { - log.info("Adding new tenant '{}' from ConfigMap update", domain); + log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain); routingDataSource.addTenant(tenant); // Инициализируем БД для нового тенанта initDatabaseForTenant(tenant); @@ -113,7 +119,7 @@ public class TenantConfigWatcher { // Удалить тенанты, которых больше нет в конфиге for (String existingDomain : new ArrayList<>(current.keySet())) { if (!newDomains.contains(existingDomain)) { - log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain); + log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain); routingDataSource.removeTenant(existingDomain); } } @@ -129,7 +135,7 @@ public class TenantConfigWatcher { try { TenantContext.setCurrentTenant(domain); - log.info("[{}] Starting Flyway migrations...", domain); + log.info("[{}] Запускаем миграции Flyway", domain); // Получаем DataSource конкретно для этого тенанта javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain); @@ -145,10 +151,10 @@ public class TenantConfigWatcher { .load(); flyway.migrate(); - log.info("[{}] Flyway migrations completed successfully", domain); + log.info("[{}] Миграции Flyway успешно выполнены", domain); } catch (Exception e) { - log.error("[{}] Flyway migration failed: {}", domain, e.getMessage()); + log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage()); } finally { TenantContext.clear(); } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java index 767f897..16d9ff6 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -2,7 +2,6 @@ package com.magistr.app.config.tenant; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.magistr.app.config.auth.AuthorizationInterceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -13,8 +12,6 @@ import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import jakarta.persistence.EntityManagerFactory; import javax.sql.DataSource; @@ -30,7 +27,7 @@ import java.util.*; * как заглушку, чтобы Spring JPA мог инициализироваться. */ @Configuration -public class TenantDataSourceConfig implements WebMvcConfigurer { +public class TenantDataSourceConfig { private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class); @@ -68,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { try { routingDataSource.addTenant(tenant); } catch (Exception e) { - log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage()); + log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage()); } } @@ -120,27 +117,6 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { return new JpaTransactionManager(emf); } - @org.springframework.context.annotation.Lazy - @org.springframework.beans.factory.annotation.Autowired - private TenantRoutingDataSource tenantRoutingDataSource; - - @org.springframework.beans.factory.annotation.Autowired - private AuthorizationInterceptor authorizationInterceptor; - - @Bean - public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) { - TenantInterceptor interceptor = new TenantInterceptor(); - interceptor.setRoutingDataSource(routingDataSource); - return interceptor; - } - - @Override - public void addInterceptors(InterceptorRegistry registry) { - // Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB) - registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**"); - registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**"); - } - private List loadTenantsFromFile() { File file = new File(tenantsConfigPath); if (!file.exists()) { @@ -154,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath); return list; } catch (IOException e) { - log.error("Failed to read tenants config: {}", e.getMessage()); + log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage()); return new ArrayList<>(); } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java new file mode 100644 index 0000000..2473306 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java @@ -0,0 +1,33 @@ +package com.magistr.app.config.tenant; + +import com.magistr.app.config.auth.AuthorizationInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class TenantWebMvcConfig implements WebMvcConfigurer { + + private final TenantRoutingDataSource tenantRoutingDataSource; + private final AuthorizationInterceptor authorizationInterceptor; + + public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource, + AuthorizationInterceptor authorizationInterceptor) { + this.tenantRoutingDataSource = tenantRoutingDataSource; + this.authorizationInterceptor = authorizationInterceptor; + } + + @Bean + public TenantInterceptor tenantInterceptor() { + TenantInterceptor interceptor = new TenantInterceptor(); + interceptor.setRoutingDataSource(tenantRoutingDataSource); + return interceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**"); + registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**"); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 5e8abd8..1f29f48 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -1,15 +1,25 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.JwtProperties; +import com.magistr.app.config.auth.JwtTokenService; +import com.magistr.app.config.auth.RefreshTokenRotation; +import com.magistr.app.config.auth.RefreshTokenService; +import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.dto.LoginRequest; import com.magistr.app.dto.LoginResponse; -import com.magistr.app.config.auth.AuthSessionService; -import com.magistr.app.config.auth.AuthContext; import com.magistr.app.model.User; import com.magistr.app.repository.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; 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; @@ -19,7 +29,9 @@ public class AuthController { private final UserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; - private final AuthSessionService sessionService; + private final JwtTokenService jwtTokenService; + private final RefreshTokenService refreshTokenService; + private final JwtProperties jwtProperties; private static final Map ROLE_REDIRECTS = Map.of( "ADMIN", "/admin/", @@ -32,14 +44,18 @@ public class AuthController { public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, - AuthSessionService sessionService) { + JwtTokenService jwtTokenService, + RefreshTokenService refreshTokenService, + JwtProperties jwtProperties) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; - this.sessionService = sessionService; + this.jwtTokenService = jwtTokenService; + this.refreshTokenService = refreshTokenService; + this.jwtProperties = jwtProperties; } @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { + public ResponseEntity login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) { Optional userOpt = userRepository.findByUsername(request.getUsername()); if (userOpt.isEmpty() || @@ -55,12 +71,52 @@ public class AuthController { .status(401) .body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null)); } - String token = sessionService.createSession(user); - String roleName = user.getRole().name(); - String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); - Long departmentId = user.getDepartmentId(); + String tenant = TenantContext.getCurrentTenant(); + String accessToken = jwtTokenService.createAccessToken(user, tenant); + String refreshToken = refreshTokenService.createSession( + user, + tenant, + servletRequest.getHeader("User-Agent"), + clientIp(servletRequest) + ); - return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString()) + .body(loginResponse(user, accessToken)); + } + + @PostMapping("/refresh") + public ResponseEntity refresh(HttpServletRequest request) { + Optional refreshToken = refreshCookieValue(request); + if (refreshToken.isEmpty()) { + return unauthorized(); + } + + Optional rotation = refreshTokenService.rotate( + refreshToken.get(), + TenantContext.getCurrentTenant(), + request.getHeader("User-Agent"), + clientIp(request) + ); + if (rotation.isEmpty()) { + return unauthorizedWithClearedCookie(); + } + + User user = rotation.get().user(); + String accessToken = jwtTokenService.createAccessToken(user, TenantContext.getCurrentTenant()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie(rotation.get().refreshToken()).toString()) + .body(loginResponse(user, accessToken)); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest request) { + refreshCookieValue(request).ifPresent(token -> + refreshTokenService.revoke(token, TenantContext.getCurrentTenant()) + ); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString()) + .body(Map.of("success", true, "message", "Выход выполнен")); } @GetMapping("/me") @@ -69,11 +125,78 @@ public class AuthController { if (user == null) { return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему")); } - return ResponseEntity.ok(Map.of( - "userId", user.id(), - "username", user.username(), - "role", user.role().name(), - "departmentId", user.departmentId() - )); + Map response = new LinkedHashMap<>(); + response.put("userId", user.id()); + response.put("username", user.username()); + response.put("role", user.role().name()); + response.put("departmentId", user.departmentId()); + return ResponseEntity.ok(response); + } + + private LoginResponse loginResponse(User user, String token) { + String roleName = user.getRole().name(); + return new LoginResponse( + true, + "OK", + token, + roleName, + ROLE_REDIRECTS.getOrDefault(roleName, "/"), + user.getDepartmentId(), + user.getId() + ); + } + + private ResponseEntity unauthorized() { + return ResponseEntity + .status(401) + .body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null)); + } + + private ResponseEntity unauthorizedWithClearedCookie() { + return ResponseEntity + .status(401) + .header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString()) + .body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null)); + } + + private ResponseCookie refreshCookie(String token) { + return ResponseCookie.from(jwtProperties.getRefreshCookieName(), token) + .httpOnly(true) + .secure(jwtProperties.isRefreshCookieSecure()) + .sameSite("Lax") + .path("/api/auth") + .maxAge(jwtProperties.getRefreshTtl()) + .build(); + } + + private ResponseCookie clearRefreshCookie() { + return ResponseCookie.from(jwtProperties.getRefreshCookieName(), "") + .httpOnly(true) + .secure(jwtProperties.isRefreshCookieSecure()) + .sameSite("Lax") + .path("/api/auth") + .maxAge(Duration.ZERO) + .build(); + } + + private Optional refreshCookieValue(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (jwtProperties.getRefreshCookieName().equals(cookie.getName())) { + return Optional.ofNullable(cookie.getValue()).filter(value -> !value.isBlank()); + } + } + return Optional.empty(); + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); } } diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java index a59fb43..a107358 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Objects; @RestController @RequestMapping("/api/department") @@ -148,6 +149,6 @@ public class DepartmentWorkspaceController { var user = AuthContext.getCurrentUser(); return user == null || user.role() == Role.ADMIN - || (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId())); + || (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId())); } } diff --git a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..5b459d5 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java @@ -0,0 +1,76 @@ +package com.magistr.app.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException exception, + HttpServletRequest request) { + String message = exception.getMessage() == null || exception.getMessage().isBlank() + ? "Некорректный запрос" + : exception.getMessage(); + return error(HttpStatus.BAD_REQUEST, message, request); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity> handleNotFound(NoSuchElementException exception, + HttpServletRequest request) { + String message = exception.getMessage() == null || exception.getMessage().isBlank() + ? "Запрошенная запись не найдена" + : exception.getMessage(); + return error(HttpStatus.NOT_FOUND, message, request); + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MethodArgumentTypeMismatchException.class + }) + public ResponseEntity> handleBadRequest(Exception exception, + HttpServletRequest request) { + log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage()); + return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception exception, + HttpServletRequest request) { + log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception); + return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request); + } + + private ResponseEntity> error(HttpStatus status, String message, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", status.value()); + body.put("error", reason(status)); + body.put("message", message); + body.put("path", request.getRequestURI()); + return ResponseEntity.status(status).body(body); + } + + private String reason(HttpStatus status) { + return switch (status) { + case BAD_REQUEST -> "Некорректный запрос"; + case NOT_FOUND -> "Не найдено"; + case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера"; + default -> status.name(); + }; + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 1d31f25..25c4f4f 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -7,14 +7,15 @@ import com.magistr.app.dto.GroupResponse; import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicYear; import com.magistr.app.model.EducationForm; -import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.Role; import com.magistr.app.model.Speciality; import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.model.StudentGroupStudyState; import com.magistr.app.repository.*; import com.magistr.app.service.ScheduleGeneratorService; +import com.magistr.app.service.StudentGroupLifecycleService; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,6 +23,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; @@ -41,6 +43,7 @@ public class GroupController { private final AcademicCalendarRepository academicCalendarRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final ScheduleGeneratorService scheduleGeneratorService; + private final StudentGroupLifecycleService groupLifecycleService; public GroupController(GroupRepository groupRepository, EducationFormRepository educationFormRepository, @@ -49,7 +52,8 @@ public class GroupController { AcademicYearRepository academicYearRepository, AcademicCalendarRepository academicCalendarRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, - ScheduleGeneratorService scheduleGeneratorService) { + ScheduleGeneratorService scheduleGeneratorService, + StudentGroupLifecycleService groupLifecycleService) { this.groupRepository = groupRepository; this.educationFormRepository = educationFormRepository; this.specialtiesRepository = specialtiesRepository; @@ -58,6 +62,7 @@ public class GroupController { this.academicCalendarRepository = academicCalendarRepository; this.assignmentRepository = assignmentRepository; this.scheduleGeneratorService = scheduleGeneratorService; + this.groupLifecycleService = groupLifecycleService; } @GetMapping @@ -67,7 +72,9 @@ public class GroupController { try { List groups = includeArchived ? groupRepository.findAll() - : groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + : groupRepository.findAll().stream() + .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now())) + .toList(); List response = groups.stream() .map(this::mapToResponse) @@ -84,7 +91,9 @@ public class GroupController { public ResponseEntity getGroupsByDepartmentId(@PathVariable Long departmentId) { logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); try { - List groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); + List groups = groupRepository.findByDepartmentId(departmentId).stream() + .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now())) + .toList(); if(groups.isEmpty()) { logger.info("Группы для кафедры с ID - {} не найдены", departmentId); @@ -264,6 +273,8 @@ public class GroupController { int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); Speciality speciality = group.getSpeciality(); SpecialtyProfile profile = group.getSpecialtyProfile(); + LocalDate today = LocalDate.now(); + StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today); return new GroupResponse( group.getId(), group.getName(), @@ -278,7 +289,11 @@ public class GroupController { speciality.getSpecialityCode(), speciality.getSpecialityName(), profile.getId(), - profile.getName() + profile.getName(), + group.getStatus(), + groupLifecycleService.isAvailableForSelection(group, today), + studyState.name(), + studyState.getLabel() ); } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java index defcf6b..eb326af 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.List; import java.util.Map; @RestController @@ -42,27 +41,17 @@ public class ScheduleController { .body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId")); } - try { - List lessons = scheduleQueryService.search( - groupId, - teacherId, - null, - null, - null, - null, - null, - null, - startDate, - endDate - ); - return ResponseEntity.ok(lessons); - } catch (IllegalArgumentException e) { - logger.info("Ошибка запроса динамического расписания: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); - } catch (Exception e) { - logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e); - return ResponseEntity.internalServerError() - .body(Map.of("message", "Произошла ошибка при генерации расписания")); - } + return ResponseEntity.ok(scheduleQueryService.search( + groupId, + teacherId, + null, + null, + null, + null, + null, + null, + startDate, + endDate + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java index 5d5c820..b83ebdd 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java @@ -10,7 +10,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.Map; @RestController @RequestMapping("/api/schedule/search") @@ -40,24 +39,17 @@ public class ScheduleSearchController { ) { logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}", groupId, teacherId, classroomId, departmentId, startDate, endDate); - try { - return ResponseEntity.ok(scheduleQueryService.search( - groupId, - teacherId, - classroomId, - departmentId, - subjectId, - lessonTypeId, - timeSlotId, - parity, - startDate, - endDate - )); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); - } catch (Exception e) { - logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e); - return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания")); - } + return ResponseEntity.ok(scheduleQueryService.search( + groupId, + teacherId, + classroomId, + departmentId, + subjectId, + lessonTypeId, + timeSlotId, + parity, + startDate, + endDate + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index e034fd2..dc088d7 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -18,7 +18,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.transaction.annotation.Transactional; @@ -52,182 +51,115 @@ public class UserController { @GetMapping public List getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех пользователей"); - try { - List users = includeArchived - ? userRepository.findAll() - : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); - - List response = users.stream() - .map(u -> { - String departmentName = departmentRepository.findById(u.getDepartmentId()) - .map(Department::getDepartmentName) - .orElse("Неизвестно"); - - UserResponse userResponse = new UserResponse( - u.getId(), - u.getUsername(), - u.getRole().name(), - u.getFullName(), - u.getJobTitle(), - departmentName); - userResponse.setStatus(u.getStatus()); - return userResponse; - }) - .toList(); - logger.info("Получено {} пользователей", response.size()); - return response; - } catch (Exception e) { - logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e); - throw e; - } - + List users = includeArchived + ? userRepository.findAll() + : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + List response = users.stream() + .map(this::toUserResponse) + .toList(); + logger.info("Получено {} пользователей", response.size()); + return response; } @GetMapping("/teachers") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public List getTeachers() { logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); - - try { - List users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED); - - List response = users.stream() - .map(u -> { - String departmentName = departmentRepository.findById(u.getDepartmentId()) - .map(Department::getDepartmentName) - .orElse("Неизвестно"); - - UserResponse userResponse = new UserResponse( - u.getId(), - u.getUsername(), - u.getRole().name(), - u.getFullName(), - u.getJobTitle(), - departmentName); - userResponse.setStatus(u.getStatus()); - return userResponse; - }) - .toList(); - logger.info("Получено {} преподавателей", response.size()); - return response; - } catch (Exception e) { - logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e); - throw e; - } + List response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream() + .map(this::toUserResponse) + .toList(); + logger.info("Получено {} преподавателей", response.size()); + return response; } @GetMapping("/teachers/{departmentId}") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getTeachersByDepartmentId(@PathVariable Long departmentId){ logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); - try { - List users = userRepository.findByRoleAndDepartmentIdAndStatusNot( - Role.TEACHER, - departmentId, - LifecycleEntity.STATUS_ARCHIVED - ); + List users = userRepository.findByRoleAndDepartmentIdAndStatusNot( + Role.TEACHER, + departmentId, + LifecycleEntity.STATUS_ARCHIVED + ); - if (users.isEmpty()) { - logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("Преподаватели для указанной кафедры не найдены"); - } - - logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); - - List userResponses = users.stream() - .map( user -> { - - return new UserResponse( - user.getId(), - user.getRole().name(), - user.getFullName(), - user.getJobTitle(), - user.getDepartmentId() - ); - }).toList(); - - return ResponseEntity.ok(userResponses); - } catch (Exception e) { - logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Произошла ошибка при получении списка преподавателей"); + if (users.isEmpty()) { + logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Преподаватели для указанной кафедры не найдены"); } + + logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId); + return ResponseEntity.ok(users.stream() + .map(this::toUserResponse) + .toList()); } @PostMapping public ResponseEntity createUser(@RequestBody CreateUserRequest request) { logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId()); - try { - if (request.getUsername() == null || request.getUsername().isBlank()) { - String errorMessage = "Имя пользователя обязательно"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getPassword() == null || request.getPassword().length() < 4) { - String errorMessage = "Пароль минимум 4 символа"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (userRepository.findByUsername(request.getUsername()).isPresent()) { - String errorMessage = "Пользователь уже существует"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getFullName() == null || request.getFullName().isBlank()) { - String errorMessage = "Имя пользователя обязательно"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getJobTitle() == null || request.getJobTitle().isBlank()) { - logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'"); - request.setJobTitle("Не указано"); - } - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - String errorMessage = "ID кафедры не может быть равен 0 или пустым"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - Role role; - try { - role = Role.valueOf(request.getRole()); - } catch (Exception e) { - logger.error("Ошибка при преобразовании роли: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль")); - } - - User user = new User(); - user.setUsername(request.getUsername()); - user.setPassword(passwordEncoder.encode(request.getPassword())); - user.setRole(role); - user.setFullName(request.getFullName()); - user.setJobTitle(request.getJobTitle()); - user.setDepartmentId(request.getDepartmentId()); - userRepository.save(user); - if (role == Role.TEACHER) { - Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null); - if (department != null) { - TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); - assignment.setTeacher(user); - assignment.setDepartment(department); - assignment.setValidFrom(LocalDate.now()); - assignment.setPrimaryAssignment(true); - assignment.setComment("Начальная кафедра при создании пользователя"); - assignment.setCreatedBy(AuthContext.currentUserId()); - teacherDepartmentAssignmentRepository.save(assignment); - } - } - - logger.info("Пользователь успешно создан с ID: {}", user.getId()); - - return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId())); - } catch (Exception e ) { - logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage())); + if (request.getUsername() == null || request.getUsername().isBlank()) { + String errorMessage = "Имя пользователя обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); } + if (request.getPassword() == null || request.getPassword().length() < 4) { + String errorMessage = "Пароль минимум 4 символа"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + String errorMessage = "Пользователь уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getFullName() == null || request.getFullName().isBlank()) { + String errorMessage = "Имя пользователя обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getJobTitle() == null || request.getJobTitle().isBlank()) { + logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'"); + request.setJobTitle("Не указано"); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + String errorMessage = "ID кафедры не может быть равен 0 или пустым"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + Role role; + try { + role = Role.valueOf(request.getRole()); + } catch (IllegalArgumentException | NullPointerException e) { + logger.error("Ошибка при преобразовании роли: {}", e.getMessage()); + return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль")); + } + + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setRole(role); + user.setFullName(request.getFullName()); + user.setJobTitle(request.getJobTitle()); + user.setDepartmentId(request.getDepartmentId()); + userRepository.save(user); + if (role == Role.TEACHER) { + Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null); + if (department != null) { + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(user); + assignment.setDepartment(department); + assignment.setValidFrom(LocalDate.now()); + assignment.setPrimaryAssignment(true); + assignment.setComment("Начальная кафедра при создании пользователя"); + assignment.setCreatedBy(AuthContext.currentUserId()); + teacherDepartmentAssignmentRepository.save(assignment); + } + } + + logger.info("Пользователь успешно создан с ID: {}", user.getId()); + return ResponseEntity.ok(toUserResponse(user)); } @DeleteMapping("/{id}") @@ -252,8 +184,7 @@ public class UserController { } user.restore(); userRepository.save(user); - return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), - user.getFullName(), user.getJobTitle(), user.getDepartmentId())); + return ResponseEntity.ok(toUserResponse(user)); } @GetMapping("/{id}/department-history") @@ -322,11 +253,29 @@ public class UserController { .stream() .map(TeacherDepartmentAssignment::getTeacher) .filter(User::isActiveRecord) - .map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), - user.getFullName(), user.getJobTitle(), user.getDepartmentId())) + .map(this::toUserResponse) .toList()); } + private UserResponse toUserResponse(User user) { + String departmentName = user.getDepartmentId() == null + ? null + : departmentRepository.findById(user.getDepartmentId()) + .map(Department::getDepartmentName) + .orElse("Неизвестно"); + UserResponse response = new UserResponse( + user.getId(), + user.getUsername(), + user.getRole().name(), + user.getFullName(), + user.getJobTitle(), + departmentName + ); + response.setDepartmentId(user.getDepartmentId()); + response.setStatus(user.getStatus()); + return response; + } + private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) { return new TeacherDepartmentAssignmentDto( assignment.getId(), diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java index 7d27645..72c83fe 100644 --- a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java +++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java @@ -128,7 +128,7 @@ public class WorkloadController { .filter(Objects::nonNull) .collect(Collectors.toSet()); return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream() - .filter(Classroom::getIsAvailable) + .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable())) .filter(classroom -> !busyClassroomIds.contains(classroom.getId())) .map(this::toClassroomResponse) .toList(); diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index 4807f35..6dc642e 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -19,6 +19,10 @@ public class GroupResponse { private String specialtyName; private Long specialtyProfileId; private String specialtyProfileName; + private String status; + private Boolean active; + private String studyState; + private String studyStateName; public GroupResponse(Long id, String name, @@ -33,7 +37,11 @@ public class GroupResponse { String specialtyCode, String specialtyName, Long specialtyProfileId, - String specialtyProfileName) { + String specialtyProfileName, + String status, + Boolean active, + String studyState, + String studyStateName) { this.id = id; this.name = name; this.groupSize = groupSize; @@ -48,6 +56,10 @@ public class GroupResponse { this.specialtyName = specialtyName; this.specialtyProfileId = specialtyProfileId; this.specialtyProfileName = specialtyProfileName; + this.status = status; + this.active = active; + this.studyState = studyState; + this.studyStateName = studyStateName; } public Long getId() { @@ -109,4 +121,20 @@ public class GroupResponse { public String getSpecialtyProfileName() { return specialtyProfileName; } + + public String getStatus() { + return status; + } + + public Boolean getActive() { + return active; + } + + public String getStudyState() { + return studyState; + } + + public String getStudyStateName() { + return studyStateName; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java index fbcd2f3..76eca04 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -71,6 +71,10 @@ public class UserResponse { return departmentId; } + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + public String getStatus() { return status; } diff --git a/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java b/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java new file mode 100644 index 0000000..ca4137e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java @@ -0,0 +1,134 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "auth_refresh_tokens") +public class AuthRefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 100) + private String tenant; + + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + @Column(name = "issued_at", nullable = false) + private LocalDateTime issuedAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "rotated_to_token_hash", length = 64) + private String rotatedToTokenHash; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "ip_address", length = 64) + private String ipAddress; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getTenant() { + return tenant; + } + + public void setTenant(String tenant) { + this.tenant = tenant; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public LocalDateTime getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(LocalDateTime issuedAt) { + this.issuedAt = issuedAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getRevokedAt() { + return revokedAt; + } + + public void setRevokedAt(LocalDateTime revokedAt) { + this.revokedAt = revokedAt; + } + + public String getRotatedToTokenHash() { + return rotatedToTokenHash; + } + + public void setRotatedToTokenHash(String rotatedToTokenHash) { + this.rotatedToTokenHash = rotatedToTokenHash; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + public boolean isActive(LocalDateTime now) { + return revokedAt == null && expiresAt.isAfter(now); + } +} diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java index 4ae5483..8de4e61 100644 --- a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java +++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java @@ -83,6 +83,9 @@ public abstract class LifecycleEntity { if (date == null) { return isActiveRecord(); } + if (isArchivedRecord() && activeTo == null) { + return false; + } boolean afterStart = activeFrom == null || !date.isBefore(activeFrom); boolean beforeEnd = activeTo == null || !date.isAfter(activeTo); return afterStart && beforeEnd; diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java index 65a9c12..4d5e4fe 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java @@ -21,16 +21,35 @@ public enum ScheduleLessonCategory { if (lessonType == null || lessonType.getLessonType() == null) { throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); } - String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU")); - if (name.contains("лекц")) { + String name = lessonType.getLessonType() + .trim() + .toLowerCase(Locale.forLanguageTag("ru-RU")) + .replace('ё', 'е'); + boolean lecture = name.equals("лекция") || name.equals("лекции") || name.startsWith("лекц"); + boolean laboratory = name.equals("лабораторная работа") + || name.equals("лабораторные работы") + || name.startsWith("лаб") + || name.contains("лаборатор"); + boolean practice = name.equals("практика") + || name.equals("практики") + || name.startsWith("практ") + || name.contains("практичес") + || name.contains("семинар"); + int matches = (lecture ? 1 : 0) + (laboratory ? 1 : 0) + (practice ? 1 : 0); + if (matches > 1) { + throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType() + + "\" неоднозначен. Используйте отдельный тип: лекция, практика или лабораторная работа"); + } + if (lecture) { return LECTURE; } - if (name.contains("лаб")) { + if (laboratory) { return LABORATORY; } - if (name.contains("практ")) { + if (practice) { return PRACTICE; } - throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType() + + "\" должен быть лекцией, практикой или лабораторной работой"); } } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java index c0bbb1c..95d49b8 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -1,5 +1,6 @@ package com.magistr.app.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import java.time.LocalDate; @@ -89,7 +90,7 @@ public class ScheduleRule { } public void setStatus(String status) { - this.status = status; + this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status; } public LocalDate getValidFrom() { @@ -172,6 +173,38 @@ public class ScheduleRule { }; } + @JsonIgnore + public boolean isActiveRecord() { + return !LifecycleEntity.STATUS_ARCHIVED.equals(status); + } + + @JsonIgnore + public boolean isArchivedRecord() { + return LifecycleEntity.STATUS_ARCHIVED.equals(status); + } + + @JsonIgnore + public boolean isActiveOn(LocalDate date) { + if (date == null) { + return isActiveRecord(); + } + if (!isActiveRecord()) { + return false; + } + boolean afterStart = validFrom == null || !date.isBefore(validFrom); + boolean beforeEnd = validTo == null || !date.isAfter(validTo); + return afterStart && beforeEnd; + } + + public void archive() { + status = LifecycleEntity.STATUS_ARCHIVED; + } + + public void restore() { + status = LifecycleEntity.STATUS_ACTIVE; + validTo = null; + } + private int valueOrZero(Integer value) { return valueOrDefault(value, 0); } diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java b/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java new file mode 100644 index 0000000..ba8c02c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java @@ -0,0 +1,19 @@ +package com.magistr.app.model; + +public enum StudentGroupStudyState { + ACTIVE("Активна"), + NOT_STARTED("Обучение не началось"), + GRADUATED("Завершила обучение"), + INACTIVE("Неактивна"), + ARCHIVED("Архив"); + + private final String label; + + StudentGroupStudyState(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java new file mode 100644 index 0000000..5d9599b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AuthRefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AuthRefreshTokenRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 9449bd6..c0215a1 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -16,7 +16,6 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Service @@ -34,7 +33,6 @@ public class ScheduleGeneratorService { private final TimeSlotRepository timeSlotRepository; private final TimeSlotScopeRepository timeSlotScopeRepository; private final TimeSlotDateAssignmentRepository dateAssignmentRepository; - private final Map consumedHoursCache = new ConcurrentHashMap<>(); public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, GroupRepository groupRepository, @@ -58,8 +56,10 @@ public class ScheduleGeneratorService { .orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); Map> rulesBySemester = new HashMap<>(); + Map consumedHoursProgress = new HashMap<>(); List result = new ArrayList<>(); Set warnedAcademicYears = new HashSet<>(); + Set primedSemesterIds = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { Optional semesterOpt = academicDateService.findSemester(date); @@ -81,9 +81,12 @@ public class ScheduleGeneratorService { semester.getId(), semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) ); + if (primedSemesterIds.add(semester.getId())) { + primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress); + } for (ScheduleRule rule : sortRules(rules)) { - renderRuleForDate(rule, date, group, null, result); + processRuleForDate(rule, date, group, null, consumedHoursProgress, result); } } @@ -97,7 +100,9 @@ public class ScheduleGeneratorService { } Map> rulesBySemester = new HashMap<>(); + Map consumedHoursProgress = new HashMap<>(); List result = new ArrayList<>(); + Set primedSemesterIds = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { Optional semesterOpt = academicDateService.findSemester(date); @@ -110,9 +115,12 @@ public class ScheduleGeneratorService { semester.getId(), semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) ); + if (primedSemesterIds.add(semester.getId())) { + primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress); + } for (ScheduleRule rule : sortRules(rules)) { - renderRuleForDate(rule, date, null, teacherId, result); + processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result); } } @@ -120,7 +128,8 @@ public class ScheduleGeneratorService { } public void clearCache() { - consumedHoursCache.clear(); + // Расход часов считается в контексте одного построения расписания. + // Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных. } private void validateRange(LocalDate startDate, LocalDate endDate) { @@ -152,10 +161,34 @@ public class ScheduleGeneratorService { .toList(); } - private void renderRuleForDate(ScheduleRule rule, + private void primeConsumedHoursBeforeRange(Semester semester, + List rules, + LocalDate rangeStart, + StudentGroup targetGroup, + Long targetTeacherId, + Map consumedHoursProgress) { + LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1)); + if (!semester.getStartDate().isBefore(endExclusive)) { + return; + } + + List sortedRules = sortRules(rules); + for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) { + for (ScheduleRule rule : sortedRules) { + processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null); + } + } + } + + private LocalDate minDate(LocalDate first, LocalDate second) { + return first.isBefore(second) ? first : second; + } + + private void processRuleForDate(ScheduleRule rule, LocalDate date, StudentGroup targetGroup, Long targetTeacherId, + Map consumedHoursProgress, List result) { if (rule.getLectureAcademicHours() == null || rule.getLaboratoryAcademicHours() == null @@ -170,6 +203,9 @@ public class ScheduleGeneratorService { if (!rule.getSubject().isActiveOn(date)) { return; } + if (!rule.isActiveOn(date)) { + return; + } Semester semester = semesterOpt.get(); List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); @@ -193,8 +229,6 @@ public class ScheduleGeneratorService { return; } - Map consumedHoursByBucket = new HashMap<>(); - for (ScheduleRuleSlot slot : slots) { if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) { continue; @@ -217,11 +251,8 @@ public class ScheduleGeneratorService { List activeScopes = new ArrayList<>(); for (LessonScope scope : lessonScopes) { - String consumptionBucket = consumptionBucket(category, scope.subgroupId()); - int consumedHours = consumedHoursByBucket.computeIfAbsent( - consumptionBucket, - key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId()) - ); + String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId()); + int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0); if (consumedHours >= totalHours) { continue; } @@ -233,28 +264,30 @@ public class ScheduleGeneratorService { continue; } - Map lessonGroupById = new LinkedHashMap<>(); - Map lessonSubgroupById = new LinkedHashMap<>(); - activeScopes.forEach(scope -> { - scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); - scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); - }); - List lessonGroups = new ArrayList<>(lessonGroupById.values()); - List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); - int consumedHours = activeScopes.stream() - .mapToInt(ActiveLessonScope::consumedHours) - .min() - .orElse(0); - int remainingAfterLesson = activeScopes.stream() - .mapToInt(ActiveLessonScope::remainingAfterLesson) - .min() - .orElse(0); + if (result != null) { + Map lessonGroupById = new LinkedHashMap<>(); + Map lessonSubgroupById = new LinkedHashMap<>(); + activeScopes.forEach(scope -> { + scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); + scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); + }); + List lessonGroups = new ArrayList<>(lessonGroupById.values()); + List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); + int consumedHours = activeScopes.stream() + .mapToInt(ActiveLessonScope::consumedHours) + .min() + .orElse(0); + int remainingAfterLesson = activeScopes.stream() + .mapToInt(ActiveLessonScope::remainingAfterLesson) + .min() + .orElse(0); - result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, - totalHours, consumedHours, remainingAfterLesson)); + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, + totalHours, consumedHours, remainingAfterLesson)); + } for (ActiveLessonScope scope : activeScopes) { - consumedHoursByBucket.put( - consumptionBucket(category, scope.scope().subgroupId()), + consumedHoursProgress.put( + consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()), scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT ); } @@ -275,62 +308,20 @@ public class ScheduleGeneratorService { .toList(); } - private int calculateConsumedHoursBeforeDate(ScheduleRule rule, - StudentGroup targetGroup, - LocalDate targetDate, - ScheduleLessonCategory category, - Long subgroupId) { - Long groupId = targetGroup == null ? null : targetGroup.getId(); - String cacheKey = rule.getId() - + ":" + category.name() - + ":" + (subgroupId == null ? "whole" : subgroupId) - + ":" + (groupId == null ? "all" : groupId) - + ":" + targetDate; - return consumedHoursCache.computeIfAbsent(cacheKey, - ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId)); - } - - private int calculateConsumedHours(ScheduleRule rule, - StudentGroup targetGroup, - LocalDate targetDate, - ScheduleLessonCategory category, - Long subgroupId) { - int consumed = 0; - for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) { - Semester semester = rule.getSemester(); - if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { - continue; - } - int weekNumber = academicDateService.getWeekNumber(semester, date); - if (weekNumber < rule.startWeekFor(category)) { - continue; - } - List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); - if (eligibleGroups.isEmpty()) { - continue; - } - - int dayOfWeek = date.getDayOfWeek().getValue(); - ScheduleParity dateParity = academicDateService.getParity(semester, date); - long activeSlots = rule.getSlots().stream() - .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) - .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) - .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) - .flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream()) - .filter(scope -> Objects.equals(scope.subgroupId(), subgroupId)) - .count(); - consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; - if (consumed >= rule.academicHoursFor(category)) { - return consumed; - } - } - return consumed; - } - private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) { return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId); } + private String consumptionProgressKey(ScheduleRule rule, + StudentGroup targetGroup, + ScheduleLessonCategory category, + Long subgroupId) { + Long groupId = targetGroup == null ? null : targetGroup.getId(); + return rule.getId() + + ":" + consumptionBucket(category, subgroupId) + + ":" + (groupId == null ? "all" : groupId); + } + private List lessonScopesForSlot(ScheduleRuleSlot slot, ScheduleLessonCategory category, List eligibleGroups) { diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java index 348044f..f226564 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -1,7 +1,6 @@ package com.magistr.app.service; import com.magistr.app.dto.RenderedLessonDto; -import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.ScheduleOverride; import com.magistr.app.model.StudentGroup; import com.magistr.app.repository.GroupRepository; @@ -9,22 +8,29 @@ import com.magistr.app.repository.ScheduleOverrideRepository; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @Service public class ScheduleQueryService { + private static final int MAX_GROUPS_WITHOUT_SCOPE = 50; + private static final long MAX_RANGE_DAYS = 120; + private final ScheduleGeneratorService scheduleGeneratorService; private final GroupRepository groupRepository; private final ScheduleOverrideRepository scheduleOverrideRepository; + private final StudentGroupLifecycleService groupLifecycleService; public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, GroupRepository groupRepository, - ScheduleOverrideRepository scheduleOverrideRepository) { + ScheduleOverrideRepository scheduleOverrideRepository, + StudentGroupLifecycleService groupLifecycleService) { this.scheduleGeneratorService = scheduleGeneratorService; this.groupRepository = groupRepository; this.scheduleOverrideRepository = scheduleOverrideRepository; + this.groupLifecycleService = groupLifecycleService; } public List search(Long groupId, @@ -37,10 +43,16 @@ public class ScheduleQueryService { String parity, LocalDate startDate, LocalDate endDate) { - List groups = resolveGroups(groupId, departmentId); - List generatedLessons = groups.stream() - .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) - .toList(); + validateRange(startDate, endDate); + List generatedLessons; + if (groupId == null && departmentId == null && teacherId != null) { + generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); + } else { + List groups = resolveGroups(groupId, departmentId, startDate, endDate); + generatedLessons = groups.stream() + .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) + .toList(); + } List lessons = applyOverrides(generatedLessons, startDate, endDate).stream() .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId)) .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) @@ -132,17 +144,39 @@ public class ScheduleQueryService { return scheduleRuleSlotId + ":" + date; } - private List resolveGroups(Long groupId, Long departmentId) { + private void validateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("Дата начала и дата окончания обязательны"); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); + } + if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) { + throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); + } + } + + private List resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) { if (groupId != null) { return groupRepository.findById(groupId) - .filter(StudentGroup::isActiveRecord) + .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate)) .map(List::of) .orElse(List.of()); } + List groups; if (departmentId != null) { - return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); + groups = groupRepository.findByDepartmentId(departmentId); + } else { + groups = groupRepository.findAll(); } - return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + groups = groups.stream() + .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate)) + .toList(); + if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) { + throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает " + + groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE); + } + return groups; } private List deduplicate(List lessons) { diff --git a/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java b/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java new file mode 100644 index 0000000..5620a9b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java @@ -0,0 +1,82 @@ +package com.magistr.app.service; + +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.model.StudentGroupStudyState; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; +import com.magistr.app.utils.CourseAndSemesterCalculator; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.Optional; + +@Service +public class StudentGroupLifecycleService { + + private final StudentGroupCalendarAssignmentRepository assignmentRepository; + + public StudentGroupLifecycleService(StudentGroupCalendarAssignmentRepository assignmentRepository) { + this.assignmentRepository = assignmentRepository; + } + + public boolean isAvailableForSelection(StudentGroup group, LocalDate date) { + return group != null + && group.isActiveRecord() + && group.isActiveOn(date) + && !isGraduatedOn(group, date); + } + + public boolean mayHaveScheduleInRange(StudentGroup group, LocalDate startDate, LocalDate endDate) { + if (group == null || startDate == null || endDate == null || endDate.isBefore(startDate)) { + return false; + } + Optional courseLimit = resolveCourseLimit(group); + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + if (group.isActiveOn(date) && hasStarted(group, date) && !isGraduatedOn(group, date, courseLimit)) { + return true; + } + } + return false; + } + + public StudentGroupStudyState getStudyState(StudentGroup group, LocalDate date) { + if (group == null || group.isArchivedRecord()) { + return StudentGroupStudyState.ARCHIVED; + } + if (!group.isActiveOn(date)) { + return StudentGroupStudyState.INACTIVE; + } + if (isGraduatedOn(group, date)) { + return StudentGroupStudyState.GRADUATED; + } + if (!hasStarted(group, date)) { + return StudentGroupStudyState.NOT_STARTED; + } + return StudentGroupStudyState.ACTIVE; + } + + private boolean hasStarted(StudentGroup group, LocalDate date) { + return CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date) > 0; + } + + private boolean isGraduatedOn(StudentGroup group, LocalDate date) { + return isGraduatedOn(group, date, resolveCourseLimit(group)); + } + + private boolean isGraduatedOn(StudentGroup group, LocalDate date, Optional courseLimit) { + int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date); + return course > 0 && courseLimit.map(limit -> course > limit).orElse(false); + } + + private Optional resolveCourseLimit(StudentGroup group) { + if (group.getId() == null) { + return Optional.empty(); + } + return assignmentRepository.findByGroupIdWithDetails(group.getId()).stream() + .map(StudentGroupCalendarAssignment::getAcademicCalendar) + .filter(calendar -> calendar.getCourseCount() != null) + .map(calendar -> calendar.getCourseCount()) + .max(Comparator.naturalOrder()); + } +} diff --git a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java index af6b488..3b0f9e9 100644 --- a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java +++ b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java @@ -9,22 +9,30 @@ import java.time.LocalDate; public class CourseAndSemesterCalculator { public static int getActualCourse(Integer yearStartStudy) { - LocalDate now = LocalDate.now(); - int currentYear = now.getYear(); - int currentMonth = now.getMonthValue(); + return getActualCourse(yearStartStudy, LocalDate.now()); + } - if (currentMonth >= 9) { - return currentYear - yearStartStudy + 1; - } else { - return currentYear - yearStartStudy; - } + public static int getActualCourse(Integer yearStartStudy, LocalDate date) { + int currentYear = date.getYear(); + int currentMonth = date.getMonthValue(); + int course = currentMonth >= 9 + ? currentYear - yearStartStudy + 1 + : currentYear - yearStartStudy; + return Math.max(0, course); } public static int getActualSemester(Integer yearStartStudy) { - int course = getActualCourse(yearStartStudy); - int currentMonth = LocalDate.now().getMonthValue(); + return getActualSemester(yearStartStudy, LocalDate.now()); + } - if ( currentMonth <= 1 || currentMonth >= 9) { + public static int getActualSemester(Integer yearStartStudy, LocalDate date) { + int course = getActualCourse(yearStartStudy, date); + if (course == 0) { + return 0; + } + int currentMonth = date.getMonthValue(); + + if (currentMonth <= 1 || currentMonth >= 9) { return course * 2 - 1; } else { return course * 2; @@ -33,11 +41,14 @@ public class CourseAndSemesterCalculator { public static int getFutureCourse(Integer yearStartStudy, String periodYears) { int recordYear = Integer.parseInt(periodYears.substring(0, 4)); - return recordYear - yearStartStudy + 1; + return Math.max(0, recordYear - yearStartStudy + 1); } public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) { int course = getFutureCourse(yearStartStudy, periodYears); + if (course == 0) { + return 0; + } if (semesterType == SemesterType.autumn) { return course * 2 - 1; diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 1503138..ed1e51f 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -3,7 +3,7 @@ server.port=8080 # PostgreSQL (дефолтный — для локальной разработки через Docker Compose) spring.datasource.url=jdbc:postgresql://db:5432/app_db spring.datasource.username=${POSTGRES_USER:myuser} -spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword} +spring.datasource.password=${POSTGRES_PASSWORD:} spring.datasource.driver-class-name=org.postgresql.Driver # JPA @@ -14,5 +14,11 @@ spring.jpa.open-in-view=false # Мультитенантность app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} -#logging.level.root=DEBUG +# JWT авторизация +app.jwt.secret=${JWT_SECRET:dev-only-change-this-jwt-secret-32-bytes-minimum} +app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m} +app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d} +app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh} +app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false} +#logging.level.root=DEBUG diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 45fb370..5332257 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -91,6 +91,25 @@ VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Ад ('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1) ON CONFLICT (username) DO NOTHING; +CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant VARCHAR(100) NOT NULL, + token_hash VARCHAR(64) UNIQUE NOT NULL, + issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP, + rotated_to_token_hash VARCHAR(64), + user_agent VARCHAR(512), + ip_address VARCHAR(64) +); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user + ON auth_refresh_tokens(user_id); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires + ON auth_refresh_tokens(tenant, expires_at); + CREATE TABLE IF NOT EXISTS teacher_department_assignments ( id BIGSERIAL PRIMARY KEY, teacher_id BIGINT NOT NULL REFERENCES users(id), @@ -950,6 +969,17 @@ COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; COMMENT ON COLUMN users.department_id IS 'ID кафедры'; +COMMENT ON TABLE auth_refresh_tokens IS 'Отзывные refresh-сессии JWT. Сырые refresh-токены не хранятся, только SHA-256 хэш'; +COMMENT ON COLUMN auth_refresh_tokens.user_id IS 'ID пользователя, которому выдан refresh-токен'; +COMMENT ON COLUMN auth_refresh_tokens.tenant IS 'Тенант, в рамках которого выдан refresh-токен'; +COMMENT ON COLUMN auth_refresh_tokens.token_hash IS 'SHA-256 хэш refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.issued_at IS 'Дата и время выдачи refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.expires_at IS 'Дата и время истечения refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время отзыва refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации'; +COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена'; +COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена'; + COMMENT ON COLUMN education_forms.id IS 'ID формы обучения'; COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; COMMENT ON COLUMN education_forms.description IS 'Описание'; diff --git a/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java new file mode 100644 index 0000000..e233672 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java @@ -0,0 +1,104 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; +import com.magistr.app.model.User; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtTokenServiceTest { + + @Test + void authenticatesValidTokenForSameTenant() { + JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15))); + String token = service.createAccessToken(user(), "magistr"); + + var authenticated = service.authenticate(token, "magistr"); + + assertThat(authenticated).isPresent(); + assertThat(authenticated.get().id()).isEqualTo(10L); + assertThat(authenticated.get().username()).isEqualTo("admin"); + assertThat(authenticated.get().role()).isEqualTo(Role.ADMIN); + assertThat(authenticated.get().departmentId()).isEqualTo(2L); + } + + @Test + void rejectsTokenForDifferentTenant() { + JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15))); + String token = service.createAccessToken(user(), "magistr"); + + assertThat(service.authenticate(token, "n8n")).isEmpty(); + } + + @Test + void rejectsTokenWithDifferentSignature() { + JwtTokenService issuer = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15))); + JwtTokenService verifier = new JwtTokenService(properties("abcdefghijklmnopqrstuvwxyz123456", Duration.ofMinutes(15))); + String token = issuer.createAccessToken(user(), "magistr"); + + assertThat(verifier.authenticate(token, "magistr")).isEmpty(); + } + + @Test + void rejectsExpiredToken() { + String secret = "12345678901234567890123456789012"; + JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15))); + String token = expiredToken(secret); + + assertThat(service.authenticate(token, "magistr")).isEmpty(); + } + + private String expiredToken(String secret) { + try { + Instant now = Instant.now(); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("magistr") + .subject("10") + .jwtID("expired") + .issueTime(Date.from(now.minusSeconds(120))) + .expirationTime(Date.from(now.minusSeconds(90))) + .claim("tenant", "magistr") + .claim("userId", 10L) + .claim("username", "admin") + .claim("role", "ADMIN") + .claim("departmentId", 2L) + .build(); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8))); + return jwt.serialize(); + } catch (JOSEException e) { + throw new IllegalStateException(e); + } + } + + private JwtProperties properties(String secret, Duration accessTtl) { + JwtProperties properties = new JwtProperties(); + properties.setSecret(secret); + properties.setAccessTtl(accessTtl); + properties.setRefreshTtl(Duration.ofDays(7)); + return properties; + } + + private User user() { + User user = new User(); + user.setId(10L); + user.setUsername("admin"); + user.setRole(Role.ADMIN); + user.setDepartmentId(2L); + user.setFullName("Администратор"); + user.setJobTitle("Администратор"); + user.setPassword("hash"); + return user; + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java new file mode 100644 index 0000000..068f1db --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java @@ -0,0 +1,115 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.AuthRefreshToken; +import com.magistr.app.model.Role; +import com.magistr.app.model.User; +import com.magistr.app.repository.AuthRefreshTokenRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RefreshTokenServiceTest { + + @Test + void storesOnlyTokenHash() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + + String rawToken = service.createSession(user(), "magistr", "Browser", "127.0.0.1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuthRefreshToken.class); + verify(repository).save(captor.capture()); + AuthRefreshToken stored = captor.getValue(); + + assertThat(stored.getTokenHash()).isNotEqualTo(rawToken); + assertThat(stored.getTokenHash()).hasSize(64); + assertThat(stored.getTenant()).isEqualTo("magistr"); + assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now()); + } + + @Test + void rotatesRefreshTokenAndRejectsReplay() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + String rawToken = "raw-refresh-token"; + AuthRefreshToken stored = activeToken(service.hashToken(rawToken)); + when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored)); + + Optional rotation = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1"); + + assertThat(rotation).isPresent(); + assertThat(rotation.get().refreshToken()).isNotEqualTo(rawToken); + assertThat(stored.getRevokedAt()).isNotNull(); + assertThat(stored.getRotatedToTokenHash()).hasSize(64); + + Optional replay = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1"); + assertThat(replay).isEmpty(); + verify(repository, times(2)).save(any(AuthRefreshToken.class)); + } + + @Test + void rejectsTokenFromDifferentTenant() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + String rawToken = "raw-refresh-token"; + when(repository.findByTokenHash(service.hashToken(rawToken))) + .thenReturn(Optional.of(activeToken(service.hashToken(rawToken)))); + + assertThat(service.rotate(rawToken, "n8n", "Browser", "127.0.0.1")).isEmpty(); + } + + @Test + void logoutRevokesActiveToken() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + String rawToken = "raw-refresh-token"; + AuthRefreshToken stored = activeToken(service.hashToken(rawToken)); + when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored)); + + service.revoke(rawToken, "magistr"); + + assertThat(stored.getRevokedAt()).isNotNull(); + verify(repository).save(stored); + } + + private JwtProperties properties() { + JwtProperties properties = new JwtProperties(); + properties.setRefreshTtl(Duration.ofDays(7)); + return properties; + } + + private AuthRefreshToken activeToken(String hash) { + AuthRefreshToken token = new AuthRefreshToken(); + token.setUser(user()); + token.setTenant("magistr"); + token.setTokenHash(hash); + token.setIssuedAt(LocalDateTime.now().minusMinutes(1)); + token.setExpiresAt(LocalDateTime.now().plusDays(1)); + return token; + } + + private User user() { + User user = new User(); + user.setId(10L); + user.setUsername("admin"); + user.setRole(Role.ADMIN); + user.setDepartmentId(2L); + user.setFullName("Администратор"); + user.setJobTitle("Администратор"); + user.setPassword("hash"); + return user; + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java new file mode 100644 index 0000000..3820475 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java @@ -0,0 +1,23 @@ +package com.magistr.app.config.tenant; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TenantConfigWatcherTest { + + @Test + void configHashUsesSha256Hex() { + String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}"); + + assertThat(hash).hasSize(64); + assertThat(hash).matches("[0-9a-f]{64}"); + assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode())); + } + + @Test + void configHashChangesWhenContentChanges() { + assertThat(TenantConfigWatcher.configHash("tenant-a")) + .isNotEqualTo(TenantConfigWatcher.configHash("tenant-b")); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java new file mode 100644 index 0000000..20e4841 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java @@ -0,0 +1,38 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.model.Role; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuthControllerTest { + + @AfterEach + void clearAuthContext() { + AuthContext.clear(); + } + + @Test + void meAllowsNullDepartmentId() { + AuthController controller = new AuthController(null, null, null, null, null); + AuthContext.setCurrentUser(new AuthenticatedUser(10L, "admin", Role.ADMIN, null)); + + ResponseEntity response = controller.me(); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertThat(body).containsEntry("userId", 10L); + assertThat(body).containsEntry("username", "admin"); + assertThat(body).containsEntry("role", "ADMIN"); + assertThat(body).containsKey("departmentId"); + assertThat(body.get("departmentId")).isNull(); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java new file mode 100644 index 0000000..37824a1 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java @@ -0,0 +1,46 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.model.Role; +import com.magistr.app.model.Subject; +import com.magistr.app.repository.SubjectCommentRepository; +import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.ScheduleQueryService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DepartmentWorkspaceControllerTest { + + @AfterEach + void clearAuthContext() { + AuthContext.clear(); + } + + @Test + void commentsRejectSubjectWithoutDepartmentForDepartmentUserWithoutNpe() { + SubjectRepository subjectRepository = mock(SubjectRepository.class); + Subject subject = new Subject(); + subject.setId(10L); + subject.setDepartmentId(null); + when(subjectRepository.findById(10L)).thenReturn(Optional.of(subject)); + DepartmentWorkspaceController controller = new DepartmentWorkspaceController( + subjectRepository, + mock(SubjectCommentRepository.class), + mock(UserRepository.class), + mock(ScheduleQueryService.class) + ); + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L)); + + var response = controller.getComments(10L); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java new file mode 100644 index 0000000..b751e4e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java @@ -0,0 +1,60 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.UserResponse; +import com.magistr.app.model.Department; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; +import com.magistr.app.model.User; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +import com.magistr.app.repository.UserRepository; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class UserControllerTest { + + @Test + void teachersUseUnifiedUserResponseMapping() { + UserRepository userRepository = mock(UserRepository.class); + DepartmentRepository departmentRepository = mock(DepartmentRepository.class); + UserController controller = new UserController( + userRepository, + null, + departmentRepository, + mock(TeacherDepartmentAssignmentRepository.class) + ); + User teacher = user(10L, "teacher", Role.TEACHER, 2L); + when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED)) + .thenReturn(List.of(teacher)); + when(departmentRepository.findById(2L)) + .thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L))); + + var response = controller.getTeachersByDepartmentId(2L); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = (List) response.getBody(); + assertThat(body).hasSize(1); + assertThat(body.get(0).getUsername()).isEqualTo("teacher"); + assertThat(body.get(0).getDepartmentId()).isEqualTo(2L); + assertThat(body.get(0).getDepartmentName()).isEqualTo("Кафедра ВТ"); + assertThat(body.get(0).getStatus()).isEqualTo(LifecycleEntity.STATUS_ACTIVE); + } + + private User user(Long id, String username, Role role, Long departmentId) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setRole(role); + user.setFullName("Тестовый пользователь"); + user.setJobTitle("Преподаватель"); + user.setDepartmentId(departmentId); + return user; + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java new file mode 100644 index 0000000..502f7ef --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java @@ -0,0 +1,50 @@ +package com.magistr.app.controller; + +import com.magistr.app.model.Classroom; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.ScheduleQueryService; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WorkloadControllerTest { + + @Test + void freeClassroomsSkipsNullAvailabilityWithoutNpe() { + ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); + ClassroomRepository classroomRepository = mock(ClassroomRepository.class); + WorkloadController controller = new WorkloadController( + scheduleQueryService, + classroomRepository, + mock(UserRepository.class), + mock(DepartmentRepository.class) + ); + when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any())) + .thenReturn(List.of()); + when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)) + .thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true))); + + var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L); + + assertThat(result).extracting("id").containsExactly(3L); + } + + private Classroom classroom(Long id, Boolean available) { + Classroom classroom = new Classroom(); + classroom.setId(id); + classroom.setName("Аудитория " + id); + classroom.setCapacity(30); + classroom.setIsAvailable(available); + return classroom; + } +} diff --git a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java new file mode 100644 index 0000000..c5e36c0 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java @@ -0,0 +1,40 @@ +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 archivedEntityRemainsActiveForHistoricalDatesBeforeActiveTo() { + Subject subject = new Subject(); + subject.setStatus(LifecycleEntity.STATUS_ARCHIVED); + subject.setActiveTo(LocalDate.of(2026, 5, 10)); + + assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 9))).isTrue(); + assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 11))).isFalse(); + } + + @Test + void activeEntityUsesDateRange() { + Subject subject = new Subject(); + subject.setActiveFrom(LocalDate.of(2026, 1, 10)); + subject.setActiveTo(LocalDate.of(2026, 1, 20)); + + assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 9))).isFalse(); + assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 10))).isTrue(); + assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue(); + assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse(); + } +} diff --git a/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java b/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java new file mode 100644 index 0000000..8b47ec0 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java @@ -0,0 +1,39 @@ +package com.magistr.app.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScheduleLessonCategoryTest { + + @Test + void mapsCanonicalLessonTypes() { + assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лекция"))) + .isEqualTo(ScheduleLessonCategory.LECTURE); + assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Практика"))) + .isEqualTo(ScheduleLessonCategory.PRACTICE); + assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лабораторная работа"))) + .isEqualTo(ScheduleLessonCategory.LABORATORY); + } + + @Test + void rejectsAmbiguousLessonType() { + assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Лабораторно-практическое занятие"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("неоднозначен"); + } + + @Test + void rejectsUnsupportedLessonType() { + assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Консультация"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("должен быть лекцией"); + } + + private LessonType lessonType(String name) { + LessonType lessonType = new LessonType(); + lessonType.setLessonType(name); + return lessonType; + } +} diff --git a/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java b/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java new file mode 100644 index 0000000..f6f0bfe --- /dev/null +++ b/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java @@ -0,0 +1,41 @@ +package com.magistr.app.model; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScheduleRuleTest { + + @Test + void activeOnUsesStatusAndValidityRange() { + ScheduleRule rule = new ScheduleRule(); + rule.setValidFrom(LocalDate.of(2026, 2, 1)); + rule.setValidTo(LocalDate.of(2026, 2, 10)); + + assertThat(rule.isActiveOn(LocalDate.of(2026, 1, 31))).isFalse(); + assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 1))).isTrue(); + assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 10))).isTrue(); + assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 11))).isFalse(); + + rule.setStatus(LifecycleEntity.STATUS_ARCHIVED); + + assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 5))).isFalse(); + } + + @Test + void archiveAndRestoreToggleStatus() { + ScheduleRule rule = new ScheduleRule(); + + rule.archive(); + + assertThat(rule.isArchivedRecord()).isTrue(); + assertThat(rule.isActiveOn(LocalDate.now())).isFalse(); + + rule.restore(); + + assertThat(rule.isActiveRecord()).isTrue(); + assertThat(rule.getValidTo()).isNull(); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java new file mode 100644 index 0000000..7fe126a --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java @@ -0,0 +1,77 @@ +package com.magistr.app.service; + +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); + StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class); + when(groupRepository.findAll()) + .thenReturn(IntStream.rangeClosed(1, 51) + .mapToObj(this::group) + .toList()); + when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true); + ScheduleQueryService service = new ScheduleQueryService( + mock(ScheduleGeneratorService.class), + groupRepository, + mock(ScheduleOverrideRepository.class), + groupLifecycleService + ); + + 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); + StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class); + ScheduleQueryService service = new ScheduleQueryService( + generatorService, + groupRepository, + overrideRepository, + groupLifecycleService + ); + 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()).findAll(); + } + + private StudentGroup group(int id) { + StudentGroup group = new StudentGroup(); + group.setId((long) id); + group.setName("Группа " + id); + return group; + } +} diff --git a/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java b/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java new file mode 100644 index 0000000..54e966d --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java @@ -0,0 +1,63 @@ +package com.magistr.app.service; + +import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.model.StudentGroupStudyState; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; +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.Mockito.mock; +import static org.mockito.Mockito.when; + +class StudentGroupLifecycleServiceTest { + + @Test + void futureGroupIsSelectableButDoesNotHaveScheduleBeforeStudyStart() { + StudentGroup group = group(1L, 2027); + StudentGroupLifecycleService service = serviceWithAssignments(group, 4); + LocalDate date = LocalDate.of(2026, 5, 1); + + assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.NOT_STARTED); + assertThat(service.isAvailableForSelection(group, date)).isTrue(); + assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse(); + } + + @Test + void groupAfterCalendarCourseLimitIsGraduatedAndUnavailableForSelection() { + StudentGroup group = group(1L, 2020); + StudentGroupLifecycleService service = serviceWithAssignments(group, 4); + LocalDate date = LocalDate.of(2026, 5, 1); + + assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.GRADUATED); + assertThat(service.isAvailableForSelection(group, date)).isFalse(); + assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse(); + } + + private StudentGroupLifecycleService serviceWithAssignments(StudentGroup group, int courseCount) { + StudentGroupCalendarAssignmentRepository repository = mock(StudentGroupCalendarAssignmentRepository.class); + when(repository.findByGroupIdWithDetails(group.getId())) + .thenReturn(List.of(assignment(courseCount))); + return new StudentGroupLifecycleService(repository); + } + + private StudentGroup group(Long id, int yearStartStudy) { + StudentGroup group = new StudentGroup(); + group.setId(id); + group.setName("Группа"); + group.setYearStartStudy(yearStartStudy); + return group; + } + + private StudentGroupCalendarAssignment assignment(int courseCount) { + AcademicCalendar calendar = new AcademicCalendar(); + calendar.setCourseCount(courseCount); + StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment(); + assignment.setAcademicCalendar(calendar); + return assignment; + } +} diff --git a/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java b/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java new file mode 100644 index 0000000..f3eb939 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java @@ -0,0 +1,26 @@ +package com.magistr.app.utils; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class CourseAndSemesterCalculatorTest { + + @Test + void courseAndSemesterAreZeroBeforeStudyStart() { + LocalDate date = LocalDate.of(2026, 5, 1); + + assertThat(CourseAndSemesterCalculator.getActualCourse(2027, date)).isZero(); + assertThat(CourseAndSemesterCalculator.getActualSemester(2027, date)).isZero(); + } + + @Test + void septemberStartsFirstCourseAndFirstSemester() { + LocalDate date = LocalDate.of(2026, 9, 1); + + assertThat(CourseAndSemesterCalculator.getActualCourse(2026, date)).isEqualTo(1); + assertThat(CourseAndSemesterCalculator.getActualSemester(2026, date)).isEqualTo(1); + } +} diff --git a/backend/tenants.json b/backend/tenants.example.json old mode 100755 new mode 100644 similarity index 74% rename from backend/tenants.json rename to backend/tenants.example.json index 704573b..3f65553 --- a/backend/tenants.json +++ b/backend/tenants.example.json @@ -4,6 +4,6 @@ "domain": "default", "url": "jdbc:postgresql://db:5432/app_db", "username": "myuser", - "password": "supersecretpassword" + "password": "replace-with-local-password" } -] \ No newline at end of file +] diff --git a/compose.yaml b/compose.yaml index a5bd0a0..05cf62d 100755 --- a/compose.yaml +++ b/compose.yaml @@ -32,11 +32,11 @@ services: ports: - "5432:5432" environment: - POSTGRES_USER: myuser - POSTGRES_PASSWORD: supersecretpassword - POSTGRES_DB: app_db + POSTGRES_USER: ${POSTGRES_USER:-myuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env} + POSTGRES_DB: ${POSTGRES_DB:-app_db} healthcheck: - test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ] + test: [ "CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"" ] interval: 5s timeout: 5s retries: 5 diff --git a/docs/API.md b/docs/API.md index 85bd686..a09293e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,6 +2,20 @@ Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON. +Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат: + +```json +{ + "timestamp": "2026-05-27T19:47:54", + "status": 400, + "error": "Некорректный запрос", + "message": "Некорректные параметры запроса", + "path": "/api/schedule" +} +``` + +Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`. + --- ## Аутентификация @@ -23,7 +37,7 @@ { "success": true, "message": "OK", - "token": "550e8400-e29b-41d4-a716-446655440000", + "token": "eyJhbGciOiJIUzI1NiJ9...", "role": "ADMIN", "redirect": "/admin/", "departmentId": 1, @@ -31,6 +45,8 @@ } ``` +Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена. + **Ошибка (401):** ```json { @@ -44,7 +60,7 @@ } ``` -> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` +> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer `. Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. @@ -59,6 +75,37 @@ Redirect по ролям: | `TEACHER` | `/teacher/` | | `STUDENT` | `/student/` | +### `POST /api/auth/refresh` + +Обновляет access JWT по refresh-cookie. Тело запроса не требуется. + +**Успешный ответ (200):** +```json +{ + "success": true, + "message": "OK", + "token": "eyJhbGciOiJIUzI1NiJ9...", + "role": "ADMIN", + "redirect": "/admin/", + "departmentId": 1, + "userId": 1 +} +``` + +Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается. + +### `POST /api/auth/logout` + +Отзывает текущий refresh-токен и очищает refresh-cookie. + +**Успешный ответ (200):** +```json +{ + "success": true, + "message": "Выход выполнен" +} +``` + ### `GET /api/auth/me` Возвращает текущего пользователя по bearer-токену. @@ -72,6 +119,8 @@ Redirect по ролям: } ``` +`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре. + --- ## Пользователи @@ -83,18 +132,20 @@ Redirect по ролям: **Ответ:** ```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` @@ -151,7 +202,7 @@ Redirect по ролям: ## Права ролей на API -Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`. +Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`. Важные ограничения: @@ -413,6 +464,8 @@ CRUD доступен по: | `timeSlotId` | Временной слот | | `parity` | `BOTH`, `ODD`, `EVEN` | +Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру. + Пример: ```http @@ -546,7 +599,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 ### `GET /api/groups` -Список всех групп. +Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию. + +Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение. **Ответ:** ```json @@ -566,14 +621,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 "specialtyName": "Программная инженерия", "specialtyProfileId": 2, "specialtyProfileName": "Без профиля", - "specialityCode": 1 + "specialityCode": 1, + "status": "ACTIVE", + "active": true, + "studyState": "ACTIVE", + "studyStateName": "Активна" } ] ``` ### `GET /api/groups/{departmentId}` -Список всех групп привязанных к конкретной кафедре. +Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются. ### `POST /api/groups` @@ -591,7 +650,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 } ``` -`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`. +`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось. + +Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`. Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`. @@ -613,7 +674,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 ### `DELETE /api/groups/{id}` -Удаление группы. +Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой. + +### `POST /api/groups/{id}/restore` + +Восстановление архивной группы. ### Подгруппы группы @@ -906,7 +971,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 | Код | Описание | |-----|----------| | `200` | Успех | -| `400` | Ошибка валидации (с `message` в теле) | +| `400` | Ошибка валидации или некорректные параметры запроса | | `401` | Неверные учётные данные | | `404` | Ресурс / тенант не найден | | `500` | Внутренняя ошибка сервера | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c5a700..ce6e9b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ graph TD - **Порт:** 8080 (внутренний) - **ORM:** Hibernate (JPA), `ddl-auto=none` - **Миграции:** Flyway (программный запуск при подключении тенанта) -- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей +- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей ### PostgreSQL - **Версия:** `postgres:alpine3.23` @@ -80,6 +80,7 @@ sequenceDiagram | `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта | | `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД | | `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | +| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое | | `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | | `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API | | `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` | @@ -99,7 +100,7 @@ sequenceDiagram ### Конфигурация тенантов Список тенантов хранится в JSON-файле: -- **Локально:** `backend/tenants.json` +- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`) - **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json` Формат: @@ -121,26 +122,29 @@ 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 на асинхронных участках. + --- ## Аутентификация -Система использует простую модель аутентификации без JWT и без полноценного Spring Security: +Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow: -1. Клиент отправляет `POST /api/auth/login` с `username` и `password` -2. Backend проверяет пароль через `BCryptPasswordEncoder` -3. При успехе возвращается: - - UUID-токен (для заголовка `Authorization: Bearer`) - - Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`) - - Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`) -4. Токен хранится в `localStorage` на клиенте +1. Клиент отправляет `POST /api/auth/login` с `username` и `password`. +2. Backend проверяет пароль через `BCryptPasswordEncoder`. +3. При успехе возвращается access JWT для заголовка `Authorization: Bearer ` и устанавливается `HttpOnly` refresh-cookie. +4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш. +5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается. +6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie. -Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно. +Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно. -`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution. +`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 2fa695c..88722a7 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие ### Учебные группы (Student Groups) - **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения -- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` +- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график +- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным. ### Аудитории (Classrooms) @@ -90,6 +91,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия. +Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы. + ### Перевод преподавателей между кафедрами Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`. @@ -136,6 +139,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 +176,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие - **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. - **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах. +- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации. - **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю. ### Точечные изменения расписания diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 9e3b82d..bc3f180 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -48,6 +48,17 @@ erDiagram TIMESTAMP created_at TIMESTAMP updated_at } + + auth_refresh_tokens { + BIGSERIAL id PK + BIGINT user_id FK + VARCHAR tenant + VARCHAR token_hash UK + TIMESTAMP issued_at + TIMESTAMP expires_at + TIMESTAMP revoked_at + VARCHAR rotated_to_token_hash + } education_forms { BIGSERIAL id PK @@ -292,6 +303,7 @@ erDiagram student_groups ||--o{ schedule_rule_groups : "group_id" student_groups ||--o{ student_group_calendar_assignments : "group_id" users ||--o{ teacher_subjects : "user_id" + users ||--o{ auth_refresh_tokens : "user_id" users ||--o{ teacher_department_assignments : "teacher_id" departments ||--o{ teacher_department_assignments : "department_id" users ||--o{ teacher_lesson_types : "user_id" @@ -379,6 +391,22 @@ erDiagram > **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. +#### `auth_refresh_tokens` — Refresh-сессии JWT +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID refresh-сессии | +| `user_id` | BIGINT FK → users (CASCADE) | Пользователь | +| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен | +| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена | +| `issued_at` | TIMESTAMP | Дата выдачи | +| `expires_at` | TIMESTAMP | Дата истечения | +| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии | +| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации | +| `user_agent` | VARCHAR(512) | User-Agent клиента | +| `ip_address` | VARCHAR(64) | IP-адрес клиента | + +Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie. + ### Учебный процесс #### `education_forms` — Формы обучения @@ -401,6 +429,11 @@ erDiagram | `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | | `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | +| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` | +| `active_from` | DATE | Дата начала действия группы | +| `active_to` | DATE | Дата окончания действия группы для исторических расчётов | +| `archived_at` | TIMESTAMP | Дата и время архивирования | +| `archive_reason` | TEXT | Причина архивирования | #### `subgroups` — Подгруппы | Колонка | Тип | Описание | @@ -616,6 +649,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` — Группы правила | Колонка | Тип | Описание | |---------|-----|----------| @@ -674,17 +709,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня 1. Все миграции находятся в `backend/src/main/resources/db/migration/` 2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) -3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway +3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД. 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline -> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных. +> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum. ### Текущие миграции | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f7727e9..e3a3a74 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -222,11 +222,13 @@ public class AbsenceController { ### Правила -1. **Никогда** не изменяйте уже закоммиченные файлы миграций +1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя 2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) 3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... 4. После добавления — перезапустите backend для применения +Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. + ### Применение ```bash @@ -252,11 +254,13 @@ com.magistr.app/ │ ├── TenantContext.java # ThreadLocal текущего тенанта │ ├── TenantInterceptor.java # Определение тенанта из Host │ ├── TenantRoutingDataSource.java # Маршрутизация к БД -│ ├── TenantDataSourceConfig.java # Spring-конфигурация +│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA +│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов │ ├── TenantConfigWatcher.java # Периодическая синхронизация │ └── ConfigMapUpdater.java # Обновление K8s ConfigMap ├── controller/ # REST-контроллеры │ ├── AuthController.java +│ ├── GlobalExceptionHandler.java │ ├── ScheduleController.java │ ├── ClassroomController.java │ ├── DatabaseController.java diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c5b5f22..7792a3d 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -146,7 +146,7 @@ frontend/ ### Особенности админских вкладок -- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. @@ -171,19 +171,24 @@ frontend/ ## API-клиент (`api.js`) -Все HTTP-запросы проходят через обёртку `apiFetch()`: +Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом: ```javascript -export async function apiFetch(endpoint, method = 'GET', body = null) { +export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) { const response = await fetch(endpoint, { method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: body ? JSON.stringify(body) : null + headers: getHeaders(body ? 'application/json' : null), + credentials: 'same-origin', + body: body ? JSON.stringify(body) : undefined }); + if (response.status === 401 && retryOnUnauthorized) { + const refreshed = await refreshAccessToken(); + if (refreshed) return apiFetch(endpoint, method, body, false); + clearAuthState(); + window.location.href = '/'; + } + if (!response.ok) { throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); } @@ -200,7 +205,7 @@ export const api = { }; ``` -Токен берётся из `localStorage.getItem('token')`. +При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа. --- @@ -211,11 +216,12 @@ export const api = { 1. Пользователь вводит логин/пароль 2. `script.js` отправляет `POST /api/auth/login` 3. При успехе сохраняет в `localStorage`: - - `token` — UUID-токен + - `token` — access JWT - `role` — роль пользователя - `departmentId` — кафедра пользователя - `userId` — ID пользователя для личного расписания преподавателя -4. Перенаправляет на соответствующий интерфейс: +4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript +5. Перенаправляет на соответствующий интерфейс: - `ADMIN` → `/admin/` - `EDUCATION_OFFICE` → `/admin/#schedule-view` - `DEPARTMENT` → `/admin/#department-workspace` @@ -230,13 +236,13 @@ export const api = { ```javascript export function isAuthenticatedAsAdmin() { const role = localStorage.getItem('role'); - return token && role === 'ADMIN'; + return getToken() && role === 'ADMIN'; } ``` ### Выход -Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`. +Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`. --- diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 6c80225..0a23cea 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -25,13 +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 ``` +`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) @@ -58,6 +66,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ | `frontend` | Deployment | Apache httpd | | `tenants-config` | ConfigMap | JSON-список тенантов | +### JWT настройки + +`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie: + +- `JWT_ACCESS_TOKEN_TTL=15m` +- `JWT_REFRESH_TOKEN_TTL=7d` +- `JWT_REFRESH_COOKIE_SECURE=true` + +`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет. + ### ConfigMap для тенантов ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`. diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 5e96564..5df791b 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -1,38 +1,43 @@ -const token = localStorage.getItem('token'); +let refreshPromise = null; + +const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId']; export function getToken() { - return token; + return localStorage.getItem('token'); } export function isAuthenticatedAsAdmin() { const role = localStorage.getItem('role'); - return token && role === 'ADMIN'; + return getToken() && role === 'ADMIN'; } export function isAuthenticatedAsRole(expectedRole) { const role = localStorage.getItem('role'); - return token && role === expectedRole; + return getToken() && role === expectedRole; } export function isAuthenticatedAsAny(roles) { const role = localStorage.getItem('role'); - return token && roles.includes(role); + return getToken() && roles.includes(role); } function getHeaders(contentType = 'application/json') { - const headers = { - 'Authorization': `Bearer ${token}` - }; + const headers = {}; + const token = getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } if (contentType) { headers['Content-Type'] = contentType; } return headers; } -export async function apiFetch(endpoint, method = 'GET', body = null) { +export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) { const options = { method, - headers: getHeaders(body ? 'application/json' : null) + headers: getHeaders(body ? 'application/json' : null), + credentials: 'same-origin' }; if (body) { @@ -48,6 +53,15 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { data = null; } + if (response.status === 401 && retryOnUnauthorized) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + return apiFetch(endpoint, method, body, false); + } + clearAuthState(); + window.location.href = '/'; + } + if (!response.ok) { throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); } @@ -55,6 +69,57 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { return data; } +export async function refreshAccessToken() { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'same-origin' + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.token) { + return false; + } + storeAuthState(data); + return true; + })().finally(() => { + refreshPromise = null; + }); + + return refreshPromise; +} + +export async function logout() { + try { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin' + }); + } catch (e) { + console.warn('Не удалось завершить серверную сессию:', e.message); + } finally { + clearAuthState(); + } +} + +export function storeAuthState(data) { + if (data.token) localStorage.setItem('token', data.token); + if (data.role) localStorage.setItem('role', data.role); + if (data.departmentId !== undefined && data.departmentId !== null) { + localStorage.setItem('departmentId', data.departmentId); + } + if (data.userId !== undefined && data.userId !== null) { + localStorage.setItem('userId', data.userId); + } +} + +export function clearAuthState() { + AUTH_KEYS.forEach(key => localStorage.removeItem(key)); +} + export const api = { get: (url) => apiFetch(url, 'GET'), post: (url, body) => apiFetch(url, 'POST', body), diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 4b73c3d..812fbb2 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); } -import { isAuthenticatedAsAny } from './api.js'; +import { isAuthenticatedAsAny, logout } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; @@ -143,9 +143,8 @@ document.addEventListener('click', (e) => { }); // Logout -btnLogout.addEventListener('click', () => { - localStorage.removeItem('token'); - localStorage.removeItem('role'); +btnLogout.addEventListener('click', async () => { + await logout(); window.location.href = '/'; }); diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index b1fa96b..014627c 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -88,7 +88,7 @@ export async function initGroups() { await loadGroups(); await loadSubgroups(); } catch (error) { - groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; + groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; subgroupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } @@ -103,13 +103,13 @@ export async function initGroups() { async function loadGroups() { try { - allGroups = await api.get('/api/groups'); + allGroups = await api.get('/api/groups?includeArchived=true'); applyGroupFilter(); populateGroupSelect(); populateSubgroupGroupSelect(); await loadAssignments(); } catch (error) { - groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } @@ -132,7 +132,7 @@ export async function initGroups() { function renderGroups(groups) { if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; + groupsTbody.innerHTML = 'Нет групп'; return; } groupsTbody.innerHTML = groups.map(group => ` @@ -145,9 +145,12 @@ export async function initGroups() { ${group.course || '-'} ${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} ${escapeHtml(group.specialtyProfileName || '-')} + ${escapeHtml(statusLabel(group))} - + ${group.status === 'ARCHIVED' + ? `` + : ``} `).join(''); @@ -232,24 +235,26 @@ export async function initGroups() { function populateGroupSelect() { const current = calendarGroupSelect.value; + const availableGroups = allGroups.filter(group => group.active); calendarGroupSelect.innerHTML = '' + - allGroups.map(group => ``).join(''); - if (current && allGroups.some(group => String(group.id) === String(current))) { + availableGroups.map(group => ``).join(''); + if (current && availableGroups.some(group => String(group.id) === String(current))) { calendarGroupSelect.value = current; - } else if (allGroups.length) { - calendarGroupSelect.value = String(allGroups[0].id); + } else if (availableGroups.length) { + calendarGroupSelect.value = String(availableGroups[0].id); } syncSelects(calendarGroupSelect); } function populateSubgroupGroupSelect() { const current = subgroupGroupSelect.value; + const availableGroups = allGroups.filter(group => group.active); subgroupGroupSelect.innerHTML = '' + - allGroups.map(group => ``).join(''); - if (current && allGroups.some(group => String(group.id) === String(current))) { + availableGroups.map(group => ``).join(''); + if (current && availableGroups.some(group => String(group.id) === String(current))) { subgroupGroupSelect.value = current; - } else if (allGroups.length) { - subgroupGroupSelect.value = String(allGroups[0].id); + } else if (availableGroups.length) { + subgroupGroupSelect.value = String(availableGroups[0].id); } syncSelects(subgroupGroupSelect); } @@ -342,15 +347,26 @@ export async function initGroups() { async function handleGroupTableClick(event) { const btnDelete = event.target.closest('.btn-delete'); const btnEdit = event.target.closest('.btn-edit-group'); + const btnRestore = event.target.closest('.btn-restore-group'); if (btnDelete) { - if (!confirm('Удалить группу?')) return; + if (!confirm('Архивировать группу? История расписания сохранится.')) return; try { await api.delete('/api/groups/' + btnDelete.dataset.id); await loadGroups(); await loadSubgroups(); } catch (error) { - alert(error.message || 'Ошибка удаления'); + alert(error.message || 'Ошибка архивирования'); + } + } + + if (btnRestore) { + try { + await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {}); + await loadGroups(); + await loadSubgroups(); + } catch (error) { + alert(error.message || 'Ошибка восстановления'); } } @@ -566,6 +582,21 @@ export async function initGroups() { return name ? `${code} — ${name}` : code; } + function statusLabel(group) { + if (group.status === 'ARCHIVED') return 'Архив'; + return group.studyStateName || (group.active ? 'Активна' : 'Неактивна'); + } + + function statusBadgeClass(group) { + if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') { + return 'badge-unavailable'; + } + if (group.studyState === 'NOT_STARTED') { + return 'badge-ef'; + } + return 'badge-available'; + } + function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { select.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index 3f99b58..9a41f88 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -1,10 +1,11 @@ // Основной модуль страницы настроек import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; +import { getToken } from '../../js/api.js'; + // Проверка авторизации -const token = localStorage.getItem('token'); const role = localStorage.getItem('role'); -if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { +if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { window.location.href = '/'; } diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 879269a..8a40df9 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -67,12 +67,13 @@ Курс Специальность Профиль + Статус Действия - Загрузка... + Загрузка... diff --git a/frontend/script.js b/frontend/script.js index cf3632f..d1455fa 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -127,23 +127,28 @@ hideAlert(); try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: usernameInput.value.trim(), - password: passwordInput.value, - }), + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + username: usernameInput.value.trim(), + password: passwordInput.value, + }), }); const data = await response.json(); - if (response.ok) { - showAlert('Вход выполнен успешно!', 'success'); + if (response.ok) { + showAlert('Вход выполнен успешно!', 'success'); - if (data.token) localStorage.setItem('token', data.token); - if (data.role) localStorage.setItem('role', data.role); - if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); + localStorage.removeItem('token'); + localStorage.removeItem('role'); + localStorage.removeItem('departmentId'); + localStorage.removeItem('userId'); + if (data.token) localStorage.setItem('token', data.token); + if (data.role) localStorage.setItem('role', data.role); + if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); if (data.userId) localStorage.setItem('userId', data.userId); const redirect = data.redirect || '/'; diff --git a/frontend/student/index.html b/frontend/student/index.html index 34219aa..2108e18 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -292,19 +292,18 @@