docs: план, ADR и контракты групп и курсов

This commit is contained in:
2026-10-26 15:47:01 +03:00
parent 920d96a0f5
commit cde04487e8
6 changed files with 945 additions and 18 deletions

View File

@@ -0,0 +1,285 @@
# 09. Контракт API: профиль студента курса
**Статус:** контракт этапа 0, реализация — этапы 3 и 5.
**Связанные документы:** [07-groups-courses.md](07-groups-courses.md),
[08-groups-courses-plan.md](08-groups-courses-plan.md),
[ADR 0001](adr/0001-groups-courses-foundation.md).
## 1. Назначение
Преподаватель своего курса открывает профиль студента и видит:
- статистику по лабораторным курса;
- комментарии преподавателей по каждой лабе;
- все попытки сдачи и может скачать файлы любой попытки.
Профиль ограничен одним курсом. Он не заменяет ведомость и не даёт доступа
к другим курсам или чужим сдачам.
## 2. Авторизация и скоуп
| Роль | Доступ |
|---|---|
| `ADMIN` | любой студент любого курса |
| `TEACHER` | только активный преподаватель курса (`course_teachers.removed_at IS NULL`) и только студент группы, привязанной к курсу (`course_groups.removed_at IS NULL`) |
| `STUDENT` | только свой профиль и только опубликованные оценки/комментарии (опционально) |
| Аноним | нет |
Проверки выполняются на сервисах:
1. JWT валиден, роль допустима.
2. Курс существует и не архивирован (для преподавателя).
3. Для `TEACHER` есть активная запись в `course_teachers`.
4. Студент существует, его аккаунт не архивный.
5. Группа студента привязана к курсу (`course_groups.removed_at IS NULL`).
6. Для скачивания сдача принадлежит этому студенту и лабе этого курса.
Чужой курс, студент или сдача → `403`/`404` без раскрытия данных.
## 3. Основной endpoint
```http
GET /api/v1/courses/{courseId}/students/{studentId}/profile
Authorization: Bearer <JWT>
```
Параметры:
- `courseId` — UUID курса;
- `studentId` — UUID аккаунта студента (`students.account_id`).
Ответ `200 application/json`:
```json
{
"course": {
"id": "3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e",
"courseNo": 1,
"title": "Промышленное программирование на языке C"
},
"student": {
"id": "7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"studentNo": "123456",
"fullName": "Иванов Иван Иванович",
"groupId": "11111111-2222-3333-4444-555555555555",
"groupName": "ПИ-21БО",
"status": "ACTIVE",
"enrollmentYear": 2025
},
"summary": {
"labsTotal": 8,
"submitted": 6,
"graded": 5,
"averageScore": 4.2,
"defensesTotal": 5,
"defensesReady": 3
},
"labs": [
{
"labId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"number": 1,
"title": "Введение в C",
"deadlineAt": "2026-10-01T20:59:00Z",
"variant": {
"id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"number": "3",
"title": "Вариант 3"
},
"level": {
"id": "cccccccc-dddd-eeee-ffff-000000000000",
"code": "base",
"title": "Базовый"
},
"attemptsCount": 2,
"lastSubmissionAt": "2026-09-28T18:12:00Z",
"grade": {
"score": 4.5,
"published": true,
"generalComment": "Хорошая работа, но есть замечания по стилю.",
"updatedBy": {
"id": "dddddddd-eeee-ffff-0000-111111111111",
"name": "Петров Пётр Петрович"
}
},
"comments": [
{
"id": "eeeeeeee-ffff-0000-1111-222222222222",
"text": "Разделите функцию на части.",
"tone": "neutral",
"author": {
"id": "dddddddd-eeee-ffff-0000-111111111111",
"name": "Петров Пётр Петрович"
},
"createdAt": "2026-09-29T09:30:00Z"
}
],
"defense": {
"sessionId": "ffffffff-0000-1111-2222-333333333333",
"status": "ready",
"readyReason": "timeout",
"code": null,
"assignmentMd": "Дополнительное задание..."
},
"submissions": [
{
"id": "00000000-1111-2222-3333-444444444444",
"seq": 1,
"createdAt": "2026-09-27T16:00:00Z",
"files": ["main.c", "Makefile"],
"downloadUrl": "/api/v1/courses/3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e/submissions/00000000-1111-2222-3333-444444444444/download"
},
{
"id": "00000000-5555-6666-7777-888888888888",
"seq": 2,
"createdAt": "2026-09-28T18:12:00Z",
"files": ["main.c", "Makefile", "README.md"],
"downloadUrl": "/api/v1/courses/3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e/submissions/00000000-5555-6666-7777-888888888888/download"
}
]
}
]
}
```
Правила:
- `labs` — все лабы курса для преподавателя/админа; для студента — только
опубликованные.
- `grade` и `comments` для студента отдаются только если оценка опубликована
(`grade_publications` / `grade_publication_students`).
- `comments` включает общий комментарий? Нет: общий комментарий — поле
`grade.generalComment`; отдельные комментарии — массив `comments`.
- `author` может быть `null`, если автор не сохранён (старые данные).
- `averageScore` — среднее по выставленным оценкам, `null` если оценок нет.
- `submissions` отсортированы по `seq`; файлы — имена без пути.
- `downloadUrl` относительный; web-BFF добавляет авторизацию.
## 4. Скачивание попытки
```http
GET /api/v1/courses/{courseId}/submissions/{submissionId}/download
Authorization: Bearer <JWT>
```
- Возвращает ZIP со всеми файлами попытки.
- Проверки: сдача принадлежит студенту и лабе курса; преподаватель активен в
курсе; админ — всегда.
- Имя файла: `submission-{labId-prefix}-try{seq}.zip`.
- Ошибки: `403` — чужая сдача/курс, `404` — сдача не найдена.
Опционально:
```http
GET /api/v1/courses/{courseId}/submissions/{submissionId}/files/{filename}
```
- Отдаёт один файл.
- Имя проверяется на path traversal; файл должен быть в каталоге попытки.
## 5. Внутренние эндпоинты
Не маршрутизируются gateway, защищены `X-Internal-Token` и пользовательским JWT
там, где он нужен.
### identity
```http
GET /api/v1/internal/students/{studentId}/context
```
```json
{
"accountId": "7a1b2c3d-...",
"role": "STUDENT",
"accountStatus": "ACTIVE",
"studentStatus": "ACTIVE",
"studentNo": "123456",
"fullName": "Иванов Иван Иванович",
"groupId": "11111111-...",
"groupName": "ПИ-21БО",
"enrollmentYear": 2025
}
```
### academic
```http
GET /api/v1/internal/courses/{courseId}/access?accountId={accountId}&groupId={groupId}
```
```json
{
"courseId": "3f2a7c1e-...",
"courseStatus": "ACTIVE",
"teacherActive": true,
"groupAttached": true
}
```
```http
GET /api/v1/internal/courses/{courseId}/labs
```
Возвращает лабы курса с `id`, `number`, `title`, `deadlineAt`, `status`,
`pointsMax`, `variant/level` при необходимости.
## 6. Ошибки
| Код | Когда |
|---|---|
| `401` | нет/просрочен JWT |
| `403` | роль не подходит, чужой курс, преподаватель не активен, студент не в группе курса |
| `404` | курс, студент, лаба или сдача не найдены |
| `409` | профиль недоступен из-за конфликта данных (например, студент без группы) |
Тело ошибки — общий `ApiError` проекта.
## 7. Производительность
- Один агрегирующий запрос на профиль, без N+1.
- Пакетная выборка по списку `labId`:
- оценки — `lab_grades` по `student_id` и `lab_id IN (...)`;
- комментарии — `grade_comments` по `student_id` и `lab_id IN (...)`;
- сдачи — `submissions` по `student_id` и `lab_id IN (...)`;
- защиты — `defense_sessions` по `student_id` и `lab_id IN (...)`;
- публикации — `grade_publications`/`grade_publication_students` по группе и лабам.
- Список лаб курса кэшируется на время запроса.
- Для больших курсов — пагинация попыток (по умолчанию все, но с лимитом).
- Индексы: `submissions(student_id, lab_id, seq)` уже уникален;
`grade_comments(student_id, lab_id)` уже есть; `lab_grades(lab_id)` добавлен;
`defense_sessions(student_id, lab_id)` уникален.
## 8. UI
- В ведомости группы ФИО — ссылка на профиль.
- В колонке действий — кнопка «Профиль».
- Маршрут преподавателя: `/teacher/courses/{courseNo}/students/{studentId}`.
- Админ: `/admin/courses/{courseNo}/students/{studentId}` или общий маршрут
с проверкой роли.
- В профиле:
- карточка студента;
- сводка;
- таблица лаб;
- блок комментариев;
- список попыток с кнопками скачивания.
- Статус защиты: «готова» / «кончилось время» (timeout).
## 9. Тесты
- `ADMIN` видит любого студента.
- `TEACHER` своего курса — `200`; чужого курса — `403`.
- `TEACHER` чужого студента — `403`.
- `STUDENT` свой профиль — `200`; чужой — `403`.
- Аноним — `401`.
- Скачивание своей попытки — `200`; чужой — `403`.
- Студент не видит неопубликованные оценки/комментарии.
- Все попытки возвращаются, а не только последняя.
- Комментарии содержат автора или `null`.
- Запрос не делает N+1 (проверка по числу SQL-запросов в интеграционном тесте).
## 10. Открытые вопросы
- Показывать ли профиль для архивных/отвязанных студентов (история или `404`).
- Нужен ли студенческий просмотр своего профиля в первом релизе.
- Формат аудита скачиваний: общий `audit_events` или отдельная таблица.
- Ограничение количества попыток в ответе и пагинация.