Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule
This commit is contained in:
89
docs/API.md
89
docs/API.md
@@ -2,6 +2,20 @@
|
||||
|
||||
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
||||
|
||||
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-05-27T19:47:54",
|
||||
"status": 400,
|
||||
"error": "Некорректный запрос",
|
||||
"message": "Некорректные параметры запроса",
|
||||
"path": "/api/schedule"
|
||||
}
|
||||
```
|
||||
|
||||
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
@@ -23,7 +37,7 @@
|
||||
{
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"token": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||
"role": "ADMIN",
|
||||
"redirect": "/admin/",
|
||||
"departmentId": 1,
|
||||
@@ -31,6 +45,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена.
|
||||
|
||||
**Ошибка (401):**
|
||||
```json
|
||||
{
|
||||
@@ -44,7 +60,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
|
||||
> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
|
||||
|
||||
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
|
||||
|
||||
@@ -59,6 +75,37 @@ Redirect по ролям:
|
||||
| `TEACHER` | `/teacher/` |
|
||||
| `STUDENT` | `/student/` |
|
||||
|
||||
### `POST /api/auth/refresh`
|
||||
|
||||
Обновляет access JWT по refresh-cookie. Тело запроса не требуется.
|
||||
|
||||
**Успешный ответ (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||
"role": "ADMIN",
|
||||
"redirect": "/admin/",
|
||||
"departmentId": 1,
|
||||
"userId": 1
|
||||
}
|
||||
```
|
||||
|
||||
Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается.
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Отзывает текущий refresh-токен и очищает refresh-cookie.
|
||||
|
||||
**Успешный ответ (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Выход выполнен"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Возвращает текущего пользователя по bearer-токену.
|
||||
@@ -72,6 +119,8 @@ Redirect по ролям:
|
||||
}
|
||||
```
|
||||
|
||||
`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
|
||||
|
||||
---
|
||||
|
||||
## Пользователи
|
||||
@@ -83,18 +132,20 @@ Redirect по ролям:
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
|
||||
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
|
||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
|
||||
{ "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
|
||||
]
|
||||
```
|
||||
|
||||
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
||||
|
||||
### `GET /api/users/teachers`
|
||||
|
||||
Список только преподавателей (роль `TEACHER`).
|
||||
|
||||
### `GET /api/users/teachers/{departmentId}`
|
||||
|
||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
|
||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||||
|
||||
### `POST /api/users`
|
||||
|
||||
@@ -151,7 +202,7 @@ Redirect по ролям:
|
||||
|
||||
## Права ролей на API
|
||||
|
||||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`.
|
||||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`.
|
||||
|
||||
Важные ограничения:
|
||||
|
||||
@@ -413,6 +464,8 @@ CRUD доступен по:
|
||||
| `timeSlotId` | Временной слот |
|
||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||
|
||||
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
|
||||
|
||||
Пример:
|
||||
|
||||
```http
|
||||
@@ -546,7 +599,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
### `GET /api/groups`
|
||||
|
||||
Список всех групп.
|
||||
Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
|
||||
|
||||
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
@@ -566,14 +621,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
"specialtyName": "Программная инженерия",
|
||||
"specialtyProfileId": 2,
|
||||
"specialtyProfileName": "Без профиля",
|
||||
"specialityCode": 1
|
||||
"specialityCode": 1,
|
||||
"status": "ACTIVE",
|
||||
"active": true,
|
||||
"studyState": "ACTIVE",
|
||||
"studyStateName": "Активна"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/groups/{departmentId}`
|
||||
|
||||
Список всех групп привязанных к конкретной кафедре.
|
||||
Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
|
||||
|
||||
### `POST /api/groups`
|
||||
|
||||
@@ -591,7 +650,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
}
|
||||
```
|
||||
|
||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`.
|
||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
|
||||
|
||||
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
|
||||
|
||||
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
|
||||
|
||||
@@ -613,7 +674,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
### `DELETE /api/groups/{id}`
|
||||
|
||||
Удаление группы.
|
||||
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||||
|
||||
### `POST /api/groups/{id}/restore`
|
||||
|
||||
Восстановление архивной группы.
|
||||
|
||||
### Подгруппы группы
|
||||
|
||||
@@ -906,7 +971,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| `200` | Успех |
|
||||
| `400` | Ошибка валидации (с `message` в теле) |
|
||||
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||||
| `401` | Неверные учётные данные |
|
||||
| `404` | Ресурс / тенант не найден |
|
||||
| `500` | Внутренняя ошибка сервера |
|
||||
|
||||
@@ -32,7 +32,7 @@ graph TD
|
||||
- **Порт:** 8080 (внутренний)
|
||||
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
||||
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
||||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей
|
||||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
|
||||
|
||||
### PostgreSQL
|
||||
- **Версия:** `postgres:alpine3.23`
|
||||
@@ -80,6 +80,7 @@ sequenceDiagram
|
||||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||||
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
||||
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
|
||||
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||||
@@ -99,7 +100,7 @@ sequenceDiagram
|
||||
### Конфигурация тенантов
|
||||
|
||||
Список тенантов хранится в JSON-файле:
|
||||
- **Локально:** `backend/tenants.json`
|
||||
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
|
||||
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
|
||||
|
||||
Формат:
|
||||
@@ -121,26 +122,29 @@ sequenceDiagram
|
||||
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
||||
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
|
||||
|
||||
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
|
||||
|
||||
### Fallback при отсутствии тенантов
|
||||
|
||||
Если при запуске нет ни одного настроенного тенанта:
|
||||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||||
|
||||
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Система использует простую модель аутентификации без JWT и без полноценного Spring Security:
|
||||
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
|
||||
|
||||
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
|
||||
2. Backend проверяет пароль через `BCryptPasswordEncoder`
|
||||
3. При успехе возвращается:
|
||||
- UUID-токен (для заголовка `Authorization: Bearer`)
|
||||
- Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`)
|
||||
- Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`)
|
||||
4. Токен хранится в `localStorage` на клиенте
|
||||
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
|
||||
2. Backend проверяет пароль через `BCryptPasswordEncoder`.
|
||||
3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `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-домене.
|
||||
|
||||
@@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
### Учебные группы (Student Groups)
|
||||
|
||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||
|
||||
### Аудитории (Classrooms)
|
||||
|
||||
@@ -90,6 +91,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
||||
|
||||
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
|
||||
|
||||
### Перевод преподавателей между кафедрами
|
||||
|
||||
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
|
||||
@@ -136,6 +139,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
||||
|
||||
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
|
||||
|
||||
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||
|
||||
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
|
||||
|
||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||
|
||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||
@@ -167,6 +176,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
@@ -48,6 +48,17 @@ erDiagram
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMP updated_at
|
||||
}
|
||||
|
||||
auth_refresh_tokens {
|
||||
BIGSERIAL id PK
|
||||
BIGINT user_id FK
|
||||
VARCHAR tenant
|
||||
VARCHAR token_hash UK
|
||||
TIMESTAMP issued_at
|
||||
TIMESTAMP expires_at
|
||||
TIMESTAMP revoked_at
|
||||
VARCHAR rotated_to_token_hash
|
||||
}
|
||||
|
||||
education_forms {
|
||||
BIGSERIAL id PK
|
||||
@@ -292,6 +303,7 @@ erDiagram
|
||||
student_groups ||--o{ schedule_rule_groups : "group_id"
|
||||
student_groups ||--o{ student_group_calendar_assignments : "group_id"
|
||||
users ||--o{ teacher_subjects : "user_id"
|
||||
users ||--o{ auth_refresh_tokens : "user_id"
|
||||
users ||--o{ teacher_department_assignments : "teacher_id"
|
||||
departments ||--o{ teacher_department_assignments : "department_id"
|
||||
users ||--o{ teacher_lesson_types : "user_id"
|
||||
@@ -379,6 +391,22 @@ erDiagram
|
||||
|
||||
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
||||
|
||||
#### `auth_refresh_tokens` — Refresh-сессии JWT
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID refresh-сессии |
|
||||
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
|
||||
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
|
||||
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
|
||||
| `issued_at` | TIMESTAMP | Дата выдачи |
|
||||
| `expires_at` | TIMESTAMP | Дата истечения |
|
||||
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
|
||||
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
|
||||
| `user_agent` | VARCHAR(512) | User-Agent клиента |
|
||||
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
|
||||
|
||||
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie.
|
||||
|
||||
### Учебный процесс
|
||||
|
||||
#### `education_forms` — Формы обучения
|
||||
@@ -401,6 +429,11 @@ erDiagram
|
||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
||||
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
||||
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
|
||||
| `active_from` | DATE | Дата начала действия группы |
|
||||
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
|
||||
| `archived_at` | TIMESTAMP | Дата и время архивирования |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
|
||||
#### `subgroups` — Подгруппы
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -616,6 +649,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
| `version_group_id` | BIGINT | Группа версий одного правила |
|
||||
| `change_reason` | TEXT | Причина изменения |
|
||||
|
||||
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
|
||||
|
||||
#### `schedule_rule_groups` — Группы правила
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
@@ -674,17 +709,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
|
||||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||||
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway
|
||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
|
||||
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
|
||||
|
||||
> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных.
|
||||
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
|
||||
|
||||
### Текущие миграции
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -222,11 +222,13 @@ public class AbsenceController {
|
||||
|
||||
### Правила
|
||||
|
||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций
|
||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
|
||||
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
||||
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
||||
4. После добавления — перезапустите backend для применения
|
||||
|
||||
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
|
||||
|
||||
### Применение
|
||||
|
||||
```bash
|
||||
@@ -252,11 +254,13 @@ com.magistr.app/
|
||||
│ ├── TenantContext.java # ThreadLocal текущего тенанта
|
||||
│ ├── TenantInterceptor.java # Определение тенанта из Host
|
||||
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
|
||||
│ ├── TenantDataSourceConfig.java # Spring-конфигурация
|
||||
│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
|
||||
│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
|
||||
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
|
||||
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
|
||||
├── controller/ # REST-контроллеры
|
||||
│ ├── AuthController.java
|
||||
│ ├── GlobalExceptionHandler.java
|
||||
│ ├── ScheduleController.java
|
||||
│ ├── ClassroomController.java
|
||||
│ ├── DatabaseController.java
|
||||
|
||||
@@ -146,7 +146,7 @@ frontend/
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||
@@ -171,19 +171,24 @@ frontend/
|
||||
|
||||
## API-клиент (`api.js`)
|
||||
|
||||
Все HTTP-запросы проходят через обёртку `apiFetch()`:
|
||||
Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
|
||||
|
||||
```javascript
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null
|
||||
headers: getHeaders(body ? 'application/json' : null),
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (response.status === 401 && retryOnUnauthorized) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) return apiFetch(endpoint, method, body, false);
|
||||
clearAuthState();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
@@ -200,7 +205,7 @@ export const api = {
|
||||
};
|
||||
```
|
||||
|
||||
Токен берётся из `localStorage.getItem('token')`.
|
||||
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,11 +216,12 @@ export const api = {
|
||||
1. Пользователь вводит логин/пароль
|
||||
2. `script.js` отправляет `POST /api/auth/login`
|
||||
3. При успехе сохраняет в `localStorage`:
|
||||
- `token` — UUID-токен
|
||||
- `token` — access JWT
|
||||
- `role` — роль пользователя
|
||||
- `departmentId` — кафедра пользователя
|
||||
- `userId` — ID пользователя для личного расписания преподавателя
|
||||
4. Перенаправляет на соответствующий интерфейс:
|
||||
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
|
||||
5. Перенаправляет на соответствующий интерфейс:
|
||||
- `ADMIN` → `/admin/`
|
||||
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
||||
- `DEPARTMENT` → `/admin/#department-workspace`
|
||||
@@ -230,13 +236,13 @@ export const api = {
|
||||
```javascript
|
||||
export function isAuthenticatedAsAdmin() {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && role === 'ADMIN';
|
||||
return getToken() && role === 'ADMIN';
|
||||
}
|
||||
```
|
||||
|
||||
### Выход
|
||||
|
||||
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
|
||||
Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,13 +25,21 @@ docker network create proxy
|
||||
|
||||
```env
|
||||
POSTGRES_USER=myuser
|
||||
POSTGRES_PASSWORD=supersecretpassword
|
||||
POSTGRES_PASSWORD=replace-with-local-password
|
||||
POSTGRES_DB=app_db
|
||||
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
|
||||
JWT_ACCESS_TOKEN_TTL=15m
|
||||
JWT_REFRESH_TOKEN_TTL=7d
|
||||
```
|
||||
|
||||
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
||||
|
||||
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
|
||||
|
||||
### Dockerfile (Backend)
|
||||
|
||||
Backend собирается через multi-stage сборку Maven:
|
||||
1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package`
|
||||
1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
|
||||
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
||||
|
||||
### Dockerfile (Frontend)
|
||||
@@ -58,6 +66,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||||
| `frontend` | Deployment | Apache httpd |
|
||||
| `tenants-config` | ConfigMap | JSON-список тенантов |
|
||||
|
||||
### JWT настройки
|
||||
|
||||
`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:
|
||||
|
||||
- `JWT_ACCESS_TOKEN_TTL=15m`
|
||||
- `JWT_REFRESH_TOKEN_TTL=7d`
|
||||
- `JWT_REFRESH_COOKIE_SECURE=true`
|
||||
|
||||
`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
|
||||
|
||||
### ConfigMap для тенантов
|
||||
|
||||
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
|
||||
|
||||
Reference in New Issue
Block a user