1273 lines
53 KiB
Markdown
1273 lines
53 KiB
Markdown
# 🔌 REST API
|
||
|
||
Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны
|
||
под `/actuator/health/`. Ответы возвращаются в формате JSON.
|
||
|
||
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
|
||
|
||
```json
|
||
{
|
||
"timestamp": "2026-05-27T19:47:54",
|
||
"status": 400,
|
||
"error": "Некорректный запрос",
|
||
"message": "Некорректные параметры запроса",
|
||
"path": "/api/schedule"
|
||
}
|
||
```
|
||
|
||
Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
|
||
|
||
---
|
||
|
||
## Служебные проверки состояния
|
||
|
||
Эти 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
|
||
}
|
||
```
|
||
|
||
> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
|
||
|
||
Поддерживаемые роли: `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` на текущую дату и legacy-привязку `users.department_id`. Роль `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": "Перевод на кафедру ВТ"
|
||
}
|
||
```
|
||
|
||
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
|
||
|
||
Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `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 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/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/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": 90
|
||
}
|
||
```
|
||
|
||
**Создание ручной сетки:**
|
||
```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
|
||
{
|
||
"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`.
|
||
|
||
**Ячейка сетки графика:**
|
||
```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` с
|
||
просьбой уточнить группу или кафедру.
|
||
|
||
Пример:
|
||
|
||
```http
|
||
GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
|
||
```
|
||
|
||
Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`.
|
||
|
||
### Точечные изменения расписания учебного отдела
|
||
|
||
| Метод | URL | Назначение |
|
||
|-------|-----|------------|
|
||
| `GET` | `/api/edu-office/schedule/overrides` | Список точечных изменений |
|
||
| `POST` | `/api/edu-office/schedule/overrides` | Создать перенос, отмену или замену |
|
||
| `PUT` | `/api/edu-office/schedule/overrides/{id}` | Обновить изменение |
|
||
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение |
|
||
|
||
```json
|
||
{
|
||
"baseRuleSlotId": 31,
|
||
"lessonDate": "2026-05-21",
|
||
"action": "REPLACE",
|
||
"newClassroomId": 2,
|
||
"newTeacherId": 5,
|
||
"comment": "Замена аудитории и преподавателя"
|
||
}
|
||
```
|
||
|
||
Правила payload:
|
||
|
||
- `CANCEL` отменяет конкретную пару; поля `newTimeSlotId`, `newClassroomId`,
|
||
`newTeacherId` и `newLessonFormat` должны отсутствовать;
|
||
- `MOVE` требует новый временной слот или аудиторию; преподавателя и формат можно изменить
|
||
в том же запросе;
|
||
- `REPLACE` требует нового преподавателя, аудиторию или формат; временной слот можно
|
||
изменить в том же запросе;
|
||
- формат принимает только `Очно` или `Онлайн`;
|
||
- `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID
|
||
временного слота с тем же интервалом не считается переносом.
|
||
|
||
До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное
|
||
расписание: семестр, календарный график, чётность, активность сущностей и остаток часов.
|
||
Если пара не формируется или нарушена матрица действия, API возвращает `400` с русским
|
||
сообщением. Если результирующее время пересекается с занятым преподавателем, аудиторией,
|
||
группой или той же подгруппой, API возвращает `409 Conflict`; соседние интервалы и разные
|
||
подгруппы одной группы не конфликтуют.
|
||
|
||
## Загруженность
|
||
|
||
| Метод | 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`.
|
||
|
||
Пример:
|
||
|
||
```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` | Расписание кафедры |
|
||
|
||
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
|
||
|
||
`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"
|
||
}
|
||
```
|
||
|
||
При создании специальности автоматически создаётся профиль `Без профиля`.
|
||
|
||
**Тело создания/обновления профиля:**
|
||
```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
|
||
}
|
||
```
|
||
|
||
`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
|
||
}
|
||
```
|
||
|
||
### `DELETE /api/groups/{id}`
|
||
|
||
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||
|
||
### `POST /api/groups/{id}/restore`
|
||
|
||
Восстановление архивной группы.
|
||
|
||
### Подгруппы группы
|
||
|
||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||
|
||
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. 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
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
|
||
|
||
---
|
||
|
||
## Аудитории
|
||
|
||
### `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`
|
||
|
||
Список форм обучения.
|
||
|
||
**Ответ:**
|
||
```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
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Управление тенантами (Базы данных)
|
||
|
||
Все 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. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
|
||
2. Открывает соединение и явно проверяет его готовность.
|
||
3. Выполняет Flyway-валидацию и миграции tenant-БД.
|
||
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
|
||
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
|
||
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
|
||
retry/backoff; неизменившаяся конфигурация не записывается повторно.
|
||
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
|
||
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
|
||
либо по истечении защитного таймаута.
|
||
|
||
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 /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` | Внешняя БД или обязательная инфраструктура временно недоступна |
|