From 81e91e056f044e8230e6fb5ede37c6dd717c917f Mon Sep 17 00:00:00 2001 From: Zuev Date: Thu, 19 Mar 2026 23:47:01 +0300 Subject: [PATCH 1/8] feat: Add OpenTelemetry integration by creating `otel.js` and importing it into `main.js`. --- frontend/admin/js/main.js | 2 ++ frontend/admin/js/otel.js | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 frontend/admin/js/otel.js diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 235203e..ee2f9e4 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -1,3 +1,5 @@ +import './otel.js'; + import { isAuthenticatedAsAdmin } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; diff --git a/frontend/admin/js/otel.js b/frontend/admin/js/otel.js new file mode 100644 index 0000000..2de329d --- /dev/null +++ b/frontend/admin/js/otel.js @@ -0,0 +1,47 @@ +import { WebTracerProvider } from 'https://esm.sh/@opentelemetry/sdk-trace-web@1.22.0'; +import { getWebAutoInstrumentations } from 'https://esm.sh/@opentelemetry/auto-instrumentations-web@0.37.0'; +import { OTLPTraceExporter } from 'https://esm.sh/@opentelemetry/exporter-trace-otlp-http@0.49.1'; +import { BatchSpanProcessor } from 'https://esm.sh/@opentelemetry/sdk-trace-base@1.22.0'; +import { registerInstrumentations } from 'https://esm.sh/@opentelemetry/instrumentation@0.49.1'; +import { ZoneContextManager } from 'https://esm.sh/@opentelemetry/context-zone@1.22.0'; +import { Resource } from 'https://esm.sh/@opentelemetry/resources@1.22.0'; +import { SemanticResourceAttributes } from 'https://esm.sh/@opentelemetry/semantic-conventions@1.22.0'; + +// Инициализация провайдера метрик и трейсов с именем сервиса +const provider = new WebTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'magistr-frontend-admin', + }), +}); + +// Экспортер отправляет данные на относительный путь /otel/v1/traces. +// На проде Caddy перехватит этот запрос и проксирует в SigNoz OTLP Collector (порт 4318). +const traceExporter = new OTLPTraceExporter({ + url: window.location.origin + '/otel/v1/traces', +}); + +// Использование BatchSpanProcessor для буферизации трейсов перед отправкой +provider.addSpanProcessor(new BatchSpanProcessor(traceExporter)); + +// Использование ZoneContextManager для поддержки асинхронных операций (Promise, setTimeout, etc) +provider.register({ + contextManager: new ZoneContextManager(), +}); + +// Регистрация авто-инструментаций для бразуера (document-load, xml-http-request, fetch, history, etc) +registerInstrumentations({ + instrumentations: [ + getWebAutoInstrumentations({ + '@opentelemetry/instrumentation-fetch': { + propagateTraceHeaderCorsUrls: /.*/, + clearTimingResources: true, + }, + '@opentelemetry/instrumentation-xml-http-request': { + propagateTraceHeaderCorsUrls: /.*/, + clearTimingResources: true, + }, + }), + ], +}); + +console.log('OpenTelemetry Web SDK initialized successfully.'); From 491807cd9464afcc8695f1b41de9021036abd5e7 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 22 Mar 2026 02:49:13 +0300 Subject: [PATCH 2/8] docs: Add comprehensive project documentation covering architecture, development, and APIs, and update AGENTS.md. --- .gitignore | 4 +- AGENTS.md | 169 +++-------------- docs/API.md | 420 +++++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 142 ++++++++++++++ docs/BUSINESS_LOGIC.md | 149 +++++++++++++++ docs/DATABASE.md | 362 +++++++++++++++++++++++++++++++++++ docs/DEVELOPMENT.md | 275 +++++++++++++++++++++++++++ docs/FRONTEND.md | 203 ++++++++++++++++++++ docs/INFRASTRUCTURE.md | 137 ++++++++++++++ docs/README.md | 113 +++++++++++ 10 files changed, 1829 insertions(+), 145 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BUSINESS_LOGIC.md create mode 100644 docs/DATABASE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/FRONTEND.md create mode 100644 docs/INFRASTRUCTURE.md create mode 100644 docs/README.md diff --git a/.gitignore b/.gitignore index b362f1c..98c3a42 100755 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ backend/build/ frontend/node_modules/ frontend/dist/ -.agents .idea/ .vscode/ -*.DS_Store -GEMINI.md \ No newline at end of file +*.DS_Store \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1e900b4..033f2d5 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,174 +28,59 @@ magistr/ │ ├── admin/ # Интерфейс администратора │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента +├── docs/ # 📖 Документация проекта ├── compose.yaml # Docker Compose конфигурация └── .env # Переменные окружения ``` **Внешние зависимости (родительская директория)** -На уровень выше расположен `../caddy-proxy/`. Это реверс-прокси, обрабатывающий трафик для `magistr.zuev.company`. Если возникают проблемы с доменом или внешним доступом, проверяйте `Caddyfile` там. - -так же на уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там. +На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там. --- -## Команды сборки и запуска - -### Docker Compose (основной способ) - -Сборка и запуск всех сервисов (backend, frontend, PostgreSQL) выполняется через Docker Compose. +## Быстрый справочник команд ```bash -# Сборка и запуск всех сервисов +# Сборка и запуск docker compose up -d --build -# Остановка всех сервисов -docker compose down +# Полный сброс БД +docker compose down -v && docker compose up -d -# Просмотр логов всех сервисов -docker compose logs -f - -# Просмотр логов конкретного сервиса +# Логи конкретного сервиса docker compose logs -f backend - -# Пересоздать контейнер базы данных (полный сброс данных и повтор миграций Flyway) -docker compose down -v -docker compose up -d db ``` -### Frontend - -Статические файлы обслуживаются через Apache (httpd:alpine). Изменения в файлах frontend требуют пересборки контейнера. +Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md). --- -## Соглашения о коде (Code Style) +## Критические правила для агентов -### Java (Backend) - -**Именование:** -- Классы: PascalCase (например, `LessonsController`, `LessonResponse`) -- Методы и переменные: camelCase -- Константы: UPPER_SNAKE_CASE -- Пакеты: lowercase (например, `com.magistr.app.controller`) - -**Импорты:** -- Группировка: static imports, затем external packages, затем internal -- Используйте wildcard imports для пакетов того же модуля: `import com.magistr.app.model.*;` -- Порядок: java.*, javax.*, external.*, internal.* - -**Форматирование:** -- Отступы: 4 пробела (стандарт Java) -- Фигурные скобки: K&R style (открывающая на той же строке) -- Длина строки: до 120 символов -- Всегда используйте фигурные скобки для if/for/while - -**Типы и аннотации:** -- Используйте явные типы вместо `var` для возвращаемых значений публичных методов -- Аннотации JPA: `@Entity`, `@Table`, `@Id`, `@GeneratedValue`, `@Column` -- Используйте `@JsonInclude(JsonInclude.Include.NON_NULL)` для DTO -- Для логгирования используйте SLF4J: `LoggerFactory.getLogger(ClassName.class)` - -**Обработка ошибок:** -- Возвращайте `ResponseEntity` с соответствующим HTTP статусом -- Логируйте ошибки с полным стектрейсом: `logger.error("msg: {}", e.getMessage(), e)` -- Для валидации используйте отдельные классы-валидаторы (см. `DayAndWeekValidator`) - -**Архитектура контроллеров:** -- Используйте constructor injection для зависимостей -- Все endpoints имеют префикс `/api/` -- Возвращайте понятные сообщения об ошибках на русском языке - -### Frontend (JavaScript) - -**Именование:** -- Файлы: kebab-case (например, `main.js`, `schedule-view.js`) -- Функции и переменные: camelCase -- Константы: UPPER_SNAKE_CASE - -**Модули:** -- Используйте ES6 modules с `import`/`export` -- Всегда указывайте расширение при импорте: `import { x } from './api.js';` - -**Форматирование:** -- Отступы: 4 пробела -- Используйте template literals вместо конкатенации строк -- Предпочитайте `const` переменные, используйте `let` только при необходимости переприсваивания - -**Лучшие практики:** -- Используйте `async/await` для асинхронных операций -- Всегда обрабатывайте ошибки в блоках `catch` -- Используйте деструктуризацию объектов -- Кешируйте DOM-элементы в переменные - ---- - -## Работа с базой данных и мультитенантностью - -**Мультитенантность:** -- Приложение поддерживает множество клиентов (университетов). Каждый клиент имеет свою изолированную базу данных PostgreSQL. -- Маршрутизация к нужной БД происходит динамически на основе поддомена (`TenantInterceptor` -> `TenantContext` -> `TenantRoutingDataSource`). -- Список клиентов хранится в Kubernetes `ConfigMap` (`tenants-config`), который монтируется в под бэкенда как `/config/tenants.json`. -- Локально список берётся из файла `backend/tenants.json`. -- При добавлении нового клиента в интерфейсе `DatabaseController` через K8s API обновляет `ConfigMap`. Все реплики бэкенда заметят изменения и в фоне инициализируют новый пул соединений (`TenantConfigWatcher`). - -**Миграции схемы (Flyway):** -- Автогенерация Hibernate ОТКЛЮЧЕНА (`ddl-auto=none`). Структура баз данных управляется строго через **Flyway**. -- Все изменения схемы БД вносятся путем создания новых файлов в `backend/src/main/resources/db/migration/` (название строго `V2__add_new_table.sql` и т.д.). -- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`), которые уже закоммичены. Это сломает контрольные суммы Flyway. -- Flyway запускается программно при первом обращении к базе тенанта. Чтобы запустить Flyway для уже существующих тенантов (накатить V2), необходимо перезапустить бэкенд: `kubectl rollout restart deployment backend -n magistr`. -- Для локального сброса базы до изначального состояния: `docker compose down -v && docker compose up -d`. - -**Сущности и связи:** -- Foreign keys с `ON DELETE CASCADE` для поддержания целостности -- Используйте расширение `pgcrypto` для хеширования паролей (bcrypt) - ---- - -## Функциональные требования к системе (Бизнес-логика) - -### 1. Ролевая модель -- **Администратор (Деканат)**: Полный доступ, настройка топологии университета, управление аудиторным фондом, подтверждение переносов, регистрация инцидентов. -- **Преподаватель**: Просмотр своего расписания, подача заявок на перенос, отметка о своём отсутствии. -- **Студент**: Только просмотр расписания (Read-only). - -### 2. Управление ресурсами и топология -- **Управление аудиториями**: - - Указание вместимости. - - Привязка доступного оборудования (через сущность Equipments: Проектор, ПК, Лаборатория). - - Установка статуса "Не доступно" (блокирует назначение пар в этот период). -- **Управление группами**: - - Управление списком студентов (и возможность деления на подгруппы). -- **Управление дисциплинами**: - - Создание предметов и привязка их к преподавателям (какие дисциплины имеет право вести конкретный преподаватель). - -### 3. Логика расписания -- **Сетка**: 7 фиксированных слотов по 1.5 часа (08:00 - 09:30, и т.д.) + поддержка кастомного времени. -- **Проверка конфликтов**: - - *Критический конфликт*: Преподаватель не может находиться в двух разных аудиториях одновременно. - - *Уточнение по преподавателям*: Преподаватель может иметь несколько пар одновременно (для разных групп), только если они проходят в одной и той же аудитории (потоковая лекция). -- **Потоковые занятия**: - - Возможность назначить одну лекцию сразу нескольким группам (технически — несколько записей в БД или одна запись со списком групп). - - Проверка вместимости: вместимость аудитории должна покрывать суммарную численность всех групп, находящихся в этой аудитории в данный слот. - -### 4. Управление инцидентами (Инклюзия отсутствия) -- **Отсутствие (Sickness/Business Trip)**: Регистрация отсутствия преподавателя (с указанием причины и периода дат). -- **Обнаружение коллизий**: Автоматическая подсветка конфликтующих пар в расписании (Red Zone). -- **Система разрешения конфликтов (Resolution Wizard)**: - - Предложение подходящей замены преподавателя на этот слот. - - Предложение переноса занятия на другое время или в другую аудиторию. - ---- - -## Языковые требования +### Flyway миграции +- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. +- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/` +- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md) +### Языковые требования - **Все ответы и комментарии на русском языке** - Сообщения об ошибках и логи на русском - Пользовательский интерфейс на русском --- -## Существующие правила проекта +## Подробная документация -См. `.agent/rules/main.md` и `.agent/rules/database_schema.md` для полного контекста о функциональных требованиях и схеме БД. +Полная документация проекта находится в папке `docs/`: + +| Документ | Содержание | +|----------|-----------| +| [`docs/README.md`](docs/README.md) | Обзор проекта, стек технологий, быстрый старт | +| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Архитектура системы, мультитенантность, аутентификация | +| [`docs/BUSINESS_LOGIC.md`](docs/BUSINESS_LOGIC.md) | Бизнес-логика, ролевая модель, правила расписания | +| [`docs/DATABASE.md`](docs/DATABASE.md) | Схема БД (ER-диаграмма), описание всех таблиц, Flyway | +| [`docs/API.md`](docs/API.md) | REST API эндпоинты с примерами запросов и ответов | +| [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг (SigNoz) | +| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта | +| [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность | diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f8e40c3 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,420 @@ +# 🔌 REST API + +Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON. + +--- + +## Аутентификация + +### `POST /api/auth/login` + +Вход в систему. + +**Тело запроса:** +```json +{ + "username": "admin", + "password": "admin" +} +``` + +**Успешный ответ (200):** +```json +{ + "success": true, + "message": "OK", + "token": "550e8400-e29b-41d4-a716-446655440000", + "role": "ADMIN", + "redirect": "/admin/" +} +``` + +**Ошибка (401):** +```json +{ + "success": false, + "message": "Неверное имя пользователя или пароль", + "token": null, + "role": null, + "redirect": null +} +``` + +> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` + +--- + +## Пользователи + +### `GET /api/users` + +Список всех пользователей. + +**Ответ:** +```json +[ + { "id": 1, "username": "admin", "role": "ADMIN" }, + { "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER" } +] +``` + +### `GET /api/users/teachers` + +Список только преподавателей (роль `TEACHER`). + +### `POST /api/users` + +Создание пользователя. + +**Тело запроса:** +```json +{ + "username": "Новый преподаватель", + "password": "password123", + "role": "TEACHER" +} +``` + +**Валидация:** +- `username` — обязателен +- `password` — минимум 4 символа +- `role` — `ADMIN`, `TEACHER` или `STUDENT` + +### `DELETE /api/users/{id}` + +Удаление пользователя. + +--- + +## Расписание (Lessons) + +### `GET /api/users/lessons` + +Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория). + +**Ответ:** +```json +[ + { + "id": 1, + "teacherName": "Тестовый преподаватель", + "groupName": "ИВТ-21-1", + "classroomName": "101 Ленинская", + "educationFormName": "Бакалавриат", + "subjectName": "Высшая математика", + "typeLesson": "Лекция", + "lessonFormat": "Очно", + "day": "Понедельник", + "week": "Верхняя", + "time": "11:40 - 13:10" + } +] +``` + +### `GET /api/users/lessons/{teacherId}` + +Занятия конкретного преподавателя. + +### `POST /api/users/lessons/create` + +Создание занятия. + +**Тело запроса:** +```json +{ + "teacherId": 2, + "groupId": 1, + "subjectId": 1, + "lessonFormat": "Очно", + "typeLesson": "Лекция", + "classroomId": 1, + "day": "Понедельник", + "week": "Верхняя", + "time": "11:40 - 13:10" +} +``` + +**Валидация:** +| Поле | Правило | +|------|---------| +| `teacherId` | Обязателен, ≠ 0 | +| `groupId` | Обязателен, ≠ 0 | +| `subjectId` | Обязателен, ≠ 0 | +| `lessonFormat` | `Очно` или `Онлайн` | +| `typeLesson` | `Лекция`, `Практическая работа`, `Лабораторная работа` | +| `classroomId` | Обязателен, ≠ 0 | +| `day` | Пн–Сб (на русском) | +| `week` | `Верхняя`, `Нижняя`, `Обе` | +| `time` | Обязателен | + +### `PUT /api/users/lessons/update/{lessonId}` + +Обновление занятия. Поддерживает partial update — передаются только изменённые поля. + +### `DELETE /api/users/lessons/delete/{lessonId}` + +Удаление занятия. + +### `GET /api/users/lessons/ping` + +Проверка доступности контроллера. Возвращает строку `pong`. + +--- + +## Группы + +### `GET /api/groups` + +Список всех групп. + +**Ответ:** +```json +[ + { + "id": 1, + "name": "ИВТ-21-1", + "groupSize": 25, + "educationFormId": 1, + "educationFormName": "Бакалавриат" + } +] +``` + +### `POST /api/groups` + +Создание группы. + +```json +{ + "name": "ИБ-31м", + "groupSize": 20, + "educationFormId": 2 +} +``` + +### `DELETE /api/groups/{id}` + +Удаление группы. + +--- + +## Аудитории + +### `GET /api/classrooms` + +Список аудиторий с привязанным оборудованием. + +**Ответ:** +```json +[ + { + "id": 1, + "name": "101 Ленинская", + "capacity": 120, + "isAvailable": true, + "equipments": [ + { "id": 1, "name": "Проектор" }, + { "id": 4, "name": "Интерактивная доска" } + ] + } +] +``` + +### `POST /api/classrooms` + +Создание аудитории. + +```json +{ + "name": "404 Лаборатория", + "capacity": 30, + "isAvailable": true, + "equipmentIds": [1, 2, 3] +} +``` + +### `PUT /api/classrooms/{id}` + +Обновление аудитории (partial update). + +### `DELETE /api/classrooms/{id}` + +Удаление аудитории. + +--- + +## Дисциплины + +### `GET /api/subjects` + +Список всех дисциплин. + +### `POST /api/subjects` + +```json +{ "name": "Физика" } +``` + +### `DELETE /api/subjects/{id}` + +Удаление дисциплины. + +--- + +## Оборудование + +### `GET /api/equipments` + +Список всего оборудования. + +### `POST /api/equipments` + +```json +{ "name": "3D-принтер" } +``` + +### `DELETE /api/equipments/{id}` + +Удаление оборудования. + +--- + +## Формы обучения + +### `GET /api/education-forms` + +Список форм обучения. + +**Ответ:** +```json +[ + { "id": 1, "name": "Бакалавриат" }, + { "id": 2, "name": "Магистратура" } +] +``` + +### `POST /api/education-forms` + +```json +{ "name": "Аспирантура" } +``` + +### `DELETE /api/education-forms/{id}` + +Удаление формы обучения. **Невозможно**, если к ней привязаны группы. + +--- + +## Привязка «Преподаватель ↔ Дисциплина» + +### `GET /api/teacher-subjects` + +Список всех привязок. + +**Ответ:** +```json +[ + { + "userId": 2, + "userName": "Тестовый преподаватель", + "subjectId": 1, + "subjectName": "Высшая математика" + } +] +``` + +### `POST /api/teacher-subjects` + +```json +{ + "userId": 2, + "subjectId": 3 +} +``` + +### `DELETE /api/teacher-subjects` + +```json +{ + "userId": 2, + "subjectId": 3 +} +``` + +--- + +## Управление тенантами (Базы данных) + +### `GET /api/database/status` + +Статус текущего подключения (определяется по домену запроса). + +**Ответ:** +```json +{ + "tenant": "default", + "connected": true, + "configured": true, + "name": "Default", + "url": "jdbc:postgresql://db:5432/app_db" +} +``` + +### `GET /api/database/tenants` + +Список всех тенантов. + +### `POST /api/database/tenants` + +Добавление нового тенанта. + +```json +{ + "name": "СВФУ", + "domain": "swsu", + "url": "jdbc:postgresql://db-host:5432/swsu_db", + "username": "dbuser", + "password": "dbpass" +} +``` + +**Логика:** +1. Создаёт HikariCP пул для нового тенанта +2. Запускает Flyway миграции на его БД +3. Обновляет Kubernetes ConfigMap + +### `DELETE /api/database/tenants/{domain}` + +Удаление тенанта. + +### `POST /api/database/test` + +Тест подключения к произвольной БД (без регистрации тенанта). + +```json +{ + "url": "jdbc:postgresql://host:5432/testdb", + "username": "user", + "password": "pass" +} +``` + +**Ответ:** +```json +{ + "success": true, + "message": "Подключение успешно!" +} +``` + +--- + +## Коды ответов + +| Код | Описание | +|-----|----------| +| `200` | Успех | +| `400` | Ошибка валидации (с `message` в теле) | +| `401` | Неверные учётные данные | +| `404` | Ресурс / тенант не найден | +| `500` | Внутренняя ошибка сервера | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f4e3cf9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,142 @@ +# 🏗 Архитектура системы + +## Общая схема + +```mermaid +graph TD + Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"] + Caddy -->|:80| Frontend["Frontend
(Apache httpd:alpine)"] + Caddy -->|/api/*| Backend["Backend
(Spring Boot 3.2.5)"] + + Backend --> TenantRouter{"TenantRoutingDataSource"} + TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL
swsu_db"] + TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL
mgu_db"] + TenantRouter -->|...| DBn["PostgreSQL
tenant_n_db"] + + Backend -->|Метрики, Логи, Трейсы| OTel["OpenTelemetry Collector"] + OTel --> SigNoz["SigNoz"] +``` + +## Компоненты + +### Frontend (Apache httpd:alpine) +- **Тип:** Статические файлы (HTML/CSS/JS) +- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server +- **Порт:** 80 +- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/` +- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`) + +### Backend (Spring Boot 3.2.5) +- **Тип:** REST API сервер +- **Язык:** Java 17 +- **Порт:** 8080 (внутренний) +- **ORM:** Hibernate (JPA), `ddl-auto=none` +- **Миграции:** Flyway (программный запуск при подключении тенанта) +- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены + +### PostgreSQL +- **Версия:** `postgres:alpine3.23` +- **Локально:** Одна БД `app_db` (тенант `default`) +- **Продакшн:** Множество БД, по одной на каждый университет (тенант) + +### Caddy (реверс-прокси) +- **Расположение:** `../caddy-proxy/` +- **Назначение:** TLS-терминация, маршрутизация запросов к backend/frontend +- **Домен:** `*.zuev.company` + +--- + +## Мультитенантная архитектура + +Ключевая особенность системы — изоляция данных каждого университета в отдельной БД PostgreSQL. + +### Принцип работы + +```mermaid +sequenceDiagram + participant Browser as Браузер + participant Interceptor as TenantInterceptor + participant Context as TenantContext + participant Router as TenantRoutingDataSource + participant DB as PostgreSQL + + Browser->>Interceptor: GET /api/users
Host: swsu.zuev.company + Interceptor->>Interceptor: resolveTenant("swsu.zuev.company") → "swsu" + Interceptor->>Context: setCurrentTenant("swsu") + Note over Interceptor: Проверка: hasTenant("swsu")? + Interceptor-->>Browser: 404 если тенант не найден + + Note over Context,Router: Обработка запроса контроллером + Router->>Router: determineCurrentLookupKey() → "swsu" + Router->>DB: SQL запрос к swsu_db + DB-->>Browser: Ответ с данными +``` + +### Ключевые классы + +| Класс | Назначение | +|-------|-----------| +| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант | +| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта | +| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД | +| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | +| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | +| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API | +| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` | + +### Определение тенанта + +Логика определения тенанта по заголовку `Host`: + +| Host | Результат | +|------|----------| +| `swsu.zuev.company` | `swsu` | +| `mgu.zuev.company` | `mgu` | +| `localhost` | `default` | +| `localhost:8080` | `default` | +| `192.168.1.1` | `default` | + +### Конфигурация тенантов + +Список тенантов хранится в JSON-файле: +- **Локально:** `backend/tenants.json` +- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json` + +Формат: +```json +[ + { + "name": "ЮЗГУ", + "domain": "swsu", + "url": "jdbc:postgresql://db-host:5432/swsu_db", + "username": "dbuser", + "password": "dbpass" + } +] +``` + +### Жизненный цикл тенанта + +1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap +2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты +3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap + +### Fallback при отсутствии тенантов + +Если при запуске нет ни одного настроенного тенанта: +1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` +2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA + +--- + +## Аутентификация + +Система использует **простую модель аутентификации** без JWT или Spring Security фильтров: + +1. Клиент отправляет `POST /api/auth/login` с `username` и `password` +2. Backend проверяет пароль через `BCryptPasswordEncoder` +3. При успехе возвращается: + - UUID-токен (для заголовка `Authorization: Bearer`) + - Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`) + - Redirect URL (`/admin/`, `/teacher/`, `/student/`) +4. Токен хранится в `localStorage` на клиенте diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md new file mode 100644 index 0000000..08cf64e --- /dev/null +++ b/docs/BUSINESS_LOGIC.md @@ -0,0 +1,149 @@ +# 📋 Бизнес-логика + +## Ролевая модель + +Система поддерживает три роли пользователей: + +| Роль | Enum | Возможности | +|------|------|------------| +| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). | +| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | +| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | + +После авторизации пользователь перенаправляется на свой интерфейс: +- `ADMIN` → `/admin/` +- `TEACHER` → `/teacher/` +- `STUDENT` → `/student/` + +--- + +## Управление ресурсами + +### Кафедры (Departments) + +Организационные единицы университета. К кафедре привязываются пользователи, группы и дисциплины. + +- Имеют уникальный числовой `code` +- Предзаполнены: «Кафедра ИБ», «Кафедра ВТ», «Кафедра КТ» + +### Специальности (Specialties) + +Учебные направления с кодом по ФГОС. + +- Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04) + +### Формы обучения (Education Forms) + +Уровни/формы обучения для привязки к группам. + +- Предзаполнены: Бакалавриат, Магистратура, Специалитет +- Нельзя удалить форму обучения, если к ней привязаны группы + +### Учебные группы (Student Groups) + +- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6) +- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) + +### Аудитории (Classrooms) + +- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность +- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества +- **Статус:** Флаг `is_available` для блокирования назначения пар + +### Оборудование (Equipments) + +Каталог оборудования для привязки к аудиториям. + +- Предзаполнены: Проектор, ПК, Лаборатория, Интерактивная доска, Документ-камера, Аудиосистема +- Уникальность по названию + +### Дисциплины (Subjects) + +- **Поля:** Название (уникальное), код, кафедра, описание +- Привязка преподавателей через `teacher_subjects` (Many-to-Many) + +--- + +## Логика расписания + +### Сущность «Занятие» (Lesson) + +Каждая запись в расписании содержит: + +| Поле | Описание | Пример | +|------|----------|--------| +| `teacher_id` | Преподаватель | 2 | +| `group_id` | Учебная группа | 1 | +| `subject_id` | Дисциплина | 3 | +| `lesson_format` | Формат проведения | `Очно`, `Онлайн` | +| `type_lesson` | Тип занятия | `Лекция`, `Практическая работа`, `Лабораторная работа` | +| `classroom_id` | Аудитория | 1 | +| `day` | День недели | `Понедельник` ... `Суббота` | +| `week` | Чётность недели | `Верхняя`, `Нижняя`, `Обе` | +| `time` | Временной слот | `8:00 - 9:30` | + +### Временны́е слоты + +Система использует 7 фиксированных слотов по 90 минут: + +| № | Время | +|---|-------| +| 1 | 08:00 – 09:30 | +| 2 | 09:40 – 11:10 | +| 3 | 11:40 – 13:10 | +| 4 | 13:30 – 15:00 | +| 5 | 15:00 – 16:30 | +| 6 | 16:40 – 18:10 | +| 7 | 18:30 – 20:00 | + +### Валидация при создании/обновлении + +- **Дни:** только `Понедельник` – `Суббота` (`DayAndWeekValidator`) +- **Недели:** только `Верхняя`, `Нижняя`, `Обе` +- **Формат:** только `Очно`, `Онлайн` (`TypeAndFormatLessonValidator`) +- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа` +- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0 + +### Данные к составлению расписания (Schedule Data) + +Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания: + +| Поле | Описание | +|------|----------| +| `department_id` | Кафедра | +| `semester` | Номер семестра | +| `group_id` | Учебная группа | +| `subjects_id` | Дисциплина | +| `lesson_type_id` | Тип занятия | +| `number_of_hours` | Количество часов | +| `is_division` | Деление на подгруппы | +| `teacher_id` | Преподаватель | +| `semester_type` | Тип семестра (Весенний / Осенний) | +| `period` | Учебный год (напр. `2024/2025`) | + +--- + +## Привязка преподаватель ↔ дисциплина + +Связь Many-to-Many через таблицу `teacher_subjects`: +- Указывается, какие дисциплины может вести конкретный преподаватель +- Дополнительные поля: `qualification_level`, `experience_years` + +Дополнительная связь через `teacher_lesson_types`: +- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине + +--- + +## Бизнес-правила (планируемые) + +> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде. + +### Проверка конфликтов +- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях +- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории +- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории + +### Управление инцидентами +- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода +- Автоматическая подсветка конфликтующих пар (Red Zone) +- Resolution Wizard: предложение замены преподавателя или переноса занятия diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..c025547 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,362 @@ +# 🗄 База данных + +## Общая информация + +- **СУБД:** PostgreSQL (локально `postgres:alpine3.23`, продакшн — managed PostgreSQL) +- **Управление схемой:** Flyway (программный запуск) +- **Hibernate DDL:** Отключён (`ddl-auto=none`) +- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей) +- **Мультитенантность:** Каждый тенант = отдельная БД + +--- + +## ER-диаграмма + +```mermaid +erDiagram + departments { + BIGSERIAL id PK + VARCHAR name + BIGINT code UK + } + + specialties { + BIGSERIAL id PK + VARCHAR name + VARCHAR specialty_code + } + + users { + BIGSERIAL id PK + VARCHAR username UK + VARCHAR password + VARCHAR role + VARCHAR full_name + VARCHAR job_title + BIGINT department_id FK + TIMESTAMP created_at + TIMESTAMP updated_at + } + + education_forms { + BIGSERIAL id PK + VARCHAR name UK + TEXT description + TIMESTAMP created_at + } + + student_groups { + BIGSERIAL id PK + VARCHAR name UK + BIGINT group_size + BIGINT education_form_id FK + BIGINT department_id FK + INT course + TIMESTAMP created_at + } + + subgroups { + BIGSERIAL id PK + BIGINT group_id FK + VARCHAR name + INT student_capacity + } + + subjects { + BIGSERIAL id PK + VARCHAR name UK + VARCHAR code + BIGINT department_id FK + TEXT description + TIMESTAMP created_at + } + + lesson_types { + BIGSERIAL id PK + VARCHAR name UK + VARCHAR color_code + INT duration_minutes + } + + equipments { + BIGSERIAL id PK + VARCHAR name UK + TEXT description + VARCHAR inventory_number + } + + classrooms { + BIGSERIAL id PK + VARCHAR name UK + INT capacity + VARCHAR building + INT floor + BOOLEAN is_available + TEXT description + TIMESTAMP created_at + } + + classroom_equipments { + BIGINT classroom_id FK,PK + BIGINT equipment_id FK,PK + INT quantity + TEXT notes + } + + teacher_subjects { + BIGINT user_id FK,PK + BIGINT subject_id FK,PK + VARCHAR qualification_level + INT experience_years + } + + teacher_lesson_types { + BIGINT user_id FK,PK + BIGINT subject_id FK,PK + BIGINT lesson_type_id FK,PK + } + + lessons { + BIGSERIAL id PK + BIGINT teacher_id FK + BIGINT group_id FK + BIGINT subject_id FK + VARCHAR lesson_format + VARCHAR type_lesson + BIGINT classroom_id FK + VARCHAR day + VARCHAR week + VARCHAR time + } + + schedule_data { + BIGSERIAL id PK + BIGINT department_id FK + INT semester + BIGINT group_id FK + BIGINT subjects_id FK + BIGINT lesson_type_id FK + INT number_of_hours + BOOLEAN is_division + BIGINT teacher_id FK + VARCHAR semester_type + VARCHAR period + } + + departments ||--o{ users : "department_id" + departments ||--o{ student_groups : "department_id" + departments ||--o{ subjects : "department_id" + departments ||--o{ schedule_data : "department_id" + education_forms ||--o{ student_groups : "education_form_id" + student_groups ||--o{ subgroups : "group_id" + student_groups ||--o{ lessons : "group_id" + student_groups ||--o{ schedule_data : "group_id" + users ||--o{ lessons : "teacher_id" + users ||--o{ teacher_subjects : "user_id" + users ||--o{ teacher_lesson_types : "user_id" + users ||--o{ schedule_data : "teacher_id" + subjects ||--o{ lessons : "subject_id" + subjects ||--o{ teacher_subjects : "subject_id" + subjects ||--o{ teacher_lesson_types : "subject_id" + subjects ||--o{ schedule_data : "subjects_id" + lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" + lesson_types ||--o{ schedule_data : "lesson_type_id" + classrooms ||--o{ lessons : "classroom_id" + classrooms ||--o{ classroom_equipments : "classroom_id" + equipments ||--o{ classroom_equipments : "equipment_id" +``` + +--- + +## Описание таблиц + +### Справочники высшего уровня + +#### `departments` — Кафедры +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID кафедры | +| `name` | VARCHAR(255) | Название кафедры | +| `code` | BIGINT UNIQUE | Код кафедры | + +#### `specialties` — Специальности +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID специальности | +| `name` | VARCHAR(255) | Название специальности | +| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) | + +### Пользователи + +#### `users` — Пользователи системы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID пользователя | +| `username` | VARCHAR(50) UNIQUE | Логин | +| `password` | VARCHAR(255) | bcrypt-хеш пароля | +| `role` | VARCHAR(20) | `ADMIN`, `TEACHER`, `STUDENT` | +| `full_name` | VARCHAR(255) | ФИО | +| `job_title` | VARCHAR(255) | Должность | +| `department_id` | BIGINT FK → departments | Кафедра | +| `created_at` | TIMESTAMP | Дата создания | +| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) | + +> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. + +### Учебный процесс + +#### `education_forms` — Формы обучения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) | +| `description` | TEXT | Описание | + +#### `student_groups` — Учебные группы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) | +| `group_size` | BIGINT | Количество студентов | +| `education_form_id` | BIGINT FK → education_forms | Форма обучения | +| `department_id` | BIGINT FK → departments | Кафедра | +| `course` | INT CHECK(1–6) | Курс | + +#### `subgroups` — Подгруппы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа | +| `name` | VARCHAR(100) | Название подгруппы | +| `student_capacity` | INT | Количество студентов | + +#### `subjects` — Дисциплины +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(200) UNIQUE | Название | +| `code` | VARCHAR(20) | Код предмета | +| `department_id` | BIGINT FK → departments | Кафедра | +| `description` | TEXT | Описание | + +### Аудиторный фонд + +#### `classrooms` — Аудитории +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(50) UNIQUE | Название (напр. `101 Ленинская`) | +| `capacity` | INT CHECK(> 0) | Вместимость | +| `building` | VARCHAR(50) | Корпус | +| `floor` | INT | Этаж | +| `is_available` | BOOLEAN | Доступна для назначения пар | +| `description` | TEXT | Описание | + +#### `equipments` — Оборудование +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(50) UNIQUE | Название | +| `description` | TEXT | Описание | +| `inventory_number` | VARCHAR(50) | Инвентарный номер | + +#### `classroom_equipments` — Привязка оборудования к аудиториям +| Колонка | Тип | Описание | +|---------|-----|----------| +| `classroom_id` | BIGINT PK, FK → classrooms (CASCADE) | Аудитория | +| `equipment_id` | BIGINT PK, FK → equipments (CASCADE) | Оборудование | +| `quantity` | INT CHECK(> 0) | Количество единиц | +| `notes` | TEXT | Примечания | + +### Расписание + +#### `lessons` — Основное расписание занятий +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `group_id` | BIGINT FK → student_groups | Группа | +| `subject_id` | BIGINT FK → subjects | Дисциплина | +| `lesson_format` | VARCHAR(255) | `Очно` / `Онлайн` | +| `type_lesson` | VARCHAR(255) | `Лекция` / `Практическая работа` / `Лабораторная работа` | +| `classroom_id` | BIGINT FK → classrooms | Аудитория | +| `day` | VARCHAR(255) | День недели | +| `week` | VARCHAR(255) | `Верхняя` / `Нижняя` / `Обе` | +| `time` | VARCHAR(255) | Временной слот | + +#### `lesson_types` — Типы занятий (справочник) +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `name` | VARCHAR(50) UNIQUE | Название типа | +| `color_code` | VARCHAR(7) | HEX-цвет для UI (напр. `#FF6B6B`) | +| `duration_minutes` | INT | Длительность (по умолчанию 90) | + +### Связи «Преподаватель ↔ Дисциплина» + +#### `teacher_subjects` — Квалификация преподавателей +| Колонка | Тип | Описание | +|---------|-----|----------| +| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель | +| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | +| `qualification_level` | VARCHAR(50) | Уровень квалификации | +| `experience_years` | INT | Стаж | + +#### `teacher_lesson_types` — Типы занятий преподавателя +| Колонка | Тип | Описание | +|---------|-----|----------| +| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель | +| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | +| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | + +#### `schedule_data` — Данные к составлению расписания +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `department_id` | BIGINT FK → departments | Кафедра | +| `semester` | INT | Номер семестра | +| `group_id` | BIGINT FK → student_groups | Группа | +| `subjects_id` | BIGINT FK → subjects | Дисциплина | +| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | +| `number_of_hours` | INT | Количество часов | +| `is_division` | BOOLEAN | Деление на подгруппы | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `semester_type` | VARCHAR(255) | Весенний / Осенний | +| `period` | VARCHAR(255) | Учебный год | + +--- + +## Flyway миграции + +### Правила работы + +1. Все миграции находятся в `backend/src/main/resources/db/migration/` +2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) +3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway +4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) +5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline + +### Текущие миграции + +| Файл | Описание | +|------|----------| +| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | + +### Накатывание на существующих тенантов + +Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend: + +```bash +# Kubernetes +kubectl rollout restart deployment backend -n magistr + +# Docker Compose (локально) +docker compose restart backend +``` + +### Полный сброс БД (локально) + +```bash +docker compose down -v # Удаляет volumes (данные) +docker compose up -d # Пересоздаёт БД с нуля +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..a0ba3a2 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,275 @@ +# 🛠 Руководство для разработчиков + +## Локальный запуск + +### Предварительные требования + +- Docker и Docker Compose +- Git +- (Опционально) Java 17 + Maven 3.9+ для запуска backend вне Docker + +### Первый запуск + +```bash +# Создать Docker-сеть +docker network create proxy + +# Собрать и запустить +docker compose up -d --build + +# Убедиться, что всё работает +docker compose logs -f +``` + +Приложение доступно: **http://localhost:80** + +### Пересборка после изменений + +```bash +# Пересобрать только backend +docker compose up -d --build backend + +# Пересобрать только frontend +docker compose up -d --build frontend +``` + +### Полный сброс данных + +```bash +docker compose down -v # Удаляет БД +docker compose up -d # Пересоздаёт с нуля +``` + +--- + +## Соглашения о коде + +### Java (Backend) + +#### Именование + +| Категория | Стиль | Пример | +|-----------|-------|--------| +| Классы | PascalCase | `LessonsController`, `LessonResponse` | +| Методы и переменные | camelCase | `getAllLessons()`, `teacherId` | +| Константы | UPPER_SNAKE_CASE | `ROLE_REDIRECTS` | +| Пакеты | lowercase | `com.magistr.app.controller` | + +#### Архитектурные правила + +- **Constructor Injection** — все зависимости через конструктор (не `@Autowired` на поля) +- **Controller → Repository** — контроллеры работают напрямую с репозиториями (без слоя service) +- **Префикс `/api/`** — все REST-эндпоинты +- **`ResponseEntity`** — все мутирующие методы возвращают `ResponseEntity` с HTTP-статусом +- **Сообщения на русском** — все ошибки и уведомления на русском языке + +#### Логирование + +Используйте SLF4J: + +```java +private static final Logger logger = LoggerFactory.getLogger(MyController.class); + +// Информационные сообщения +logger.info("Запрос на получение всех занятий"); + +// Ошибки с полным стектрейсом +logger.error("Ошибка при сохранении: {}", e.getMessage(), e); +``` + +#### Валидация + +- Для сложных правил — отдельные классы-валидаторы (`DayAndWeekValidator`, `TypeAndFormatLessonValidator`) +- Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()` + +#### Импорты + +```java +// 1. Static imports +import static org.junit.Assert.*; + +// 2. Java/Jakarta +import java.util.*; +import jakarta.persistence.*; + +// 3. External libraries +import org.springframework.web.bind.annotation.*; +import com.fasterxml.jackson.databind.ObjectMapper; + +// 4. Internal packages (wildcard для того же модуля) +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +``` + +#### Форматирование + +- **Отступы:** 4 пробела +- **Скобки:** K&R style (открывающая на той же строке) +- **Длина строки:** до 120 символов +- **Фигурные скобки** обязательны для `if`/`for`/`while` + +### JavaScript (Frontend) + +#### Именование + +| Категория | Стиль | Пример | +|-----------|-------|--------| +| Файлы | kebab-case | `main.js`, `schedule-view.js` | +| Функции и переменные | camelCase | `loadUsers()`, `pageTitle` | +| Константы | UPPER_SNAKE_CASE | `API_BASE_URL` | + +#### Модули + +- ES6 Modules с `import`/`export` +- **Всегда указывать расширение:** `import { api } from './api.js';` + +#### Лучшие практики + +```javascript +// ✅ Предпочитайте const +const token = localStorage.getItem('token'); + +// ✅ Async/await вместо .then() +async function loadData() { + try { + const data = await api.get('/api/users'); + } catch (e) { + console.error('Ошибка:', e.message); + } +} + +// ✅ Template literals +const msg = `Найдено ${items.length} записей`; + +// ✅ Деструктуризация +const { id, name, role } = user; +``` + +#### Форматирование + +- **Отступы:** 4 пробела +- **Кавычки:** одинарные `'` +- **Точки с запятой:** обязательны + +--- + +## Создание нового эндпоинта (пошагово) + +### 1. Модель (если нужна новая таблица) + +Создайте Flyway миграцию `V{N}__{description}.sql`: + +```sql +-- backend/src/main/resources/db/migration/V3__add_absences.sql +CREATE TABLE IF NOT EXISTS absences ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + reason VARCHAR(255) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL +); +``` + +Создайте JPA-сущность: + +```java +@Entity +@Table(name = "absences") +public class Absence { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // ... +} +``` + +### 2. Репозиторий + +```java +public interface AbsenceRepository extends JpaRepository { + List findByTeacherId(Long teacherId); +} +``` + +### 3. DTO (опционально) + +```java +public record AbsenceResponse(Long id, String teacherName, String reason) {} +``` + +### 4. Контроллер + +```java +@RestController +@RequestMapping("/api/absences") +public class AbsenceController { + private final AbsenceRepository absenceRepository; + + public AbsenceController(AbsenceRepository absenceRepository) { + this.absenceRepository = absenceRepository; + } + + @GetMapping + public List getAll() { + return absenceRepository.findAll(); + } +} +``` + +--- + +## Работа с миграциями Flyway + +### Правила + +1. **Никогда** не изменяйте уже закоммиченные файлы миграций +2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) +3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... +4. После добавления — перезапустите backend для применения + +### Применение + +```bash +# Локально — сброс и повтор всех миграций +docker compose down -v && docker compose up -d + +# Продакшн — применить к существующим тенантам +kubectl rollout restart deployment backend -n magistr +``` + +--- + +## Структура пакетов (Backend) + +``` +com.magistr.app/ +├── Application.java # Точка входа +├── config/ +│ ├── AppConfig.java # Бины (BCryptPasswordEncoder) +│ ├── DataInitializer.java # Инициализация данных +│ └── tenant/ # Мультитенантность +│ ├── TenantConfig.java # POJO конфигурации тенанта +│ ├── TenantContext.java # ThreadLocal текущего тенанта +│ ├── TenantInterceptor.java # Определение тенанта из Host +│ ├── TenantRoutingDataSource.java # Маршрутизация к БД +│ ├── TenantDataSourceConfig.java # Spring-конфигурация +│ ├── TenantConfigWatcher.java # Периодическая синхронизация +│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap +├── controller/ # REST-контроллеры +│ ├── AuthController.java +│ ├── LessonsController.java +│ ├── ClassroomController.java +│ ├── DatabaseController.java +│ ├── UserController.java +│ ├── GroupController.java +│ ├── SubjectController.java +│ ├── EquipmentController.java +│ ├── EducationFormController.java +│ └── TeacherSubjectController.java +├── dto/ # Data Transfer Objects +├── model/ # JPA-сущности +├── repository/ # Spring Data JPA +└── utils/ # Валидаторы + ├── DayAndWeekValidator.java + └── TypeAndFormatLessonValidator.java +``` diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 0000000..c571a55 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,203 @@ +# 🎨 Frontend + +## Общая информация + +| Параметр | Значение | +|----------|----------| +| **Фреймворк** | Нет (Vanilla JavaScript) | +| **Модульная система** | ES6 Modules (`import`/`export`) | +| **Стили** | CSS (модульный подход) | +| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) | +| **Веб-сервер** | Apache httpd:alpine | + +--- + +## Структура файлов + +``` +frontend/ +├── index.html # 🔐 Страница авторизации (общая) +├── script.js # Логика авторизации +├── style.css # Стили страницы авторизации +├── theme-toggle.js # Переключение светлой/тёмной темы +├── Dockerfile # httpd:alpine +│ +├── admin/ # 👨‍💼 Интерфейс администратора +│ ├── index.html # SPA-оболочка с sidebar +│ ├── css/ +│ │ ├── main.css # CSS-переменные, цвета, типографика +│ │ ├── layout.css # Раскладка (sidebar, topbar, content) +│ │ ├── components.css # Кнопки, таблицы, карточки, формы +│ │ └── modals.css # Модальные окна +│ ├── js/ +│ │ ├── main.js # Инициализация, маршрутизация, навигация +│ │ ├── api.js # HTTP-обёртка (fetch + Authorization) +│ │ ├── utils.js # Утилиты +│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия) +│ │ └── views/ # Модули представлений +│ │ ├── users.js # Управление пользователями +│ │ ├── groups.js # Управление группами +│ │ ├── classrooms.js # Управление аудиториями +│ │ ├── subjects.js # Управление дисциплинами +│ │ ├── equipments.js # Управление оборудованием +│ │ ├── edu-forms.js # Формы обучения +│ │ ├── schedule.js # Расписание занятий +│ │ └── database.js # Управление тенантами +│ └── views/ # HTML-шаблоны представлений +│ ├── users.html +│ ├── groups.html +│ ├── classrooms.html +│ ├── subjects.html +│ ├── equipments.html +│ ├── edu-forms.html +│ ├── schedule.html +│ └── database.html +│ +├── teacher/ # 👩‍🏫 Интерфейс преподавателя +│ └── index.html # Просмотр расписания +│ +└── student/ # 🎓 Интерфейс студента + └── index.html # Просмотр расписания (read-only) +``` + +--- + +## Система маршрутизации (Admin SPA) + +Админ-панель работает как **Single Page Application** без фреймворка. + +Навигация реализована через `data-tab` атрибуты на элементах sidebar: + +```html +Пользователи +Группы +Расписание занятий +``` + +При клике на пункт меню `main.js`: +1. Загружает HTML-шаблон из `views/{tab}.html` через `fetch()` +2. Вставляет его в `#app-content` +3. Подключает соответствующий JS-модуль из `js/views/{tab}.js` +4. Обновляет заголовок страницы (`#page-title`) + +### Разделы админ-панели + +| Tab | Описание | API | +|-----|----------|-----| +| `users` | CRUD пользователей | `/api/users` | +| `groups` | CRUD групп | `/api/groups` | +| `edu-forms` | Формы обучения | `/api/education-forms` | +| `equipments` | Оборудование | `/api/equipments` | +| `classrooms` | Аудитории | `/api/classrooms` | +| `subjects` | Дисциплины | `/api/subjects` | +| `schedule` | Расписание | `/api/users/lessons` | +| `database` | Тенанты | `/api/database` | + +--- + +## API-клиент (`api.js`) + +Все HTTP-запросы проходят через обёртку `apiFetch()`: + +```javascript +export async function apiFetch(endpoint, method = 'GET', body = null) { + const response = await fetch(endpoint, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: body ? JSON.stringify(body) : null + }); + + if (!response.ok) { + throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); + } + + return await response.json(); +} + +// Shortcut-методы +export const api = { + get: (url) => apiFetch(url, 'GET'), + post: (url, body) => apiFetch(url, 'POST', body), + put: (url, body) => apiFetch(url, 'PUT', body), + delete: (url, body) => apiFetch(url, 'DELETE', body) +}; +``` + +Токен берётся из `localStorage.getItem('token')`. + +--- + +## Аутентификация (Frontend) + +### Страница входа (`/index.html`) + +1. Пользователь вводит логин/пароль +2. `script.js` отправляет `POST /api/auth/login` +3. При успехе сохраняет в `localStorage`: + - `token` — UUID-токен + - `role` — роль пользователя +4. Перенаправляет на соответствующий интерфейс: + - `ADMIN` → `/admin/` + - `TEACHER` → `/teacher/` + - `STUDENT` → `/student/` + +### Проверка авторизации + +На каждой странице проверяется наличие токена и роли: + +```javascript +export function isAuthenticatedAsAdmin() { + const role = localStorage.getItem('role'); + return token && role === 'ADMIN'; +} +``` + +### Выход + +Кнопка «Выйти» очищает `localStorage` и перенаправляет на `/`. + +--- + +## CSS-архитектура + +### Модульный подход + +Стили разделены на 4 файла (порядок подключения важен): + +1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема +2. **`layout.css`** — Sidebar, topbar, content area, responsive +3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы +4. **`modals.css`** — Модальные окна + +### Темизация + +CSS-переменные позволяют поддерживать светлую/тёмную тему: + +```css +:root { + --bg-primary: #ffffff; + --text-primary: #1a1a2e; + --accent: #6366f1; +} + +[data-theme="dark"] { + --bg-primary: #0f0f23; + --text-primary: #e2e8f0; + --accent: #818cf8; +} +``` + +Переключение — через `theme-toggle.js`. + +--- + +## Адаптивность + +Интерфейс адаптирован под мобильные устройства: +- Sidebar скрывается на экранах < 768px +- Появляется кнопка-гамбургер (`#menu-toggle`) +- Sidebar выезжает как overlay +- Таблицы получают горизонтальный скролл diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md new file mode 100644 index 0000000..6c80225 --- /dev/null +++ b/docs/INFRASTRUCTURE.md @@ -0,0 +1,137 @@ +# 🏭 Инфраструктура + +## Docker Compose (локальная разработка) + +### Сервисы + +```yaml +services: + backend: # Spring Boot (Java 17), порт 8080 + frontend: # Apache httpd:alpine, порт 80 + db: # PostgreSQL alpine3.23, порт 5432 +``` + +### Сеть + +Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском: + +```bash +docker network create proxy +``` + +### Переменные окружения + +Файл `.env` в корне проекта: + +```env +POSTGRES_USER=myuser +POSTGRES_PASSWORD=supersecretpassword +``` + +### Dockerfile (Backend) + +Backend собирается через multi-stage сборку Maven: +1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package` +2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar` + +### Dockerfile (Frontend) + +```dockerfile +FROM httpd:alpine +COPY . /usr/local/apache2/htdocs/ +RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ +``` + +--- + +## Kubernetes (продакшн) + +### Расположение конфигурации + +Файлы Kubernetes манифестов: `../k8s/` + +### Ключевые ресурсы + +| Ресурс | Тип | Описание | +|--------|-----|----------| +| `backend` | Deployment | Spring Boot приложение | +| `frontend` | Deployment | Apache httpd | +| `tenants-config` | ConfigMap | JSON-список тенантов | + +### ConfigMap для тенантов + +ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`. + +При добавлении тенанта через API: +1. `DatabaseController` обновляет in-memory DataSource +2. `ConfigMapUpdater` обновляет ConfigMap через Kubernetes API +3. `TenantConfigWatcher` на остальных подах подхватывает изменения (каждые 30 сек) + +### Обновление backend + +```bash +kubectl rollout restart deployment backend -n magistr +``` + +--- + +## Caddy (реверс-прокси) + +**Расположение:** `../caddy-proxy/` для локальной разработки, в продакшене - отдельный сервис + +В продакшене Caddy обрабатывает входящий трафик для `*.zuev.company`: +- Автоматическое получение TLS-сертификатов (Let's Encrypt) +- Маршрутизация `/api/*` → backend:8080 +- Маршрутизация статики → frontend:80 + +--- + +## CI/CD (Gitea Actions) + +### Пайплайн сборки Docker-образов + +Расположение: `.gitea/workflows/docker-build.yaml` + +Основные шаги: +1. Checkout кода +2. Login в Docker Registry +3. Build + Push образов (`backend`, `frontend`) +4. Генерация меток через `docker/metadata-action` + +--- + +## Мониторинг (SigNoz + OpenTelemetry) + +### Архитектура мониторинга + +```mermaid +graph LR + Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"] + Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector + Collector --> SigNoz["SigNoz"] + + Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"] +``` + +### Интеграция Backend + +Backend отправляет через OpenTelemetry: +- **Логи** — через Logback + OTLP exporter +- **Трейсы** — автоинструментация Spring Boot +- **Метрики** — JVM метрики, HTTP метрики + +Tenant ID добавляется в: +- MDC (логи): `MDC.put("tenant.id", tenant)` +- Span атрибуты: `Span.current().setAttribute("tenant.id", tenant)` + +### Интеграция Frontend + +Файл `admin/js/otel.js` — клиентская телеметрия: +- Метрики производительности страниц +- Трейсы пользовательских действий + +### Дашборды SigNoz + +- JVM Dashboard (Heap, GC, Threads) +- PostgreSQL Dashboard (Connections, Queries) +- HTTP Dashboard (Requests, Latency, Errors) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3775cc5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,113 @@ +# 📚 Magistr — Система управления университетским расписанием + +## Обзор + +**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. + +- **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company) +- **Локальная разработка:** [http://localhost:80](http://localhost:80) + +--- + +## Стек технологий + +| Компонент | Технология | +|-----------|-----------| +| **Backend** | Java 17, Spring Boot 3.2.5 | +| **Frontend** | Vanilla JavaScript (ES6 Modules) + HTML/CSS | +| **База данных** | PostgreSQL (через Flyway миграции) | +| **Контейнеризация** | Docker, Docker Compose | +| **Продакшн** | Kubernetes, Caddy (реверс-прокси) | +| **Мониторинг** | SigNoz, OpenTelemetry | +| **CI/CD** | Gitea Actions | + +--- + +## Быстрый старт + +### Предварительные требования + +- Docker и Docker Compose +- Git + +### Локальный запуск + +```bash +# 1. Клонировать репозиторий +git clone magistr && cd magistr + +# 2. Создать Docker-сеть (если ещё не создана) +docker network create proxy + +# 3. Запустить все сервисы +docker compose up -d --build +``` + +После запуска приложение доступно по адресу: **http://localhost:80** + +**Учётные данные по умолчанию:** + +| Логин | Пароль | Роль | +|-------|--------|------| +| `admin` | `admin` | Администратор | +| `Тестовый преподаватель` | `1234567890` | Преподаватель | + +### Полезные команды + +```bash +# Просмотр логов +docker compose logs -f backend + +# Полный сброс базы данных (удаление данных + повтор миграций) +docker compose down -v +docker compose up -d + +# Остановка всех сервисов +docker compose down +``` + +--- + +## Структура проекта + +``` +magistr/ +├── backend/ # Java Spring Boot backend +│ └── src/main/ +│ ├── java/com/magistr/app/ +│ │ ├── controller/ # REST-контроллеры (10 шт.) +│ │ ├── model/ # JPA-сущности +│ │ ├── dto/ # Data Transfer Objects +│ │ ├── repository/ # Spring Data JPA репозитории +│ │ ├── config/ # Конфигурация приложения +│ │ │ └── tenant/ # Мультитенантность +│ │ └── utils/ # Валидаторы +│ └── resources/ +│ ├── application.properties +│ └── db/migration/ # Flyway SQL миграции +├── frontend/ # Статический фронтенд +│ ├── index.html # Страница авторизации +│ ├── admin/ # Админ-панель (деканат) +│ │ ├── js/views/ # Модули представлений +│ │ └── css/ # Стили +│ ├── teacher/ # Интерфейс преподавателя +│ └── student/ # Интерфейс студента +├── docs/ # 📖 Документация (вы здесь) +├── compose.yaml # Docker Compose конфигурация +├── .env # Переменные окружения +└── AGENTS.md # Руководство для AI-агентов +``` + +--- + +## 📖 Навигация по документации + +| Документ | Содержание | +|----------|-----------| +| [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов | +| [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами | +| [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции | +| [REST API](API.md) | Все эндпоинты с примерами запросов и ответов | +| [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг | +| [Разработка](DEVELOPMENT.md) | Code Style, соглашения, инструкции для разработчиков | +| [Frontend](FRONTEND.md) | Архитектура фронтенда, модули, стили | From fcd7baac71dda34dec670e701587f78142147839 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 22 Mar 2026 15:22:10 +0300 Subject: [PATCH 3/8] feat: Add AutoUpdateDocs agent skill and new logging documentation, updating AGENTS.md. --- .agents/skills/AutoUpdateDocs.md | 85 ++++++++++++++++ AGENTS.md | 1 + docs/LOGGING.md | 167 +++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 .agents/skills/AutoUpdateDocs.md create mode 100644 docs/LOGGING.md 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/AGENTS.md b/AGENTS.md index 033f2d5..a6da314 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,3 +84,4 @@ docker compose logs -f backend | [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг (SigNoz) | | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта | | [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность | +| [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz | diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 0000000..41ad688 --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,167 @@ +# 📋 Логирование + +## Стек технологий + +| Компонент | Технология | +|-----------|------------| +| Фасад | SLF4J (`org.slf4j.Logger`) | +| Реализация | Logback (поставляется с `spring-boot-starter-web`) | +| Конфигурация | Стандартная Spring Boot (без кастомного `logback.xml`) | +| Экспорт (прод) | OpenTelemetry Java Agent → OTLP → SigNoz | +| Контекст тенанта | SLF4J MDC (`tenant.id`) | + +--- + +## Архитектура + +```mermaid +graph LR + Code["Java-код
log.info(...)"] --> SLF4J["SLF4J API"] + SLF4J --> Logback["Logback"] + Logback -->|"Локальная разработка"| Console["stdout / stderr"] + Logback -->|"Продакшн"| OTelAgent["OTel Java Agent
(Logback Appender)"] + OTelAgent -->|"OTLP HTTP"| SigNoz["SigNoz"] +``` + +### Локальная разработка + +Логи выводятся в `stdout` контейнера в стандартном формате Spring Boot: + +``` +2026-03-22 12:00:00.123 INFO 1 --- [main] c.m.app.config.DataInitializer : Initializing databases for 1 tenant(s)... +``` + +Просмотр логов: + +```bash +docker compose logs -f backend +``` + +### Продакшн (Kubernetes) + +OpenTelemetry Java Agent подключается как `-javaagent` в [Dockerfile](file:///mnt/HDD/magistr/magistr/backend/Dockerfile) и автоматически перехватывает логи Logback, экспортируя их в SigNoz по OTLP. + +```dockerfile +ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"] +``` + +Конфигурация агента задаётся через переменные окружения в [backend.yaml](file:///mnt/HDD/magistr/k8s/backend.yaml): + +| Переменная | Значение | Назначение | +|------------|----------|------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://192.168.1.100:4318` | Адрес SigNoz Collector | +| `OTEL_SERVICE_NAME` | `magistr-backend` | Имя сервиса в SigNoz | +| `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=default` | Окружение | +| `OTEL_LOGS_EXPORTER` | `otlp` | Экспорт логов через OTLP | +| `OTEL_METRICS_EXPORTER` | `otlp` | Экспорт метрик через OTLP | +| `OTEL_TRACES_EXPORTER` | `otlp` | Экспорт трейсов через OTLP | +| `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES` | `tenant.id` | Захват MDC-атрибута в логи | + +> [!NOTE] +> В локальной разработке OpenTelemetry Agent также встроен в Docker-образ, но без переменных `OTEL_*` он работает в режиме noop — логи идут только в stdout. + +--- + +## Мультитенантный контекст (MDC) + +Каждый HTTP-запрос обогащается tenant ID через [TenantInterceptor](file:///mnt/HDD/magistr/magistr/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java): + +```java +// preHandle — при входе запроса +MDC.put("tenant.id", tenant); +Span.current().setAttribute("tenant.id", tenant); + +// afterCompletion — после завершения +MDC.remove("tenant.id"); +``` + +Это позволяет: +- Фильтровать логи по тенанту в SigNoz +- Коррелировать логи с трейсами через Span-атрибуты +- Идентифицировать, к какому университету относится каждая запись + +--- + +## Использование в коде + +### Классы с логированием + +| Класс | Уровни | Что логируется | +|-------|--------|----------------| +| `TenantInterceptor` | DEBUG, WARN | Резолвинг тенанта, неизвестный тенант (404) | +| `TenantDataSourceConfig` | INFO, WARN, ERROR | Загрузка тенантов, fallback на H2 | +| `TenantRoutingDataSource` | INFO, WARN | Добавление/удаление тенантов, тест соединения | +| `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции | +| `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s | +| `DataInitializer` | INFO | Инициализация БД при старте | +| `LessonsController` | INFO, DEBUG, ERROR | CRUD-операции с занятиями, валидация | + +### Паттерн использования + +```java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MyClass { + private static final Logger log = LoggerFactory.getLogger(MyClass.class); + + public void doSomething() { + log.info("Операция выполнена: param={}", value); + log.error("Ошибка: {}", e.getMessage(), e); // со стектрейсом + } +} +``` + +### Рекомендации по уровням + +| Уровень | Когда использовать | +|---------|-------------------| +| `ERROR` | Необработанные ошибки, сбои подключения к БД, провалы миграций | +| `WARN` | Неизвестный тенант, нет конфигурации, fallback-сценарии | +| `INFO` | Успешные операции, CRUD-действия, старт/стоп компонентов | +| `DEBUG` | Детали резолвинга тенанта, ping-запросы | + +--- + +## Настройка уровня логирования + +В [application.properties](file:///mnt/HDD/magistr/magistr/backend/src/main/resources/application.properties) (по умолчанию закомментировано): + +```properties +# Включить DEBUG для всего приложения +#logging.level.root=DEBUG + +# Только для пакета приложения +logging.level.com.magistr.app=DEBUG + +# Только для конкретного класса +logging.level.com.magistr.app.config.tenant.TenantInterceptor=DEBUG +``` + +Также можно задавать через переменные окружения: + +```bash +LOGGING_LEVEL_ROOT=DEBUG +LOGGING_LEVEL_COM_MAGISTR_APP=DEBUG +``` + +--- + +## Просмотр логов + +### Локально (Docker Compose) + +```bash +# Все логи backend +docker compose logs -f backend + +# Фильтрация по ключевому слову +docker compose logs -f backend | grep "tenant" +``` + +### Продакшн (SigNoz) + +Логи доступны в веб-интерфейсе SigNoz → раздел **Logs**: +- Фильтрация по `service.name = magistr-backend` +- Фильтрация по `tenant.id` (из MDC) +- Корреляция с трейсами через общий `trace_id` From e03a68b7a85ec905763de18ede99f18ea068cb6b Mon Sep 17 00:00:00 2001 From: Zuev Date: Mon, 23 Mar 2026 02:05:02 +0300 Subject: [PATCH 4/8] feat: add frontend-design skill with its documentation and license, and update gitignore. --- .agents/skills/frontend-design/LICENSE.txt | 177 +++++++++++++++++++++ .agents/skills/frontend-design/SKILL.md | 42 +++++ .gitignore | 3 +- 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/frontend-design/LICENSE.txt create mode 100644 .agents/skills/frontend-design/SKILL.md diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..78d2f00 --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,42 @@ +--- +name: frontend-design +description: Создание выразительных, готовых к продакшену frontend-интерфейсов с высоким качеством дизайна. Используйте этот навык, когда пользователь просит разработать веб-компоненты, страницы, артефакты, постеры или приложения (например, сайты, лендинги, дашборды, React-компоненты, HTML/CSS верстку или когда нужно стилизовать/улучшить любой веб-интерфейс). Генерирует креативный, отточенный код и UI-дизайн, избегая шаблонной эстетики ИИ. +license: Полные условия в LICENSE.txt +--- + +Этот навык направляет создание выразительных, готовых к продакшену frontend-интерфейсов, которые избегают шаблонной "ИИ-эстетики". Создавайте реально работающий код с исключительным вниманием к эстетическим деталям и творческим решениям. + +Пользователь предоставляет требования к фронтенду: компонент, страницу, приложение или интерфейс для разработки. Требования могут включать контекст о цели, аудитории или технических ограничениях. + +## Дизайн-мышление + +Перед написанием кода поймите контекст и примите СМЕЛОЕ эстетическое направление: +- **Цель**: Какую проблему решает этот интерфейс? Кто им пользуется? +- **Тон**: Выберите крайность: брутальный минимализм, максималистский хаос, ретро-футуризм, органический/природный, люксовый/утонченный, игривый/игрушечный, редакционный/журнальный, брутализм/грубый, арт-деко/геометрический, мягкий/пастельный, индустриальный/утилитарный и т.д. Вариантов очень много. Используйте их для вдохновения, но создайте дизайн, верный выбранному эстетическому направлению. +- **Ограничения**: Технические требования (фреймворк, производительность, доступность). +- **Отличительная черта**: Что делает это НЕЗАБЫВАЕМЫМ? Какую единственную вещь кто-то запомнит? + +**КРИТИЧЕСКИ ВАЖНО**: Выберите четкое концептуальное направление и выполните его с точностью. Смелый максимализм и утонченный минимализм — оба работают, ключ кроется в осознанности намерений, а не в интенсивности. + +Затем реализуйте рабочий код (HTML/CSS/JS, React, Vue и т.д.), который: +- Готов к продакшену и функционален +- Визуально поразителен и легко запоминается +- Согласован с четкой эстетической точкой зрения +- Тщательно проработан в каждой детали + +## Руководство по эстетике фронтенда + +Сфокусируйтесь на: +- **Типографика**: Выбирайте шрифты, которые красивы, уникальны и интересны. Избегайте общих шрифтов, таких как Arial и Inter; вместо этого делайте выбор в пользу выразительных, неожиданных и характерных вариантов, которые повышают уровень эстетики фронтенда. Сочетайте акцидентный шрифт (display) с утонченным текстовым (body). +- **Цвет и тема**: Придерживайтесь согласованной эстетики. Используйте CSS-переменные для консистентности. Доминирующие цвета с резкими акцентами работают намного лучше, чем робкие, равномерно распределенные палитры. +- **Анимация (Motion)**: Используйте анимации для эффектов и микро-взаимодействий. Отдавайте предпочтение CSS-решениям для HTML. Используйте библиотеки анимаций для React, если они доступны. Фокусируйтесь на моментах с высоким влиянием: одна хорошо срежиссированная загрузка страницы с каскадным появлением элементов (animation-delay) создает больше восторга, чем множество разрозненных микро-взаимодействий. Используйте триггеры при скролле (scroll-triggering) и состояния наведения (hover), которые удивляют. +- **Пространственная композиция**: Неожиданные макеты. Асимметрия. Перекрытие. Диагональное направление. Элементы, ломающие сетку. Обильное негативное пространство ИЛИ контролируемая плотность элементов. +- **Фоны и визуальные детали**: Создавайте атмосферу и глубину вместо использования скучных сплошных цветов по умолчанию. Добавляйте контекстуальные эффекты и текстуры, соответствующие общей эстетике. Применяйте творческие формы: градиентные сетки, шумовые текстуры, геометрические паттерны, слоистые прозрачности, драматичные тени, декоративные рамки, кастомные курсоры и эффекты зернистости (grain). + +НИКОГДА не используйте шаблонную сгенерированную ИИ эстетику: заезженные семейства шрифтов (Inter, Roboto, Arial, системные шрифты), клишированные цветовые схемы (особенно фиолетовые градиенты на белом фоне), предсказуемые макеты и паттерны компонентов, а также типовой скучный дизайн без характера, не учитывающий контекст. + +Интерпретируйте творчески и делайте неожиданные выборы, которые кажутся действительно разработанными под данный контекст. Ни один дизайн не должен быть шаблонным ("под копирку"). Варьируйте между светлыми и темными темами, разными шрифтами, различной эстетикой. НИКОГДА не сходитесь к общим выборам (например, Space Grotesk) в разных генерациях кода. + +**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения. + +Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению. diff --git a/.gitignore b/.gitignore index 98c3a42..9647f9d 100755 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ frontend/dist/ .idea/ .vscode/ -*.DS_Store \ No newline at end of file +*.DS_Store +skills-lock.json \ No newline at end of file From 87cd4cbdc7f3f4e5f9cbf5e759a50f0d0096731e Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 27 May 2026 15:19:18 +0300 Subject: [PATCH 5/8] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 2 +- AGENTS.md | 2 +- backend/pom.xml | 13 ++ .../app/config/auth/AuthSessionService.java | 33 ---- .../config/auth/AuthorizationInterceptor.java | 23 ++- .../app/config/auth/JwtProperties.java | 66 ++++++++ .../app/config/auth/JwtTokenService.java | 110 +++++++++++++ .../app/config/auth/RefreshTokenRotation.java | 6 + .../app/config/auth/RefreshTokenService.java | 132 ++++++++++++++++ .../app/controller/AuthController.java | 144 ++++++++++++++++-- .../magistr/app/model/AuthRefreshToken.java | 134 ++++++++++++++++ .../AuthRefreshTokenRepository.java | 11 ++ .../src/main/resources/application.properties | 8 +- .../main/resources/db/migration/V1__init.sql | 30 ++++ .../app/config/auth/JwtTokenServiceTest.java | 104 +++++++++++++ .../config/auth/RefreshTokenServiceTest.java | 115 ++++++++++++++ docs/API.md | 39 ++++- docs/ARCHITECTURE.md | 19 ++- docs/DATABASE.md | 34 ++++- docs/DEVELOPMENT.md | 4 +- docs/FRONTEND.md | 30 ++-- docs/INFRASTRUCTURE.md | 15 ++ frontend/admin/js/api.js | 85 +++++++++-- frontend/admin/js/main.js | 7 +- frontend/admin/settings/js/main.js | 5 +- frontend/script.js | 29 ++-- frontend/student/index.html | 76 ++++++++- frontend/teacher/index.html | 76 ++++++++- 28 files changed, 1227 insertions(+), 125 deletions(-) delete mode 100644 backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java create mode 100644 backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java 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/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/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 5e8abd8..0b5096d 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,24 @@ 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.Map; import java.util.Optional; @@ -19,7 +28,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 +43,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 +70,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") @@ -76,4 +131,71 @@ public class AuthController { "departmentId", user.departmentId() )); } + + 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/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/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/resources/application.properties b/backend/src/main/resources/application.properties index 1503138..a1d4852 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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/docs/API.md b/docs/API.md index 85bd686..a7088c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -23,7 +23,7 @@ { "success": true, "message": "OK", - "token": "550e8400-e29b-41d4-a716-446655440000", + "token": "eyJhbGciOiJIUzI1NiJ9...", "role": "ADMIN", "redirect": "/admin/", "departmentId": 1, @@ -31,6 +31,8 @@ } ``` +Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена. + **Ошибка (401):** ```json { @@ -44,7 +46,7 @@ } ``` -> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` +> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer `. Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. @@ -59,6 +61,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-токену. @@ -151,7 +184,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`. Важные ограничения: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c5a700..13d7ecb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -131,16 +131,15 @@ sequenceDiagram ## Аутентификация -Система использует простую модель аутентификации без 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/DATABASE.md b/docs/DATABASE.md index 9e3b82d..240625c 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` — Формы обучения @@ -674,17 +702,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..8e7be9d 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 diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a8aa0f1..7eff0c3 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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..ad899bd 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -26,8 +26,13 @@ docker network create proxy ```env POSTGRES_USER=myuser POSTGRES_PASSWORD=supersecretpassword +JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes +JWT_ACCESS_TOKEN_TTL=15m +JWT_REFRESH_TOKEN_TTL=7d ``` +`JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене он задаётся через Kubernetes Secret `app-secret`. + ### Dockerfile (Backend) Backend собирается через multi-stage сборку Maven: @@ -58,6 +63,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/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/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 @@