# 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 ``` Параметры: - `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`, по умолчанию не более 50 (`submissionLimit`, максимум 500); `submissionsTotal` — всего попыток, `attemptsCount` — тоже полное число. Файлы — имена без пути. - `downloadUrl` относительный; web-BFF добавляет авторизацию. ## 4. Скачивание попытки ```http GET /api/v1/courses/{courseId}/submissions/{submissionId}/download Authorization: Bearer ``` - Возвращает 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` по группе и лабам. - Список лаб курса кэшируется на время запроса. - Для больших курсов — лимит попыток: `submissionLimit` (по умолчанию 50, максимум 500) и `submissionsTotal` для индикатора «показаны последние N»; `attemptsCount` не зависит от лимита. - Индексы: `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`. - Студент не видит неопубликованные оценки/комментарии. - Все попытки возвращаются (в пределах `submissionLimit`), а не только последняя; `submissionsTotal`/`attemptsCount` показывают полное число. - Профиль на 10 лабораторных делает не более 15 SQL-запросов (без N+1). - Комментарии содержат автора или `null`. - Запрос не делает N+1 (проверка по числу SQL-запросов в интеграционном тесте). ## 10. Открытые вопросы - Показывать ли профиль для архивных/отвязанных студентов (история или `404`). - Нужен ли студенческий просмотр своего профиля в первом релизе. - ~~Формат аудита скачиваний: общий `audit_events` или отдельная таблица.~~ Решено на этапе 6: общий `identity.audit_events` (`V10`). - Ограничение количества попыток в ответе и пагинация — follow-up.