286 lines
12 KiB
Markdown
286 lines
12 KiB
Markdown
# 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` или отдельная таблица.
|
||
- Ограничение количества попыток в ответе и пагинация.
|