1496 lines
70 KiB
Markdown
1496 lines
70 KiB
Markdown
# 🔌 REST API
|
||
|
||
Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны
|
||
под `/actuator/health/`. Ответы возвращаются в формате JSON.
|
||
|
||
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404`,
|
||
`409` и `500` используется общий JSON-формат:
|
||
|
||
```json
|
||
{
|
||
"timestamp": "2026-05-27T16:47:54Z",
|
||
"status": 400,
|
||
"error": "Некорректный запрос",
|
||
"message": "Некорректные параметры запроса",
|
||
"path": "/api/schedule"
|
||
}
|
||
```
|
||
|
||
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||
|
||
Все поля момента времени (`createdAt`, `updatedAt`, `reviewedAt`, `archivedAt` и
|
||
аналогичные) сериализуются как ISO-8601 UTC с суффиксом `Z`. Поля календарной даты
|
||
(`date`, `validFrom`, `validTo`, `activeFrom`, `activeTo`) остаются строками `YYYY-MM-DD`
|
||
без часового пояса и вычисляются по бизнес-зоне `Europe/Moscow`.
|
||
|
||
Нарушения ограничений PostgreSQL также обрабатываются централизованно. Известные CHECK
|
||
возвращают `400`, а UNIQUE, FK и GiST exclusion conflicts — `409` с безопасным русским
|
||
сообщением. Тексты JDBC, SQL, имена ограничений и внутренние причины исключений в JSON не
|
||
передаются; неизвестное нарушение получает обобщённое сообщение.
|
||
|
||
---
|
||
|
||
## Служебные проверки состояния
|
||
|
||
Эти endpoints предназначены для Kubernetes kubelet, не требуют bearer-токен и не зависят
|
||
от tenant-домена в заголовке `Host`. Из Actuator наружу опубликован только `health`, а
|
||
состав компонентов, домены, JDBC URL и credentials в ответах скрыты.
|
||
|
||
### `GET /actuator/health/liveness`
|
||
|
||
Проверяет только жизнеспособность процесса. Недоступность tenant-БД не меняет liveness и
|
||
не должна создавать цикл перезапусков pod.
|
||
|
||
**Ответ работающего процесса (200):**
|
||
|
||
```json
|
||
{
|
||
"status": "UP"
|
||
}
|
||
```
|
||
|
||
### `GET /actuator/health/readiness`
|
||
|
||
Разрешает направлять трафик в pod только после успешных миграций и свежей успешной
|
||
проверки соединения со всеми обязательными tenant-БД. Пустая конфигурация, H2-заглушка,
|
||
ошибка чтения tenant-конфигурации, незавершённая или неуспешная миграция, недоступное либо
|
||
просроченное соединение делают pod неготовым.
|
||
|
||
**Готов (200):**
|
||
|
||
```json
|
||
{
|
||
"status": "UP"
|
||
}
|
||
```
|
||
|
||
**Не готов (503):**
|
||
|
||
```json
|
||
{
|
||
"status": "DOWN"
|
||
}
|
||
```
|
||
|
||
`UP` и `DOWN` — стандартные машинные значения протокола Spring Boot Actuator, а не
|
||
пользовательские сообщения интерфейса.
|
||
|
||
---
|
||
|
||
## Аутентификация
|
||
|
||
### `POST /api/auth/login`
|
||
|
||
Вход в систему.
|
||
|
||
**Тело запроса:**
|
||
```json
|
||
{
|
||
"username": "admin",
|
||
"password": "admin"
|
||
}
|
||
```
|
||
|
||
**Успешный ответ (200):**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"message": "OK",
|
||
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||
"role": "ADMIN",
|
||
"redirect": "/admin/",
|
||
"departmentId": 1,
|
||
"userId": 1
|
||
}
|
||
```
|
||
|
||
Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена.
|
||
|
||
**Ошибка (401):**
|
||
```json
|
||
{
|
||
"success": false,
|
||
"message": "Неверное имя пользователя или пароль",
|
||
"token": null,
|
||
"role": null,
|
||
"redirect": null,
|
||
"departmentId": null,
|
||
"userId": null
|
||
}
|
||
```
|
||
|
||
Для несуществующего пользователя, неверного пароля и архивной учётной записи возвращается
|
||
одинаковый ответ `401`, поэтому endpoint не раскрывает наличие или состояние пользователя.
|
||
|
||
**Временная блокировка (429):**
|
||
|
||
```http
|
||
HTTP/1.1 429 Too Many Requests
|
||
Retry-After: 60
|
||
```
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"message": "Слишком много попыток входа. Повторите позже",
|
||
"token": null,
|
||
"role": null,
|
||
"redirect": null,
|
||
"departmentId": null,
|
||
"userId": null
|
||
}
|
||
```
|
||
|
||
Backend ведёт общий для всех pod счётчик по tenant, нормализованному `username` и IP.
|
||
После пяти отказов по умолчанию комбинация блокируется на 60 секунд; следующие отказы
|
||
после окончания блокировки увеличивают задержку до максимума 15 минут. `Retry-After`
|
||
содержит оставшееся целое число секунд. Успешный вход сбрасывает счётчик.
|
||
|
||
> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
|
||
> Штатный web-клиент хранит access JWT только в памяти страницы; после reload он получает
|
||
> новый access JWT через `HttpOnly` refresh-cookie и `POST /api/auth/refresh`.
|
||
|
||
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
|
||
|
||
Redirect по ролям:
|
||
|
||
| Роль | Redirect |
|
||
|------|----------|
|
||
| `ADMIN` | `/admin/` |
|
||
| `EDUCATION_OFFICE` | `/admin/#schedule-view` |
|
||
| `DEPARTMENT` | `/admin/#department-workspace` |
|
||
| `SCHEDULE_VIEWER` | `/admin/#schedule-view` |
|
||
| `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-токен отзывается.
|
||
Один refresh-токен можно успешно использовать только один раз, в том числе при параллельных
|
||
запросах: первый запрос получает новую пару токенов, остальные получают `401`, а их cookie
|
||
очищается.
|
||
|
||
### `POST /api/auth/logout`
|
||
|
||
Отзывает текущий refresh-токен и очищает refresh-cookie.
|
||
|
||
**Успешный ответ (200):**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"message": "Выход выполнен"
|
||
}
|
||
```
|
||
|
||
### `GET /api/auth/me`
|
||
|
||
Возвращает текущего пользователя по bearer-токену.
|
||
|
||
```json
|
||
{
|
||
"userId": 1,
|
||
"username": "admin",
|
||
"role": "ADMIN",
|
||
"departmentId": 1
|
||
}
|
||
```
|
||
|
||
`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
|
||
|
||
---
|
||
|
||
## Пользователи
|
||
|
||
### `GET /api/users`
|
||
|
||
Список всех пользователей.
|
||
|
||
**Ответ:**
|
||
```json
|
||
[
|
||
{ "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`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||
|
||
Выборка использует записи `teacher_department_assignments`, действующие на текущую дату.
|
||
Дополнительная связь (`is_primary=false`) также включает преподавателя в список кафедры.
|
||
Роль `DEPARTMENT` может запрашивать только свою кафедру.
|
||
|
||
### `POST /api/users`
|
||
|
||
Создание пользователя.
|
||
|
||
**Тело запроса:**
|
||
```json
|
||
{
|
||
"username": "teacher1",
|
||
"password": "password",
|
||
"role": "TEACHER",
|
||
"fullName": "Test Teacher",
|
||
"jobTitle": "Proffessor",
|
||
"departmentId": 1
|
||
}
|
||
```
|
||
|
||
**Валидация:**
|
||
- `username` — обязателен и уникален
|
||
- `password` — минимум 8 символов
|
||
- `role` — `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
|
||
- `fullName` — обязателен
|
||
- `departmentId` — обязателен
|
||
|
||
### `DELETE /api/users/{id}`
|
||
|
||
Архивирование пользователя. Исторические связи и расписание остаются в БД, но пользователь больше не может войти.
|
||
|
||
### `POST /api/users/{id}/restore`
|
||
|
||
Восстановление архивного пользователя.
|
||
|
||
### `GET /api/users/{id}/department-history`
|
||
|
||
История переводов преподавателя между кафедрами.
|
||
|
||
### `POST /api/users/{id}/department-transfer`
|
||
|
||
Перевод преподавателя на другую кафедру без потери прошлых связей.
|
||
|
||
```json
|
||
{
|
||
"departmentId": 2,
|
||
"validFrom": "2026-06-01",
|
||
"comment": "Перевод на кафедру ВТ"
|
||
}
|
||
```
|
||
|
||
Если `validFrom` находится в будущем, текущая основная кафедра остаётся действующей до дня,
|
||
предшествующего переводу. Поле совместимости `users.department_id` переключается только
|
||
когда новая основная запись уже действует на текущую дату. Пересекающиеся периоды двух
|
||
основных кафедр одного преподавателя отклоняются с `409 Conflict`.
|
||
|
||
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
|
||
|
||
Список преподавателей кафедры на конкретную дату по таблице истории назначений. Архивный
|
||
преподаватель входит в исторический ответ, если на указанную дату действовали и пользователь,
|
||
и его назначение. Роль `DEPARTMENT` может запрашивать только свою кафедру.
|
||
|
||
---
|
||
|
||
## Заявки на создание преподавателей
|
||
|
||
Администратор просматривает и обрабатывает заявки кафедр на создание новых преподавателей.
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
|
||
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
|
||
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
|
||
|
||
**Тело одобрения:**
|
||
```json
|
||
{
|
||
"departmentId": 2,
|
||
"username": "teacher.new",
|
||
"password": "secure-pass",
|
||
"fullName": "Новый Преподаватель",
|
||
"jobTitle": "Доцент",
|
||
"reviewComment": "Данные проверены"
|
||
}
|
||
```
|
||
|
||
Пароль задаёт только администратор при одобрении заявки. Сама заявка пароль не хранит.
|
||
|
||
**Тело отклонения:**
|
||
```json
|
||
{
|
||
"reviewComment": "Нужно уточнить ФИО"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Права ролей на API
|
||
|
||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`.
|
||
|
||
Важные ограничения:
|
||
|
||
- `DEPARTMENT` не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API;
|
||
- `DEPARTMENT` создаёт и комментирует дисциплины через `/api/department/*`, где кафедра берётся из текущего пользователя;
|
||
- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только дисциплины своей кафедры и преподавателей с основной или дополнительной связью с ней на текущую дату;
|
||
- `SCHEDULE_VIEWER` имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности.
|
||
|
||
---
|
||
|
||
## Динамическое расписание
|
||
|
||
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.
|
||
|
||
### `GET /api/schedule`
|
||
|
||
Получение расписания группы или преподавателя за период.
|
||
|
||
**Параметры:**
|
||
|
||
| Параметр | Обязателен | Описание |
|
||
|----------|------------|----------|
|
||
| `groupId` | Да, если нет `teacherId` | ID учебной группы |
|
||
| `teacherId` | Да, если нет `groupId` | ID преподавателя |
|
||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||
|
||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120
|
||
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля
|
||
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и
|
||
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
|
||
расписание для неё возвращается пустым списком. Время пары берётся из базового слота
|
||
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
|
||
`/api/admin/time-slots`.
|
||
|
||
**Пример:**
|
||
```http
|
||
GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||
```
|
||
|
||
**Ответ:**
|
||
```json
|
||
[
|
||
{
|
||
"scheduleRuleId": 10,
|
||
"scheduleRuleSlotId": 31,
|
||
"date": "2026-04-27",
|
||
"dayOfWeek": 1,
|
||
"dayName": "Понедельник",
|
||
"weekNumber": 13,
|
||
"parity": "ODD",
|
||
"timeSlotId": 3,
|
||
"timeSlotOrder": 3,
|
||
"startTime": "11:40:00",
|
||
"endTime": "13:10:00",
|
||
"subjectId": 1,
|
||
"subjectName": "Высшая математика",
|
||
"teacherId": 2,
|
||
"teacherName": "Петров Препод Петрович",
|
||
"classroomId": 1,
|
||
"classroomName": "101 Ленинская",
|
||
"lessonTypeId": 1,
|
||
"lessonTypeName": "Лекция",
|
||
"lessonFormat": "Очно",
|
||
"subgroupId": null,
|
||
"subgroupName": null,
|
||
"subgroupIds": [],
|
||
"subgroupNames": [],
|
||
"groupIds": [1],
|
||
"groupNames": ["ИВТ-21-1"],
|
||
"activityType": "Т",
|
||
"lessonTypeAcademicHours": 32,
|
||
"consumedLessonTypeAcademicHoursBeforeLesson": 12,
|
||
"remainingLessonTypeAcademicHoursAfterLesson": 18
|
||
}
|
||
]
|
||
```
|
||
|
||
### `GET /api/schedule/semesters`
|
||
|
||
Доступный только для чтения список семестров для фильтров просмотра расписания. Доступен всем ролям,
|
||
которые могут просматривать расписание, включая `DEPARTMENT` и `SCHEDULE_VIEWER`.
|
||
Семестры возвращаются от новых к старым.
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": 2,
|
||
"academicYearId": 1,
|
||
"academicYearTitle": "2025/2026",
|
||
"semesterType": "spring",
|
||
"startDate": "2026-02-09",
|
||
"endDate": "2026-06-30"
|
||
}
|
||
]
|
||
```
|
||
|
||
### `GET /api/admin/time-slots`
|
||
|
||
Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной.
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/admin/time-slots` | Список слотов |
|
||
| `GET` | `/api/admin/time-slots/effective?date=2026-05-02` | Эффективные слоты для даты |
|
||
| `POST` | `/api/admin/time-slots` | Создать слот |
|
||
| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот |
|
||
| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот |
|
||
| `GET` | `/api/admin/time-slots/scopes` | Список сеток времени |
|
||
| `POST` | `/api/admin/time-slots/scopes` | Создать ручную сетку |
|
||
| `PUT` | `/api/admin/time-slots/scopes/{id}` | Переименовать ручную сетку |
|
||
| `DELETE` | `/api/admin/time-slots/scopes/{id}` | Удалить ручную сетку |
|
||
| `GET` | `/api/admin/time-slots/date-assignments` | Ручные назначения дат |
|
||
| `POST` | `/api/admin/time-slots/date-assignments` | Применить ручную сетку к дате |
|
||
| `DELETE` | `/api/admin/time-slots/date-assignments/{id}` | Убрать ручное назначение |
|
||
|
||
**Тело создания/обновления:**
|
||
```json
|
||
{
|
||
"orderNumber": 1,
|
||
"scopeId": 1,
|
||
"startTime": "08:00:00",
|
||
"endTime": "09:30:00"
|
||
}
|
||
```
|
||
|
||
`durationMinutes` в запросе не требуется и не считается доверенным значением: backend
|
||
всегда вычисляет длительность как разницу `endTime - startTime` и возвращает результат в
|
||
ответе. Начало должно быть раньше окончания, а длительность — не меньше одной минуты.
|
||
Внутри одной сетки запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы;
|
||
соседние слоты, у которых окончание первого совпадает с началом второго, разрешены.
|
||
Конфликт номера или интервала возвращает `409 Conflict`. Слот, на который уже ссылается
|
||
правило расписания, нельзя перенести из базовой сетки `DEFAULT`.
|
||
|
||
**Создание ручной сетки:**
|
||
```json
|
||
{
|
||
"name": "Праздничная сетка"
|
||
}
|
||
```
|
||
|
||
**Ручное применение сетки к дате:**
|
||
```json
|
||
{
|
||
"date": "2026-05-08",
|
||
"scopeId": 3
|
||
}
|
||
```
|
||
|
||
Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments`, имеют приоритет над автоматической субботней сеткой и назначаются пользователем из модального окна ячейки редактора календарного графика. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
|
||
|
||
### Учебные годы, семестры и коды календарного графика
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/admin/calendar/years` | Учебные годы с семестрами |
|
||
| `POST` | `/api/admin/calendar/years` | Создать учебный год |
|
||
| `PUT` | `/api/admin/calendar/years/{id}` | Обновить учебный год |
|
||
| `DELETE` | `/api/admin/calendar/years/{id}` | Удалить учебный год |
|
||
| `GET` | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года |
|
||
| `POST` | `/api/admin/calendar/years/{academicYearId}/semesters` | Создать семестр |
|
||
| `PUT` | `/api/admin/calendar/semesters/{id}` | Обновить семестр |
|
||
| `GET` | `/api/admin/calendar/activity-types` | Справочник кодов активностей графика |
|
||
| `POST` | `/api/admin/calendar/activity-types` | Создать код активности |
|
||
| `PUT` | `/api/admin/calendar/activity-types/{id}` | Обновить код активности |
|
||
| `DELETE` | `/api/admin/calendar/activity-types/{id}` | Удалить код активности |
|
||
|
||
**Тело учебного года:**
|
||
```json
|
||
{
|
||
"title": "2026-2027",
|
||
"startDate": "2026-09-01",
|
||
"endDate": "2027-06-30"
|
||
}
|
||
```
|
||
|
||
**Тело семестра:**
|
||
```json
|
||
{
|
||
"semesterType": "autumn",
|
||
"startDate": "2026-09-01",
|
||
"endDate": "2027-01-31"
|
||
}
|
||
```
|
||
|
||
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
|
||
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
|
||
период разрешено начать на следующий календарный день после окончания предыдущего.
|
||
Повторное название года, повторный тип семестра или пересечение возвращаются как
|
||
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
|
||
При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
|
||
|
||
**Тело создания/обновления кода активности:**
|
||
```json
|
||
{
|
||
"code": "Т",
|
||
"name": "Теоретическое обучение",
|
||
"allowSchedule": true,
|
||
"colorCode": "#22c55e",
|
||
"displayOrder": 10,
|
||
"description": "Обычные пары разрешены"
|
||
}
|
||
```
|
||
|
||
### Календарные учебные графики
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
|
||
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
|
||
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
||
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
|
||
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
|
||
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
|
||
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки |
|
||
| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров |
|
||
| `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика |
|
||
|
||
**Тело создания/обновления графика:**
|
||
```json
|
||
{
|
||
"title": "09.03.04 очная форма 2025-2026",
|
||
"academicYearId": 1,
|
||
"specialtyId": 2,
|
||
"specialtyProfileId": 3,
|
||
"studyFormId": 1,
|
||
"courseCount": 4
|
||
}
|
||
```
|
||
|
||
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
|
||
`courseCount` должен быть в диапазоне `1..8`.
|
||
|
||
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
|
||
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
|
||
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
|
||
`409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить
|
||
`courseCount`, пока существуют строки старших курсов или дисциплины семестров выше
|
||
`courseCount * 2`.
|
||
|
||
**Ячейка сетки графика:**
|
||
```json
|
||
{
|
||
"calendarId": 1,
|
||
"courseNumber": 1,
|
||
"date": "2025-09-01",
|
||
"weekNumber": 1,
|
||
"dayOfWeek": 1,
|
||
"activityTypeId": 1
|
||
}
|
||
```
|
||
|
||
`PUT /api/admin/academic-calendars/{id}/grid` выполняет атомарную полную замену. До
|
||
удаления прежних строк backend проверяет весь список: он должен быть непустым и не
|
||
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
|
||
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber` — с номером семидневного периода
|
||
от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый
|
||
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
|
||
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
|
||
|
||
**Привязка дисциплин к графику:**
|
||
```json
|
||
[
|
||
{ "semesterNumber": 1, "subjectId": 1 },
|
||
{ "semesterNumber": 1, "subjectId": 2 },
|
||
{ "semesterNumber": 2, "subjectId": 3 }
|
||
]
|
||
```
|
||
|
||
`semesterNumber` — номер учебного семестра внутри графика: для 4 курсов доступны значения `1..8`. API принимает только существующие неархивные дисциплины из `/api/subjects`, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с `subjectName`, `subjectCode` и `departmentId`. `PUT /subjects` выполняет атомарную полную замену списка: перед вставкой нового набора старые привязки этого графика удаляются и синхронизируются с БД.
|
||
|
||
### `POST /api/admin/schedule-rules`
|
||
|
||
Создание правила динамического расписания.
|
||
|
||
```json
|
||
{
|
||
"subjectId": 1,
|
||
"semesterId": 1,
|
||
"lectureAcademicHours": 32,
|
||
"laboratoryAcademicHours": 16,
|
||
"practiceAcademicHours": 24,
|
||
"lectureStartWeek": 1,
|
||
"laboratoryStartWeek": 3,
|
||
"practiceStartWeek": 2,
|
||
"groupIds": [1, 2],
|
||
"slots": [
|
||
{
|
||
"dayOfWeek": 1,
|
||
"parity": "BOTH",
|
||
"timeSlotId": 3,
|
||
"subgroupId": null,
|
||
"subgroupIds": [],
|
||
"teacherId": 2,
|
||
"classroomId": 1,
|
||
"lessonTypeId": 1,
|
||
"lessonFormat": "Очно"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
CRUD доступен по:
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` |
|
||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
|
||
|
||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||
|
||
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
|
||
|
||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит
|
||
часов обязателен, неотрицателен и кратен двум; ноль разрешён для неиспользуемого типа, но
|
||
суммарно хотя бы один тип должен иметь положительный лимит. Если лимит типа ненулевой, в
|
||
правиле должен быть хотя бы один слот этого типа; слот типа не принимается при нулевом
|
||
лимите. Вложенные идентификаторы, которых нет в БД, считаются ошибкой payload и дают `400`.
|
||
|
||
`teacherId` должен ссылаться на активного пользователя с ролью `TEACHER`, а
|
||
`lessonFormat` принимает только `Очно` или `Онлайн`. `parity` принимает только `BOTH`,
|
||
`ODD` или `EVEN`.
|
||
|
||
До сохранения backend попарно проверяет все слоты нового payload: точные дубли и
|
||
пересечения преподавателя, аудитории или аудитории обучающихся отклоняются. `ODD` и `EVEN`
|
||
не пересекаются; `BOTH` пересекается с обеими чётностями. Затем выполняется та же проверка
|
||
с активными правилами семестра. Активные недели рассчитываются по лимиту часов типа
|
||
занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое
|
||
фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных
|
||
слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один
|
||
слот, но слот для всей группы конфликтует с любой её подгруппой. Создание правил одного
|
||
семестра сериализуется блокировкой строки семестра в PostgreSQL. Конфликт с уже сохранённым
|
||
правилом возвращает `409 Conflict`:
|
||
|
||
```json
|
||
{
|
||
"message": "Невозможно сохранить правило: слот занят",
|
||
"conflictRule": {
|
||
"id": 12,
|
||
"subjectName": "Математический анализ",
|
||
"semesterId": 1,
|
||
"groupNames": ["ИБ-101"],
|
||
"slots": []
|
||
},
|
||
"conflictFields": ["classroom"],
|
||
"conflictReasons": ["Аудитория"]
|
||
}
|
||
```
|
||
|
||
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или
|
||
`group`. Для точного дубля используется `slot`. При конфликте внутри нового payload
|
||
`conflictRule` отсутствует, потому что конфликтующей сохранённой записи ещё нет:
|
||
|
||
```json
|
||
{
|
||
"message": "Невозможно сохранить правило: слоты внутри правила конфликтуют",
|
||
"conflictFields": ["teacher", "group"],
|
||
"conflictReasons": ["Преподаватель", "Группа"]
|
||
}
|
||
```
|
||
|
||
`conflictReasons` содержит те же причины в русских подписях для интерфейса.
|
||
|
||
### `GET /api/lesson-types`
|
||
|
||
Справочник типов занятий для конструктора правил расписания.
|
||
|
||
**Ответ:**
|
||
```json
|
||
[
|
||
{ "id": 1, "name": "Лекция" },
|
||
{ "id": 2, "name": "Практика" },
|
||
{ "id": 3, "name": "Лабораторная работа" }
|
||
]
|
||
```
|
||
|
||
### `GET /api/schedule/search`
|
||
|
||
Расширенный поиск расписания. В отличие от `GET /api/schedule`, принимает несколько фильтров одновременно.
|
||
|
||
| Параметр | Описание |
|
||
|----------|----------|
|
||
| `startDate` / `endDate` | Обязательный период |
|
||
| `groupId` | Учебная группа |
|
||
| `teacherId` | Преподаватель |
|
||
| `classroomId` | Аудитория |
|
||
| `departmentId` | Кафедра |
|
||
| `subjectId` | Дисциплина |
|
||
| `lessonTypeId` | Тип занятия |
|
||
| `timeSlotId` | Временной слот |
|
||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||
|
||
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
|
||
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
|
||
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
|
||
базовый день, из него добавляются только указанные `baseRuleSlotId`, после чего применяются
|
||
все overrides и выполняется окончательный фильтр преподавателя. Поэтому новый преподаватель
|
||
видит назначенную замену, а исходный больше её не видит. Если релевантных замен нет, обход
|
||
всех групп не выполняется.
|
||
|
||
Широкий пользовательский поиск без `groupId`, `departmentId` и teacher-only режима
|
||
разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с
|
||
просьбой уточнить группу или кафедру.
|
||
|
||
Ограничение относится только к интерактивному `/api/schedule/search`. Endpoints
|
||
`/api/workload/*` используют отдельный агрегирующий путь и обрабатывают все активные группы
|
||
разрешённого кафедрального scope, в том числе при количестве больше 50.
|
||
|
||
Пример:
|
||
|
||
```http
|
||
GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
|
||
```
|
||
|
||
Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`.
|
||
Для занятия, к которому применено разовое изменение, дополнительно заполнены:
|
||
|
||
- `scheduleOverrideId` — идентификатор изменения;
|
||
- `overrideAction` — `MOVE` или `REPLACE` (`CANCEL` в выдачу не попадает);
|
||
- `originalLessonDate` — исходная дата занятия из базового правила.
|
||
|
||
Перенос удаляет занятие из исходного дня и добавляет его в целевой. Поиск учитывает
|
||
изменение, если в запрошенный диапазон попала исходная **или** целевая дата, поэтому
|
||
входящий перенос находится даже запросом только по целевому диапазону. Для целевой даты
|
||
пересчитываются день недели, номер недели и чётность; учёт академических часов остаётся
|
||
привязан к исходному занятию.
|
||
|
||
### Точечные изменения расписания учебного отдела
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/edu-office/schedule/overrides?startDate=&endDate=` | Список изменений; диапазон проверяется по исходной или целевой дате |
|
||
| `GET` | `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=&lessonDate=` | Границы семестра и допустимые учебные даты для конкретного занятия |
|
||
| `POST` | `/api/edu-office/schedule/overrides` | Создать перенос, отмену или замену |
|
||
| `PUT` | `/api/edu-office/schedule/overrides/{id}` | Обновить изменение |
|
||
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила |
|
||
|
||
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный
|
||
диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя,
|
||
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
|
||
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
|
||
|
||
```json
|
||
{
|
||
"baseRuleSlotId": 31,
|
||
"lessonDate": "2026-05-21",
|
||
"targetLessonDate": "2026-05-27",
|
||
"action": "MOVE",
|
||
"newTimeSlotId": 4,
|
||
"newClassroomId": 2,
|
||
"newTeacherId": 5,
|
||
"newLessonFormat": "Онлайн",
|
||
"comment": "Перенос конкретного занятия"
|
||
}
|
||
```
|
||
|
||
`lessonDate` всегда обозначает исходное занятие из правила. `targetLessonDate` передаётся
|
||
только при переносе на другой день и не заменяет идентификатор исходной пары
|
||
`baseRuleSlotId + lessonDate`.
|
||
|
||
Правила payload:
|
||
|
||
- `CANCEL` отменяет конкретную пару; `targetLessonDate` и все поля `new*` должны отсутствовать;
|
||
- `MOVE` требует новый временной слот; при переносе даты слот выбирается из эффективной
|
||
сетки целевого дня, а преподавателя, аудиторию и формат можно изменить тем же запросом;
|
||
- `REPLACE` используется только без изменения даты и времени и требует нового
|
||
преподавателя, аудиторию или формат;
|
||
- формат принимает только `Очно` или `Онлайн`;
|
||
- `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID
|
||
временного слота с тем же интервалом не считается переносом.
|
||
|
||
До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное
|
||
расписание: семестр, календарный график, чётность, активность сущностей и остаток часов.
|
||
При переносе даты backend дополнительно проверяет тот же семестр, действие правила и
|
||
дисциплины, lifecycle итоговых ресурсов, учебный календарь всех затронутых групп и
|
||
принадлежность времени эффективной сетке целевого дня. Если пара не формируется или
|
||
нарушена матрица действия, API возвращает `400` с русским сообщением. Если результирующее
|
||
время пересекается с занятым преподавателем, аудиторией, группой или той же подгруппой,
|
||
API возвращает `409 Conflict`; соседние интервалы и разные подгруппы одной группы не
|
||
конфликтуют.
|
||
|
||
Пример ответа availability:
|
||
|
||
```json
|
||
{
|
||
"semesterId": 3,
|
||
"semesterStartDate": "2026-02-09",
|
||
"semesterEndDate": "2026-06-30",
|
||
"availableDates": ["2026-05-21", "2026-05-22", "2026-05-25"]
|
||
}
|
||
```
|
||
|
||
## Загруженность
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/workload/teachers` | Загруженность преподавателей |
|
||
| `GET` | `/api/workload/classrooms` | Загруженность аудиторий |
|
||
| `GET` | `/api/workload/departments` | Загруженность кафедр |
|
||
| `GET` | `/api/workload/time-slots` | Загруженность по парам |
|
||
| `GET` | `/api/workload/free-classrooms` | Свободные аудитории на дату и пару |
|
||
|
||
Общие параметры для отчётов: `startDate`, `endDate`, опционально `departmentId`.
|
||
|
||
Роли `ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER` могут не передавать `departmentId`
|
||
для глобального отчёта или выбрать конкретную кафедру. Для роли `DEPARTMENT` backend всегда
|
||
использует кафедру из access JWT: отсутствие параметра означает свою кафедру, а попытка
|
||
передать чужой `departmentId` возвращает `403 Forbidden`. То же ограничение применяется к
|
||
`GET /api/workload/free-classrooms`, хотя этот endpoint не принимает `departmentId`.
|
||
|
||
Кафедра каждой записи определяется по `teacher_department_assignments` на дату занятия:
|
||
сначала используется основное, затем дополнительное назначение. Поэтому период, включающий
|
||
дату перевода, разделяет нагрузку одного преподавателя между прежней и новой кафедрами,
|
||
а историческая нагрузка не зависит от текущего значения `users.department_id`.
|
||
|
||
Workload и поиск свободных аудиторий строятся через специализированный агрегирующий метод
|
||
`ScheduleQueryService`: он сохраняет проверку диапазона, применение overrides, фильтр пары и
|
||
дедупликацию, но не наследует UI-лимит 50 групп из `/api/schedule/search`.
|
||
|
||
Пример:
|
||
|
||
```http
|
||
GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-01
|
||
```
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": 2,
|
||
"name": "Петров Препод Петрович",
|
||
"departmentId": 1,
|
||
"departmentName": "Кафедра ИБ",
|
||
"lessonCount": 8,
|
||
"academicHours": 16,
|
||
"occupiedSlotCount": 8
|
||
}
|
||
]
|
||
```
|
||
|
||
## Кабинет кафедры
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/department/subjects` | Дисциплины текущей кафедры |
|
||
| `POST` | `/api/department/subjects/import` | Загрузка списка дисциплин |
|
||
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
|
||
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
|
||
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
|
||
| `POST` | `/api/department/teachers/{teacherId}/assignments` | Добавить существующего преподавателя на кафедру |
|
||
| `GET` | `/api/department/teacher-requests` | Заявки кафедры на создание преподавателей |
|
||
| `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя |
|
||
| `GET` | `/api/department/schedule` | Расписание кафедры |
|
||
|
||
`POST /api/department/subjects/import` нормализует пробелы по краям и сравнивает названия
|
||
без учёта регистра. Повторный импорт дисциплины своей кафедры обновляет код и восстанавливает
|
||
архивную запись, не создавая новый ID. Если такое название уже принадлежит другой кафедре,
|
||
API возвращает `409 Conflict`; владелец записи не изменяется. Повторы одного названия внутри
|
||
payload обрабатываются один раз, используется последнее значение.
|
||
|
||
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры только по
|
||
`teacher_department_assignments`. Основные и дополнительные назначения учитываются на
|
||
текущую дату; архивные и ещё не начавшиеся назначения исключаются.
|
||
|
||
`POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`.
|
||
|
||
```json
|
||
{
|
||
"departmentId": 2,
|
||
"comment": "Совместительство"
|
||
}
|
||
```
|
||
|
||
`POST /api/department/teacher-requests` принимает логин, ФИО, должность и комментарий. Повторная pending-заявка с тем же логином запрещена.
|
||
|
||
```json
|
||
{
|
||
"username": "teacher.new",
|
||
"fullName": "Новый Преподаватель",
|
||
"jobTitle": "Доцент",
|
||
"comment": "Нужен для дисциплин кафедры"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Кафедры и специальности
|
||
|
||
### Кафедры
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/departments` | Список кафедр |
|
||
| `POST` | `/api/departments` | Создать кафедру |
|
||
| `PUT` | `/api/departments/{id}` | Обновить кафедру |
|
||
| `DELETE` | `/api/departments/{id}` | Удалить кафедру |
|
||
|
||
**Тело создания/обновления:**
|
||
```json
|
||
{
|
||
"departmentName": "Кафедра ИБ",
|
||
"departmentCode": 1
|
||
}
|
||
```
|
||
|
||
### Специальности
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/specialties` | Список специальностей |
|
||
| `POST` | `/api/specialties` | Создать специальность |
|
||
| `PUT` | `/api/specialties/{id}` | Обновить специальность |
|
||
| `DELETE` | `/api/specialties/{id}` | Удалить специальность |
|
||
| `GET` | `/api/specialties/{id}/profiles` | Профили выбранной специальности |
|
||
| `POST` | `/api/specialties/{id}/profiles` | Создать профиль |
|
||
| `PUT` | `/api/specialties/{id}/profiles/{profileId}` | Обновить профиль |
|
||
| `DELETE` | `/api/specialties/{id}/profiles/{profileId}` | Удалить профиль |
|
||
|
||
**Тело создания/обновления:**
|
||
```json
|
||
{
|
||
"specialityName": "Программная инженерия",
|
||
"specialityCode": "09.03.04"
|
||
}
|
||
```
|
||
|
||
При создании специальности автоматически создаётся профиль `Без профиля`.
|
||
|
||
`EDUCATION_OFFICE` имеет read-only доступ к `GET /api/specialties`,
|
||
`GET /api/specialties/profiles` и `GET /api/specialties/{id}/profiles`, необходимый для
|
||
инициализации календарных учебных графиков. Создание, изменение, архивирование специальностей
|
||
и CRUD профилей остаются доступны только `ADMIN`.
|
||
|
||
**Тело создания/обновления профиля:**
|
||
```json
|
||
{
|
||
"name": "Безопасность автоматизированных систем",
|
||
"description": "Необязательное описание"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Группы
|
||
|
||
### `GET /api/groups`
|
||
|
||
Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
|
||
|
||
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
||
|
||
**Ответ:**
|
||
```json
|
||
[
|
||
{
|
||
"id": 1,
|
||
"name": "ИВТ-21-1",
|
||
"groupSize": 25,
|
||
"educationFormId": 1,
|
||
"educationFormName": "Бакалавриат",
|
||
"departmentId": 1,
|
||
"yearStartStudy": 2023,
|
||
"course": 3,
|
||
"semester": 6,
|
||
"specialtyId": 1,
|
||
"specialtyCode": "09.03.04",
|
||
"specialtyName": "Программная инженерия",
|
||
"specialtyProfileId": 2,
|
||
"specialtyProfileName": "Без профиля",
|
||
"specialityCode": 1,
|
||
"status": "ACTIVE",
|
||
"active": true,
|
||
"studyState": "ACTIVE",
|
||
"studyStateName": "Активна"
|
||
}
|
||
]
|
||
```
|
||
|
||
### `GET /api/groups/{departmentId}`
|
||
|
||
Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
|
||
|
||
### `POST /api/groups`
|
||
|
||
Создание группы.
|
||
|
||
```json
|
||
{
|
||
"name": "ИВТ-11",
|
||
"groupSize": 12,
|
||
"educationFormId": 1,
|
||
"departmentId": 1,
|
||
"yearStartStudy": 2026,
|
||
"specialtyId": 1,
|
||
"specialtyProfileId": 2
|
||
}
|
||
```
|
||
|
||
`groupSize`, `yearStartStudy` и все связанные идентификаторы должны быть положительными;
|
||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как
|
||
legacy-alias для старых клиентов и исторически содержит ID записи из
|
||
`/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже
|
||
`0`, если обучение ещё не началось.
|
||
|
||
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
|
||
|
||
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
|
||
|
||
### `PUT /api/groups/{id}`
|
||
|
||
Редактирование группы.
|
||
|
||
```json
|
||
{
|
||
"name": "ИВТ-11",
|
||
"groupSize": 24,
|
||
"educationFormId": 1,
|
||
"departmentId": 1,
|
||
"yearStartStudy": 2026,
|
||
"specialtyId": 1,
|
||
"specialtyProfileId": 2
|
||
}
|
||
```
|
||
|
||
Если у группы уже есть календарные назначения, изменение года начала обучения,
|
||
специальности, профиля или формы обучения повторно проверяется для каждого учебного года.
|
||
Несовместимое изменение возвращает `409 Conflict`; группа и её назначения остаются без
|
||
изменений.
|
||
|
||
Если у группы есть активные подгруппы, `groupSize` нельзя уменьшить ниже суммы их
|
||
`studentCapacity`. Такой запрос отклоняется без изменения группы. Параллельные изменения
|
||
группы и подгрупп сериализуются на backend и проверяются ограничениями PostgreSQL.
|
||
|
||
### `DELETE /api/groups/{id}`
|
||
|
||
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||
|
||
### `POST /api/groups/{id}/restore`
|
||
|
||
Восстановление архивной группы.
|
||
|
||
### Подгруппы группы
|
||
|
||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||
|
||
`studentCapacity` обязателен и должен быть больше нуля. Для одной учебной группы сумма
|
||
численностей активных подгрупп не может превышать численность самой группы. Frontend на
|
||
вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или
|
||
три подгруппы.
|
||
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
|
||
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/subgroups` | Список всех подгрупп |
|
||
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
|
||
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
|
||
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
|
||
| `DELETE` | `/api/groups/{groupId}/subgroups/{id}` | Удалить подгруппу, если она не используется в расписании |
|
||
|
||
**Тело создания/обновления:**
|
||
```json
|
||
{
|
||
"name": "Подгруппа 1",
|
||
"studentCapacity": 12
|
||
}
|
||
```
|
||
|
||
**Ответ:**
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"groupId": 1,
|
||
"groupName": "ИВТ-21-1",
|
||
"name": "Подгруппа 1",
|
||
"studentCapacity": 12
|
||
}
|
||
```
|
||
|
||
### Календарные графики группы
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/groups/{id}/calendar-assignments` | Список назначений графиков группе |
|
||
| `PUT` | `/api/groups/{id}/calendar-assignments` | Создать или заменить назначение на учебный год |
|
||
| `DELETE` | `/api/groups/{id}/calendar-assignments/{assignmentId}` | Удалить назначение |
|
||
|
||
**Тело назначения графика:**
|
||
```json
|
||
{
|
||
"academicYearId": 1,
|
||
"calendarId": 5
|
||
}
|
||
```
|
||
|
||
**Ответ назначения:**
|
||
```json
|
||
{
|
||
"id": 12,
|
||
"groupId": 1,
|
||
"groupName": "ИВТ-21-1",
|
||
"academicYearId": 1,
|
||
"academicYearTitle": "2025-2026",
|
||
"calendarId": 5,
|
||
"calendarTitle": "09.03.04 очная форма 2025-2026",
|
||
"subjects": [
|
||
{
|
||
"id": 44,
|
||
"calendarId": 5,
|
||
"semesterNumber": 1,
|
||
"subjectId": 1,
|
||
"subjectName": "Высшая математика",
|
||
"subjectCode": "Б1.О.01",
|
||
"departmentId": 1
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и
|
||
форме обучения, что и группа. Вычисленный курс группы должен находиться в диапазоне
|
||
`1..courseCount`. Сохранение выполняется транзакционно с блокировкой группы, графика,
|
||
учебного года и существующего назначения; конкурентный конфликт возвращает `409 Conflict`.
|
||
|
||
---
|
||
|
||
## Аудитории
|
||
|
||
### `GET /api/classrooms`
|
||
|
||
Список аудиторий с привязанным оборудованием.
|
||
|
||
**Ответ:**
|
||
```json
|
||
[
|
||
{
|
||
"id": 1,
|
||
"name": "101 Ленинская",
|
||
"capacity": 120,
|
||
"building": "Главный корпус",
|
||
"floor": 2,
|
||
"isAvailable": true,
|
||
"equipments": [
|
||
{ "id": 1, "name": "Проектор" },
|
||
{ "id": 4, "name": "Интерактивная доска" }
|
||
]
|
||
}
|
||
]
|
||
```
|
||
|
||
### `POST /api/classrooms`
|
||
|
||
Создание аудитории.
|
||
|
||
```json
|
||
{
|
||
"name": "404 Лаборатория",
|
||
"capacity": 30,
|
||
"building": "Лабораторный корпус",
|
||
"floor": 4,
|
||
"isAvailable": true,
|
||
"equipmentIds": [1, 2, 3]
|
||
}
|
||
```
|
||
|
||
### `PUT /api/classrooms/{id}`
|
||
|
||
Обновление аудитории (partial update).
|
||
|
||
### `DELETE /api/classrooms/{id}`
|
||
|
||
Архивирование аудитории. Архивная аудитория остаётся в историческом расписании, но не выбирается в новых назначениях.
|
||
|
||
### `POST /api/classrooms/{id}/restore`
|
||
|
||
Восстановление архивной аудитории.
|
||
|
||
---
|
||
|
||
## Дисциплины
|
||
|
||
### `GET /api/subjects`
|
||
|
||
Список всех дисциплин.
|
||
|
||
```json
|
||
{
|
||
"name": "Физика",
|
||
"code": null,
|
||
"departmentId": 1
|
||
}
|
||
```
|
||
|
||
### `GET /api/subjects/{departmentId}`
|
||
|
||
Список всех дисциплин привязанных к кафедре.
|
||
|
||
### `POST /api/subjects`
|
||
|
||
```json
|
||
{
|
||
"name": "Физика",
|
||
"code": null,
|
||
"departmentId": 1
|
||
}
|
||
```
|
||
|
||
### `DELETE /api/subjects/{id}`
|
||
|
||
Удаление дисциплины.
|
||
|
||
---
|
||
|
||
## Оборудование
|
||
|
||
### `GET /api/equipments`
|
||
|
||
Список всего оборудования.
|
||
|
||
### `POST /api/equipments`
|
||
|
||
```json
|
||
{ "name": "3D-принтер" }
|
||
```
|
||
|
||
### `DELETE /api/equipments/{id}`
|
||
|
||
Удаление оборудования.
|
||
|
||
---
|
||
|
||
## Формы обучения
|
||
|
||
`GET /api/education-forms` доступен `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT` и
|
||
`SCHEDULE_VIEWER`. Создание `POST /api/education-forms` и удаление
|
||
`DELETE /api/education-forms/{id}` разрешены `ADMIN` и `EDUCATION_OFFICE`; удаление по-прежнему
|
||
отклоняется, если форма используется группой или календарным графиком.
|
||
|
||
### `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
|
||
}
|
||
```
|
||
|
||
Для роли `DEPARTMENT` дисциплина должна принадлежать кафедре текущего пользователя, а у
|
||
преподавателя на текущую дату должна действовать основная или дополнительная связь с этой
|
||
кафедрой.
|
||
|
||
### `DELETE /api/teacher-subjects`
|
||
|
||
```json
|
||
{
|
||
"userId": 2,
|
||
"subjectId": 3
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Управление тенантами (Базы данных)
|
||
|
||
Все endpoints раздела доступны только пользователю с ролью `ADMIN`.
|
||
|
||
### `GET /api/database/status`
|
||
|
||
Статус текущего подключения (определяется по домену запроса).
|
||
|
||
**Ответ:**
|
||
```json
|
||
{
|
||
"tenant": "default",
|
||
"connected": true,
|
||
"configured": true,
|
||
"name": "Default",
|
||
"url": "jdbc:postgresql://db:5432/app_db"
|
||
}
|
||
```
|
||
|
||
### `GET /api/database/tenants`
|
||
|
||
Список всех тенантов. Пароль в ответ не включается.
|
||
|
||
```json
|
||
[
|
||
{
|
||
"name": "СВФУ",
|
||
"domain": "swsu",
|
||
"url": "jdbc:postgresql://db-host:5432/swsu_db",
|
||
"username": "dbuser",
|
||
"connected": true
|
||
}
|
||
]
|
||
```
|
||
|
||
### `POST /api/database/tenants`
|
||
|
||
Создание нового или обновление существующего тенанта по `domain`.
|
||
|
||
```json
|
||
{
|
||
"name": "СВФУ",
|
||
"domain": "swsu",
|
||
"url": "jdbc:postgresql://db-host:5432/swsu_db",
|
||
"username": "dbuser",
|
||
"password": "dbpass"
|
||
}
|
||
```
|
||
|
||
`domain` приводится к нижнему регистру и должен быть одной DNS-меткой длиной от 1 до
|
||
63 символов. `url` обязателен и должен начинаться с `jdbc:`. Если `name` пуст, вместо
|
||
него используется нормализованный `domain`.
|
||
|
||
**Логика:**
|
||
1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию
|
||
tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает `503`.
|
||
2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
|
||
3. Открывает соединение и явно проверяет его готовность.
|
||
4. Выполняет Flyway-валидацию и миграции tenant-БД.
|
||
5. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
|
||
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
|
||
6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
|
||
retry/backoff; неизменившаяся конфигурация не записывается повторно.
|
||
7. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
|
||
8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
|
||
либо по истечении защитного таймаута.
|
||
9. Возвращает внутри backend `TenantLifecycleMutationResult` с подтверждённой
|
||
`TenantSecretUpdateReceipt` для согласования mounted-проекции.
|
||
|
||
Backend соединяется с Kubernetes API только через проверенный service-account CA и
|
||
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
|
||
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
|
||
Kubernetes updater.
|
||
|
||
При ошибке credentials, проверки соединения, Flyway или сохранения Secret временный pool
|
||
закрывается, а прежнее подключение продолжает обслуживать запросы. Если Kubernetes
|
||
подтвердил запись, но последующая локальная активация завершилась ошибкой, backend
|
||
восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой записи.
|
||
Более новое изменение другого pod не перезаписывается. Неопределённый сетевой результат
|
||
сначала сверяется повторным чтением и не создаёт основания для небезопасной компенсации.
|
||
Ошибка lifecycle возвращается как безопасный русский ответ без JDBC/Flyway details:
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"message": "Не удалось выполнить миграции базы данных тенанта"
|
||
}
|
||
```
|
||
|
||
HTTP-статусы: `400` для некорректного payload и `503` для ошибки подключения, миграции,
|
||
персистенции или активации.
|
||
|
||
Вне Kubernetes обновление Secret пропускается, поэтому добавленный через API tenant живёт
|
||
только до перезапуска процесса. Для постоянной локальной конфигурации используется
|
||
неотслеживаемый файл `backend/tenants.json`.
|
||
|
||
### `DELETE /api/database/tenants/{domain}`
|
||
|
||
Удаление тенанта. Backend читает актуальный Secret, удаляет только запрошенный `domain` и
|
||
выполняет условный `PUT` по `resourceVersion`, затем атомарно исключает tenant из локальной
|
||
маршрутизации и передаёт pool на отложенное закрытие. При отказе локального удаления
|
||
компенсация также допускается только для подтверждённой версии и не затирает более новое
|
||
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
|
||
|
||
Для `POST` и `DELETE` baseline готовится до API-мутации под общим lifecycle monitor.
|
||
После успеха backend использует семантические `previousTenants` и `committedTenants` из
|
||
`TenantSecretUpdateReceipt`. Fence создаётся только для реального изменения общего Secret
|
||
(`persisted=true`, `changed=true`): известные старые и промежуточные снимки временно
|
||
откладываются, ожидаемый committed-снимок применяется полностью. Persisted no-op и локальная
|
||
операция fence не создают, а неизвестный merged snapshot другого pod синхронизируется сразу.
|
||
SHA-256 файла подтверждается только после полного успешного sync; ошибки повторяются с
|
||
экспоненциальной задержкой от 30 до 300 секунд.
|
||
|
||
### `POST /api/database/test`
|
||
|
||
Тест подключения к произвольной БД (без регистрации тенанта).
|
||
|
||
```json
|
||
{
|
||
"url": "jdbc:postgresql://host:5432/testdb",
|
||
"username": "user",
|
||
"password": "pass"
|
||
}
|
||
```
|
||
|
||
**Ответ:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"message": "Подключение успешно!"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Коды ответов
|
||
|
||
| Код | Описание |
|
||
|-----|----------|
|
||
| `200` | Успех |
|
||
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||
| `401` | Неверные учётные данные |
|
||
| `403` | Недостаточно прав для операции |
|
||
| `404` | Ресурс / тенант не найден |
|
||
| `409` | Конфликт бизнес-инвариантов или конкурентного изменения |
|
||
| `500` | Внутренняя ошибка сервера |
|
||
| `503` | Внешняя БД или обязательная инфраструктура временно недоступна |
|