# Backend UniVerse Этот документ помогает быстро вкатиться в backend UniVerse: понять слои, основные сущности, API, фоновые процессы, интеграции и точки расширения. Диаграммы написаны в Mermaid, поэтому их можно смотреть прямо в IDE/Markdown-просмотрщике с поддержкой Mermaid. ## Что делает backend Backend UniVerse обслуживает платформу открытых лекций: - хранит пользователей, роли, профили студентов и преподавателей; - ведет каталог курсов, тегов, локаций и лекций; - позволяет студентам записываться на лекции и оставлять отзывы; - анализирует отзывы через LLM в фоне; - начисляет монеты, XP и достижения; - отправляет и планирует уведомления; - синхронизирует расписание и аудитории из Modeus; - публикует REST API и Swagger/OpenAPI. Основной solution: [`backend/UniVerse.sln`](../backend/UniVerse.sln). ## Быстрый старт для чтения кода Начинай с этих файлов: 1. [`backend/UniVerse.Api/Program.cs`](../backend/UniVerse.Api/Program.cs) - composition root: DI, middleware, auth, Swagger, фоновые сервисы. 2. [`backend/UniVerse.Api/Controllers`](../backend/UniVerse.Api/Controllers) - HTTP API и роли доступа. 3. [`backend/UniVerse.Application/Interfaces`](../backend/UniVerse.Application/Interfaces) - контракты бизнес-сервисов. 4. [`backend/UniVerse.Infrastructure/Services`](../backend/UniVerse.Infrastructure/Services) - реализация сценариев. 5. [`backend/UniVerse.Domain/Entities`](../backend/UniVerse.Domain/Entities) - доменная модель. 6. [`backend/UniVerse.Infrastructure/Data/AppDbContext.cs`](../backend/UniVerse.Infrastructure/Data/AppDbContext.cs) и [`Configurations`](../backend/UniVerse.Infrastructure/Data/Configurations) - схема PostgreSQL. ## Проекты и ответственность ```mermaid flowchart LR Client[Frontend / API client] Api[UniVerse.Api
Controllers, middleware, hosted services] App[UniVerse.Application
DTO, interfaces, mappings] Infra[UniVerse.Infrastructure
EF Core, services, external clients] Domain[UniVerse.Domain
Entities, enums, exceptions] Db[(PostgreSQL)] Llm[OpenAI-compatible LLM] Modeus[Modeus / schedule.rdcenter.ru] Email[SMTP] Client --> Api Api --> App Api --> Infra Infra --> App Infra --> Domain App --> Domain Infra --> Db Infra --> Llm Infra --> Modeus Infra --> Email ``` | Проект | Что внутри | Зависит от | | --- | --- | --- | | `UniVerse.Api` | ASP.NET Core Web API: контроллеры, JWT, CORS, Swagger, middleware, background services | `Application`, `Infrastructure`, `ServiceDefaults` | | `UniVerse.Application` | DTO, интерфейсы сервисов, mapping extensions | `Domain` | | `UniVerse.Domain` | Entities, enums, доменные исключения | ничего | | `UniVerse.Infrastructure` | EF Core, PostgreSQL, сервисы, LLM/Modeus/SMTP-клиенты, Quartz scheduler | `Domain`, `Application` | | `UniVerse.AppHost` | .NET Aspire host для совместного запуска API и frontend dev server | API и frontend как внешняя команда | | `UniVerse.Api.Tests` | xUnit-тесты авторизации, Swagger и геймификации | API | ## Жизненный цикл HTTP-запроса ```mermaid sequenceDiagram participant C as Client participant M as ASP.NET middleware participant Auth as JWT auth participant Ctrl as Controller participant Svc as Application interface / Infrastructure service participant Db as AppDbContext C->>M: HTTP request /api/v1/... M->>M: RequestLoggingMiddleware M->>M: ExceptionHandlingMiddleware M->>Auth: UseAuthentication / UseAuthorization Auth-->>Ctrl: ClaimsPrincipal with roles Ctrl->>Svc: DTO/request + current user id Svc->>Db: EF Core query/command Db-->>Svc: entities Svc-->>Ctrl: DTO/result Ctrl-->>C: JSON response ``` Пайплайн в [`Program.cs`](../backend/UniVerse.Api/Program.cs): - `RequestLoggingMiddleware` пишет входящие запросы. - `ExceptionHandlingMiddleware` переводит доменные исключения в `application/problem+json`. - CORS берет origins из `Cors:Origins`. - JWT Bearer проверяет issuer, audience, lifetime и signing key. - Swagger доступен в `Development` по `/api/docs`, JSON - `/api/docs/v1/swagger.json`. ## Конфигурация Основные секции: | Секция | Назначение | | --- | --- | | `ConnectionStrings:DefaultConnection` | подключение к PostgreSQL | | `Jwt:*` | issuer, audience, secret, TTL access/refresh токенов | | `AzureAd:*` | Microsoft Entra ID OAuth flow | | `Cors:Origins` | разрешенные origins frontend | | `Llm:*` | OpenAI-compatible endpoint, API key, model | | `ModeusApi:*` | endpoint и ключ внешнего расписания | | `Email:Smtp:*` | SMTP-провайдер уведомлений | | `Gamification:XpThresholds` | пороги уровней по XP | | `Aspire:Enabled` | включает service defaults при запуске через AppHost | Для окружений удобнее задавать переменные в формате `Section__Key`, например: ```bash ConnectionStrings__DefaultConnection="Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres" Jwt__Secret="local-secret-at-least-32-characters" Llm__ApiKey="..." ModeusApi__ApiKey="..." Email__Smtp__Host="smtp.example.com" ``` Важно: секреты не должны попадать в документацию, README, коммиты и логи. В dev-файлах могут быть локальные значения, но для реального запуска используй переменные окружения или secret storage. ## Как запустить backend Из корня репозитория: ```bash cd backend dotnet restore UniVerse.sln dotnet ef database update --project UniVerse.Infrastructure --startup-project UniVerse.Api dotnet run --project UniVerse.Api --launch-profile http ``` По launch profile API слушает `http://localhost:5019`, Swagger UI: `http://localhost:5019/api/docs`. Тесты: ```bash cd backend dotnet test UniVerse.sln ``` Docker compose есть в корне: - [`docker-compose-test.yml`](../docker-compose-test.yml) - сборка локальных образов; - [`docker-compose-prod.yml`](../docker-compose-prod.yml) - запуск published images. ## Авторизация и роли Роли заданы enum `UserRole`: `Student`, `Teacher`, `Admin`. Токены: - access token - JWT Bearer, передается в `Authorization: Bearer `; - refresh token - HttpOnly cookie `refreshToken`; - logout отзывает refresh token в БД, но уже выданный access token живет до истечения TTL. Вход: ```mermaid sequenceDiagram participant U as User browser participant F as Frontend participant A as UniVerse.Api participant MS as Microsoft Entra ID participant DB as PostgreSQL U->>F: Нажимает "Войти" F->>A: GET /api/v1/auth/login/microsoft?returnUrl=... A->>A: Генерирует state и cookie A-->>U: 302 redirect на Microsoft U->>MS: OAuth authorize MS-->>A: GET /api/v1/auth/callback/microsoft?code&state A->>A: Проверяет state A->>MS: Обменивает code на token A->>DB: Upsert user, roles, refresh token A-->>U: refreshToken cookie + redirect с access token во fragment ``` Dev-вход доступен только в `Development`: ```http POST /api/v1/auth/login/dev Content-Type: application/json { "email": "student@example.com", "displayName": "Student", "roles": ["Student"] } ``` ## Карта API Базовый префикс: `/api/v1`. | Область | Endpoint | Доступ | Назначение | | --- | --- | --- | --- | | Auth | `POST /auth/login/microsoft` | public | обмен authorization code на токены | | Auth | `GET /auth/login/microsoft` | public | server-driven redirect на Microsoft | | Auth | `GET /auth/callback/microsoft` | public | OAuth callback | | Auth | `POST /auth/login/dev` | public, только Development | dev login | | Auth | `POST /auth/refresh` | public + refresh cookie | обновить access token | | Auth | `POST /auth/logout` | auth | отозвать refresh token | | Auth | `GET /auth/me` | auth | текущий пользователь | | Users | `GET /users` | Admin | список пользователей | | Users | `GET /users/{id}` | auth | профиль пользователя | | Users | `PUT /users/{id}` | владелец или Admin | обновить `displayName`, `avatarUrl` | | Users | `GET /users/{id}/stats` | auth | XP, уровень, монеты, посещения | | Users | `GET /users/{id}/reviews` | auth | отзывы пользователя | | Users | `GET /users/{id}/achievements` | auth | достижения пользователя | | Users | `GET /users/{id}/transactions` | владелец или Admin | история монет | | Users | `PATCH /users/{id}/role` | Admin | заменить набор ролей | | Users | `PATCH /users/{id}/active` | Admin | активировать/деактивировать пользователя | | Courses | `GET /courses` | auth | список курсов с фильтрами | | Courses | `GET /courses/{id}` | auth | курс с тегами | | Courses | `POST /courses` | Admin | создать курс | | Courses | `PUT /courses/{id}` | Admin | обновить курс | | Courses | `DELETE /courses/{id}` | Admin | удалить курс | | Courses | `POST /courses/{id}/tags` | Admin | привязать тег | | Courses | `DELETE /courses/{id}/tags/{tagId}` | Admin | отвязать тег | | Lectures | `GET /lectures` | auth | каталог лекций | | Lectures | `GET /lectures/{id}` | auth | детальная карточка | | Lectures | `POST /lectures` | Admin | создать лекцию | | Lectures | `PUT /lectures/{id}` | Admin, Teacher | обновить лекцию | | Lectures | `DELETE /lectures/{id}` | Admin | удалить лекцию | | Lectures | `POST /lectures/{id}/enroll` | Student | записаться | | Lectures | `DELETE /lectures/{id}/enroll` | Student | отменить запись | | Lectures | `PATCH /lectures/{id}/attendance/{userId}` | Admin, Teacher | отметить посещение | | Lectures | `GET /lectures/{id}/enrollments` | Admin, Teacher | записавшиеся | | Lectures | `GET /lectures/{id}/reviews` | auth | отзывы по лекции | | Reviews | `POST /reviews` | Student | создать отзыв | | Reviews | `GET /reviews/{id}` | auth | получить отзыв | | Reviews | `PUT /reviews/{id}` | владелец | обновить отзыв и сбросить LLM-статус | | Reviews | `DELETE /reviews/{id}` | владелец или Admin | удалить отзыв | | Reviews | `GET /reviews/pending` | Admin | очередь LLM-анализа | | Reviews | `POST /reviews/{id}/reanalyze` | Admin | поставить отзыв на повторный анализ | | Tags | `GET /tags` | auth | список тегов | | Tags | `GET /tags/tree` | auth | дерево тегов | | Tags | `GET /tags/{id}` | auth | тег по id | | Tags | `POST /tags` | Admin | создать тег | | Tags | `PUT /tags/{id}` | Admin | обновить тег | | Tags | `DELETE /tags/{id}` | Admin | удалить тег | | Locations | `GET /locations` | auth | список локаций | | Locations | `GET /locations/{id}` | auth | локация по id | | Locations | `POST /locations` | Admin | создать локацию | | Locations | `PUT /locations/{id}` | Admin | обновить локацию | | Locations | `DELETE /locations/{id}` | Admin | удалить локацию | | Achievements | `GET /achievements` | auth | каталог достижений | | Achievements | `GET /achievements/{id}` | auth | достижение по id | | Achievements | `POST /achievements` | Admin | создать достижение | | Achievements | `PUT /achievements/{id}` | Admin | обновить достижение | | Achievements | `DELETE /achievements/{id}` | Admin | удалить достижение | | Notifications | `GET /notifications` | auth | уведомления текущего пользователя | | Notifications | `PATCH /notifications/read-all` | auth | отметить свои уведомления прочитанными | | Notifications | `POST /notifications/send` | Admin | отправить уведомление сразу | | Notifications | `POST /notifications/schedule` | Admin | запланировать уведомление через Quartz | | Sync | `POST /sync/schedule` | Admin | синхронизация лекций из Modeus | | Sync | `POST /sync/rooms` | Admin | синхронизация аудиторий | | Sync | `POST /sync/employees?fullname=...` | Admin | поиск сотрудников | | Sync | `GET /sync/status` | Admin | статус последней синхронизации | ## Доменная модель ```mermaid erDiagram USERS ||--o{ USER_ROLES : has USERS ||--o| STUDENT_PROFILES : may_have USERS ||--o| TEACHER_PROFILES : may_have USERS ||--o{ REFRESH_TOKENS : owns USERS ||--o{ LECTURE_ENROLLMENTS : enrolls USERS ||--o{ REVIEWS : writes USERS ||--o{ USER_ACHIEVEMENTS : earns USERS ||--o{ COIN_TRANSACTIONS : receives USERS ||--o{ USER_NOTIFICATIONS : receives COURSES ||--o{ LECTURES : contains COURSES ||--o{ COURSE_TAGS : tagged TAGS ||--o{ COURSE_TAGS : assigned TAGS ||--o{ TAGS : parent_of LOCATIONS ||--o{ LECTURES : hosts LECTURES ||--o{ LECTURE_ENROLLMENTS : has LECTURES ||--o{ REVIEWS : receives USERS ||--o{ LECTURES : teaches ACHIEVEMENTS ||--o{ USER_ACHIEVEMENTS : awarded_as REVIEWS ||--o{ COIN_TRANSACTIONS : may_reward ACHIEVEMENTS ||--o{ COIN_TRANSACTIONS : may_reward ``` Ключевые таблицы и поля: | Сущность | Смысл | | --- | --- | | `users` | аккаунт: email, display name, avatar, active flag, Microsoft id, XP, coins | | `user_roles` | join table ролей; у пользователя может быть несколько ролей | | `student_profiles`, `teacher_profiles` | дополнительные данные профиля | | `refresh_tokens` | refresh token, expiry, revoked flag | | `courses` | дисциплины/курсы; могут быть синхронизированы из Modeus | | `tags` | иерархические теги с типом: institute/faculty/subject/topic/etc | | `course_tags` | связь many-to-many между курсами и тегами | | `lectures` | открытые лекции: курс, преподаватель, локация, время, формат, вместимость | | `lecture_enrollments` | записи студентов, флаг посещения | | `reviews` | отзывы студентов и результат LLM-анализа | | `achievements` | каталог достижений и условие получения | | `user_achievements` | полученные пользователем достижения | | `coin_transactions` | история начисления монет/XP | | `user_notifications` | in-app уведомления пользователя | EF Core приводит имена таблиц и колонок к `snake_case` в [`AppDbContext`](../backend/UniVerse.Infrastructure/Data/AppDbContext.cs). PostgreSQL enums регистрируются для ролей, типов тегов, LLM-статусов, сентимента и типов транзакций. ## Основные сценарии ### Запись на лекцию ```mermaid sequenceDiagram participant S as Student participant C as LecturesController participant LS as LectureService participant GS as GamificationService participant DB as PostgreSQL S->>C: POST /api/v1/lectures/{id}/enroll C->>LS: EnrollAsync(lectureId, currentUserId) LS->>DB: Load lecture with enrollments LS->>LS: Проверить isOpen, capacity, duplicate LS->>DB: Insert lecture_enrollment LS->>GS: CheckAndAwardAchievementsAsync(userId) GS->>DB: Проверить условия достижений GS-->>C: done C-->>S: 204 No Content ``` Условия: - лекция должна быть открыта (`IsOpen = true`); - если `MaxEnrollments > 0`, число записей не должно превышать лимит; - повторная запись дает `409 Conflict`; - после записи проверяются достижения. ### Посещение лекции `PATCH /api/v1/lectures/{id}/attendance/{userId}` доступен `Admin` и `Teacher`. Сервис меняет `LectureEnrollment.Attended`. Если `attended = true`, запускается проверка достижений. В текущей реализации отдельное начисление монет за посещение не вызывается напрямую, но достижения могут начислить награду. ### Отзыв и LLM-анализ ```mermaid sequenceDiagram participant S as Student participant RC as ReviewsController participant RS as ReviewService participant BG as LlmProcessingBackgroundService participant LLM as LlmAnalysisService participant Client as LlmClient participant G as GamificationService participant DB as PostgreSQL S->>RC: POST /api/v1/reviews RC->>RS: CreateAsync(userId, request) RS->>DB: Insert review with LlmStatus=Pending RS->>G: CheckAndAwardAchievementsAsync(userId) RC-->>S: 201 ReviewDto loop каждые 2 минуты BG->>LLM: ProcessPendingReviewsAsync() LLM->>DB: взять до 10 Pending отзывов LLM->>Client: AnalyzeReviewAsync(text, lecture context) Client-->>LLM: qualityScore, sentiment, tags, isInformative LLM->>DB: сохранить результат, LlmStatus=Analyzed alt отзыв информативный LLM->>G: AwardCoinsAsync(..., 10, ReviewReward) end LLM->>G: CheckAndAwardAchievementsAsync(userId) end ``` LLM-запись хранит: - `LlmStatus`: `Pending`, `Analyzed`, `Rejected`; - `Sentiment`: `Positive`, `Neutral`, `Negative`; - `QualityScore`; - `IsInformative`; - `LlmTags`. Если LLM-клиент падает, статус остается `Pending`, а фоновый сервис попробует снова на следующем цикле. ### Геймификация `GamificationService` делает две вещи: - начисляет монеты и XP через `AwardCoinsAsync`; - проверяет каталог достижений через `CheckAndAwardAchievementsAsync`. Условия достижений хранятся строкой `type:value`, например: | Условие | Что проверяется | | --- | --- | | `first_activity:1` | есть запись, отзыв или посещение | | `lectures_attended:N` | посещено минимум N лекций | | `reviews_written:N` | написано минимум N отзывов | | `lectures_registered:N` | оформлено минимум N записей | | `active_registrations:N` | есть N будущих записей | | `attendance_streak_weeks:N` | посещения N недель подряд | | `coins_earned:N` | получено минимум N положительных монет | | `level_reached:N` | достигнут уровень N | | `profile_completed:1` | заполнены `DisplayName` и `AvatarUrl` | Каталог базовых достижений заполняется при старте через `AchievementCatalogHostedService` и [`AchievementCatalogSeeder`](../backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs). ### Уведомления ```mermaid flowchart LR Admin[Admin API request] Achievement[Achievement earned] NotificationService[NotificationService] Db[(user_notifications)] Provider[INotificationProvider] Email[EmailNotificationProvider / SMTP] Quartz[QuartzNotificationScheduler] Job[NotificationJob] Admin -->|POST /notifications/send| NotificationService Admin -->|POST /notifications/schedule| Quartz Achievement --> NotificationService NotificationService --> Db NotificationService --> Provider Provider --> Email Quartz --> Job Job --> NotificationService ``` Сейчас основной канал - `email`. In-app уведомления хранятся в `user_notifications`; пользователь может получить их через `GET /api/v1/notifications` и отметить все прочитанными через `PATCH /api/v1/notifications/read-all`. ### Синхронизация Modeus ```mermaid flowchart TD Admin[Admin] --> Sync[SyncController] Sync --> Service[ScheduleSyncService] Service --> Modeus[ModeusApiClient] Modeus --> External[schedule.rdcenter.ru] Service --> Courses[(courses)] Service --> Lectures[(lectures)] Service --> Locations[(locations)] Service --> Status[static _lastStatus] ``` `POST /api/v1/sync/schedule`: - запрашивает события из Modeus; - upsert-ит курс по `ExternalId`; - upsert-ит локацию из комнаты события; - upsert-ит лекцию по `ExternalId`; - сохраняет счетчики `created`, `updated`, `skipped`. `POST /api/v1/sync/rooms` импортирует аудитории в `locations`. `POST /api/v1/sync/employees` только ищет сотрудников и не создает пользователей автоматически. Статус последней синхронизации хранится в памяти процесса (`static _lastStatus`), поэтому после рестарта API он снова `idle`. ## Ошибки и ответы Доменные исключения: | Исключение | HTTP | | --- | --- | | `NotFoundException` | `404 Not Found` | | `ForbiddenException` | `403 Forbidden` | | `ConflictException` | `409 Conflict` | | `UnauthorizedAccessException` | `401 Unauthorized` | | неизвестная ошибка | `500 Internal Server Error` | Формат ошибки: ```json { "type": "https://httpstatuses.com/404", "title": "Not Found", "status": 404, "detail": "Lecture with id 10 was not found.", "traceId": "..." } ``` ## Фильтрация и пагинация Общий формат пагинации: ```json { "items": [], "total": 0, "page": 1, "pageSize": 20, "totalPages": 0 } ``` Частые query-параметры: - `page`, `pageSize` - для пагинации; - `search` - текстовый поиск; - `tagId`, `courseId`, `teacherId` - фильтры каталога; - `dateFrom`, `dateTo` - даты лекций; - `format` - `Online` или `Offline`; - `isOpen` - открыта ли запись. ## Swagger и тесты Swagger: - UI: `/api/docs`; - JSON: `/api/docs/v1/swagger.json`; - `AuthorizeOperationFilter` добавляет Bearer security только к защищенным endpoint-ам и дописывает требуемые роли в описание. Тесты: - `EndpointAuthorizationTests` проверяет, что защищенные endpoint-ы возвращают `401` анонимам и `403` неправильным ролям; - `SwaggerDocumentTests` проверяет генерацию OpenAPI и security metadata; - `GamificationServiceTests` проверяет правила достижений/наград. ## Как добавить новый backend-сценарий Обычный путь изменения: 1. Добавить или изменить entity/enum в `UniVerse.Domain`, если меняется модель данных. 2. Добавить DTO в `UniVerse.Application/DTOs`. 3. Добавить контракт в `UniVerse.Application/Interfaces`. 4. Реализовать сервис в `UniVerse.Infrastructure/Services`. 5. Зарегистрировать сервис в DI в `Program.cs`. 6. Добавить endpoint в controller. 7. Если меняется БД, создать EF migration через `dotnet ef`, не редактировать миграции вручную. 8. Добавить тесты авторизации/бизнес-логики. 9. Проверить Swagger JSON и, если нужно, frontend-контракт. Команда миграции: ```bash cd backend dotnet ef migrations add NameOfMigration --project UniVerse.Infrastructure --startup-project UniVerse.Api dotnet ef database update --project UniVerse.Infrastructure --startup-project UniVerse.Api ``` ## Места, на которые стоит обратить внимание - `GET /api/v1/users/{id}/enrollments` сейчас проверяет доступ, но возвращает пустой `200 OK`; полноценная выдача записей пользователя еще не реализована. - `CreatedAtAction` в нескольких `POST` endpoint-ах передает `{ id = 0 }`, хотя созданная сущность уже известна. Если клиенту важен `Location` header, это стоит поправить. - `SyncStatusDto` хранится в памяти процесса, не в БД. - `ReviewLlmStatus.Rejected` есть в enum, но текущий `LlmAnalysisService` не переводит отзывы в rejected. - В `CourseTag`, `LectureEnrollment`, `UserAchievement` entity есть поле `Id`, но `AppDbContext.OnModelCreating` дополнительно задает составные ключи для этих сущностей после применения конфигураций. При изменении модели обязательно сверяй snapshot/миграцию. - `Secure = true` у refresh cookie означает, что для cookie-flow в браузере нужен HTTPS. В локальной разработке через plain HTTP это может влиять на поведение cookie. ## Ментальная модель Если совсем коротко: ```mermaid flowchart LR Auth[Auth: Microsoft/dev login] --> User[User + roles] User --> Catalog[Catalog: courses, tags, locations] Catalog --> Lecture[Lectures] User --> Enrollment[Enrollments] Lecture --> Enrollment Enrollment --> Attendance[Attendance] Lecture --> Review[Reviews] Review --> Llm[LLM analysis] Llm --> Rewards[Coins + XP] Attendance --> Achievements[Achievements] Enrollment --> Achievements Review --> Achievements Rewards --> Achievements Achievements --> Notifications[Notifications] Modeus[Modeus sync] --> Catalog Modeus --> Lecture ``` Почти все пользовательские сценарии проходят через пользователя с ролью, меняют лекции/записи/отзывы, а потом запускают геймификацию. Внешние интеграции - LLM, Modeus и SMTP - изолированы в Infrastructure и подключены через интерфейсы Application.