1 Commits

Author SHA1 Message Date
Renovate Bot 7265a9507d chore(deps): update dependency eslint-plugin-vue to ~10.9.0
renovate/artifacts Artifact file update failure
Frontend CI / build-and-check (pull_request) Failing after 12s
Frontend Playwright / e2e (pull_request) Failing after 4m55s
2026-05-31 05:00:48 +00:00
27 changed files with 174 additions and 1036 deletions
+105 -235
View File
@@ -1,295 +1,165 @@
# UniVerse # UniVerse
UniVerse - веб-платформа для открытых межнаправленческих лекций университета. Система помогает студентам находить занятия других направлений, записываться на них, получать напоминания, оставлять отзывы, а преподавателям и администраторам - видеть аналитику посещаемости и качества обратной связи. UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
Проект состоит из Vue 3 frontend, ASP.NET Core backend и PostgreSQL-хранилища. Backend предоставляет REST API, интегрируется с Microsoft Entra ID, Modeus API и OpenAI-compatible LLM, а frontend реализует отдельные сценарии для ролей `Student`, `Teacher` и `Admin`. [Документация API](backend/UniVerse.Api/openapi.json)
[Документация бекнда](docs/backend.md)
- [OpenAPI snapshot](backend/UniVerse.Api/openapi.json) ## Что внутри
- [Backend notes](docs/backend.md)
- [Frontend E2E tests](docs/playwright-tests.md)
- [Load testing](docs/load-testing-k6.md)
## Возможности - Расписание/события и сущности: курсы, лекции, аудитории (locations)
- Отзывы студентов с фоновым LLM-анализом (качество/тональность/теги)
- Геймификация: XP/уровни, монеты, достижения
- JWT-аутентификация и роли (`Admin`, `Teacher`, `Student`)
- Swagger/OpenAPI в Development
### Для студента ## Технологии
- Каталог открытых лекций с поиском, фильтрацией, карточками и деталями занятия. - .NET 10 / ASP.NET Core
- Запись на лекцию и отмена записи с учетом лимитов мест и персонального лимита активных записей. - PostgreSQL + EF Core (Npgsql)
- Личный дашборд: ближайшие лекции, прогресс уровня, XP, монеты, достижения и статистика. - Serilog
- Мои лекции: список записей, скачивание `.ics` для одной лекции или всего расписания, ссылка календарной подписки. - Swagger (Swashbuckle)
- Отзывы о лекциях с оценкой `Like`, `Neutral`, `Dislike`.
- Уведомления и профиль пользователя.
### Для преподавателя ## Структура репозитория
- Дашборд преподавателя по своим занятиям. Код backend лежит в папке `backend/` и собран в solution `backend/UniVerse.sln`:
- Просмотр списка лекций и записей.
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
- Работа с отзывами без раскрытия лишних персональных данных студентам.
### Для администратора - `backend/UniVerse.Api` — Web API (контроллеры, middleware, background services)
- `backend/UniVerse.Application` — DTO, интерфейсы сервисов, маппинги
- Административная панель со статистикой пользователей, лекций, записей и ожидающих LLM-анализа отзывов. - `backend/UniVerse.Domain` — доменные сущности/enum/исключения
- Управление пользователями: роли `Student`, `Teacher`, `Admin`, блокировка и разблокировка аккаунтов. - `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
- Управление лекциями и создание новых занятий.
- Синхронизация расписания, аудиторий и преподавателей из Modeus.
- Модерация отзывов, повторный запуск LLM-анализа и настройка промпта.
- Управление курсами, тегами, локациями и достижениями через API.
### Платформенные функции
- Microsoft Entra ID login и dev-login для локальной разработки.
- JWT access token и refresh flow через cookie.
- Ролевая защита маршрутов frontend и endpoint-level авторизация backend.
- Фоновая очередь анализа отзывов через LLM.
- Геймификация: XP, уровни, монеты, достижения и транзакции.
- Email-уведомления и планировщик Quartz.
- Rate limiting, Serilog, Prometheus metrics и Swagger UI в Development.
- Aspire AppHost для совместного локального запуска API и Vite frontend.
- Docker Compose для production-окружения с backend, frontend и PostgreSQL.
## Архитектура
```mermaid
flowchart LR
Student[Student UI] --> Frontend[Vue 3 frontend]
Teacher[Teacher UI] --> Frontend
Admin[Admin UI] --> Frontend
Frontend -->|/api/v1 JSON| Api[ASP.NET Core Web API]
Api --> Auth[Auth and RBAC]
Api --> App[Application services]
App --> Domain[Domain model]
App --> Infra[Infrastructure]
Infra --> Db[(PostgreSQL)]
Infra --> Llm[OpenAI-compatible LLM]
Infra --> Modeus[Modeus schedule API]
Infra --> Mail[SMTP email]
Api --> Quartz[Quartz jobs]
Quartz --> Mail
Api --> Metrics[Prometheus /metrics]
```
Backend следует Clean Architecture:
- `UniVerse.Api` - controllers, middleware, Swagger, DI, background services.
- `UniVerse.Application` - DTO, interfaces, service contracts, mappings, prompts.
- `UniVerse.Domain` - entities, enums, domain exceptions, domain services.
- `UniVerse.Infrastructure` - EF Core, migrations, external clients, notification and business service implementations.
- `UniVerse.AppHost` - Aspire host для локального запуска API и frontend.
- `UniVerse.Api.Tests` - unit и integration tests.
Frontend построен на Vue 3, TypeScript, Vite, Pinia и Vue Router:
- `src/views/student` - кабинет студента, каталог, карточка лекции, мои лекции, отзывы, профиль, уведомления.
- `src/views/teacher` - кабинет преподавателя, лекции, аналитика.
- `src/views/admin` - дашборд, пользователи, лекции, отзывы.
- `src/api` - typed API client, DTO-типы и мапперы.
- `src/components/ui` и `src/components/layout` - переиспользуемые UI и layout-компоненты.
## Сценарий записи и анализа отзыва
```mermaid
sequenceDiagram
actor S as Student
participant UI as Vue frontend
participant API as UniVerse API
participant DB as PostgreSQL
participant Q as ReviewAnalysisQueue
participant LLM as LLM API
participant G as GamificationService
S->>UI: Выбирает открытую лекцию
UI->>API: POST /api/v1/lectures/{id}/enroll
API->>DB: Проверяет лимиты и сохраняет запись
API-->>UI: 204 No Content
S->>UI: Оставляет отзыв
UI->>API: POST /api/v1/reviews
API->>DB: Сохраняет отзыв со статусом Pending
API->>Q: Ставит отзыв в очередь анализа
Q->>LLM: Анализ качества, тональности и тегов
LLM-->>Q: Результат анализа
Q->>DB: Обновляет отзыв
Q->>G: Начисляет XP, монеты и достижения
G->>DB: Сохраняет транзакции и награды
```
Основные группы таблиц:
- Пользователи и доступ: `users`, `user_roles`, `student_profiles`, `teacher_profiles`, `refresh_tokens`.
- Расписание: `courses`, `lectures`, `locations`, `tags`, `course_tags`.
- Участие и обратная связь: `lecture_enrollments`, `reviews`, `review_prompt_settings`.
- Геймификация: `achievements`, `user_achievements`, `coin_transactions`, `level_thresholds`.
- Уведомления: `user_notifications`.
## API
Базовый префикс: `/api/v1`.
- `/auth` - Microsoft login, dev-login, refresh, logout, текущий пользователь.
- `/users` - профиль, статистика, роли, активность, записи, достижения, транзакции, `.ics`.
- `/lectures` - каталог лекций, CRUD, запись, посещаемость, отзывы по лекции.
- `/reviews` - создание, список, модерация, повторный анализ, настройка LLM-промпта.
- `/courses` - курсы и привязка тегов.
- `/tags` - теги и дерево тегов.
- `/locations` - аудитории и локации.
- `/achievements` - каталог достижений.
- `/notifications` - уведомления, отметка прочтения, отправка и планирование.
- `/sync` - синхронизация расписания, аудиторий и преподавателей из Modeus.
В Development Swagger UI доступен по адресу `http://localhost:5019/api/docs`.
## Стек
### Frontend
- Vue 3, TypeScript, Vite.
- Pinia, Vue Router.
- Playwright E2E tests.
- Nginx для production-раздачи и проксирования.
### Backend
- .NET 10, ASP.NET Core Web API.
- EF Core 10, Npgsql, PostgreSQL.
- Swashbuckle/OpenAPI.
- Serilog, Prometheus, ASP.NET Core Rate Limiting.
- Quartz для отложенных уведомлений.
- Aspire AppHost.
- xUnit, NSubstitute, WebApplicationFactory.
### Интеграции
- Microsoft Entra ID.
- Modeus schedule API.
- OpenAI-compatible LLM API.
- SMTP email.
## Требования ## Требования
- .NET SDK 10.x. - .NET SDK 10 (`dotnet --version` должен показать `10.x`)
- Node.js `^20.19.0 || >=22.12.0`. - PostgreSQL 14+ (или Docker для поднятия Postgres)
- pnpm.
- PostgreSQL 17+ или Docker.
- Для production - Docker Engine и Docker Compose.
## Конфигурация ## Конфигурация
Backend читает настройки из `backend/UniVerse.Api/appsettings.json`, `appsettings.Development.json` и переменных окружения в формате `Section__Key`. Основные настройки лежат в `backend/UniVerse.Api/appsettings.json`:
Основные секции: - `ConnectionStrings:DefaultConnection` — строка подключения к Postgres
- `Jwt:*` — секрет/issuer/audience и сроки жизни токенов
- `Cors:Origins` — origin’ы фронтенда
- `Llm:*` — настройки LLM (OpenAI-compatible)
- `ModeusApi:*` — настройки интеграции с Modeus
- `ConnectionStrings:DefaultConnection` - подключение к PostgreSQL. Можно переопределять через переменные окружения в формате `Section__Key`, например:
- `Jwt:*` - issuer, audience, secret и сроки жизни токенов.
- `AzureAd:*` - Microsoft Entra ID.
- `Cors:Origins` - разрешенные origin frontend.
- `RateLimiting:*` - глобальный fixed-window limiter.
- `Llm:*` - base URL, API key, model и параметры анализа отзывов.
- `ModeusApi:*` - base URL, API key и timeout.
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
Frontend использует переменные: - `ConnectionStrings__DefaultConnection`
- `Jwt__Secret`
- `Llm__ApiKey`
- `ModeusApi__ApiKey`
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`. ## Быстрый старт (локально)
- `VITE_API_PROXY_TARGET` - target для Vite proxy при запуске через Aspire.
- `VITE_AUTH_RETURN_URL` - frontend callback URL, по умолчанию `/auth/callback`.
## Быстрый старт 1) Поднять Postgres (пример через Docker):
### 1. Установить зависимости frontend
```bash
pnpm -C frontend install
```
### 2. Поднять PostgreSQL
```bash ```bash
docker run --rm --name universe-postgres \ docker run --rm --name universe-postgres \
-e POSTGRES_DB=universe \ -e POSTGRES_DB=universe \
-e POSTGRES_USER=postgres \ -e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \ -e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \ -p 5432:5432 \
postgres:18 postgres:18
``` ```
### 3. Применить миграции 2) Применить миграции (первый раз потребуется `dotnet-ef`):
```bash ```bash
dotnet tool install --global dotnet-ef dotnet tool install --global dotnet-ef
cd backend cd backend
dotnet ef database update \ dotnet ef database update \
--project UniVerse.Infrastructure \ --project UniVerse.Infrastructure \
--startup-project UniVerse.Api --startup-project UniVerse.Api
``` ```
### 4. Запустить backend 3) Запустить API:
```bash ```bash
dotnet run --project backend/UniVerse.Api --launch-profile http cd backend
dotnet run --project UniVerse.Api --launch-profile http
``` ```
API по умолчанию слушает `http://localhost:5019`. По умолчанию (профиль `http`) API поднимется на `http://localhost:5019`.
Swagger UI доступен в Development по адресу: `http://localhost:5019/swagger`.
### 5. Запустить frontend ## Запуск в Docker
В `backend/UniVerse.Api/Dockerfile` настроена сборка контейнера API.
```bash ```bash
pnpm -C frontend dev cd backend
docker build -f UniVerse.Api/Dockerfile -t universe-api .
docker run --rm -p 8080:8080 \
-e ASPNETCORE_URLS=http://+:8080 \
-e ConnectionStrings__DefaultConnection="Host=host.docker.internal;Port=5432;Database=universe;Username=postgres;Password=postgres" \
universe-api
``` ```
Vite frontend по умолчанию слушает `http://localhost:5173` и проксирует `/api` на `http://localhost:5019`. Примечание: на Linux `host.docker.internal` может быть недоступен — проще запускать Postgres тоже в Docker и соединять контейнеры в одной сети.
## Запуск через Aspire ## Аутентификация
Aspire AppHost запускает API и Vite frontend вместе: - `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
- `GET /api/v1/auth/login/microsoft` — старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft).
- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`.
- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`.
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
```bash Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
pnpm -C frontend install
dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj
```
Frontend обычно доступен на `http://localhost:5173`. Target API передается во frontend через `VITE_API_PROXY_TARGET`. Большинство методов API защищены `[Authorize]`.
## Docker Compose ## Фоновый LLM-анализ отзывов
Production compose описан в `docker-compose-prod.yml`: Сервис `LlmProcessingBackgroundService` раз в ~2 минуты берёт отзывы со статусом `Pending` и прогоняет через LLM-клиент.
LLM-ключ задаётся через `Llm:ApiKey`.
- `app` - ASP.NET Core backend. Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
- `frontend` - собранный Vue frontend и Nginx.
- `db` - PostgreSQL.
Перед запуском задайте переменные окружения для PostgreSQL, JWT, Microsoft auth, CORS и внешних интеграций: ## Интеграция с Modeus
```bash Эндпоинты синхронизации доступны только администратору:
docker compose -f docker-compose-prod.yml up -d
```
Тестовый compose находится в `docker-compose-test.yml`. - `POST /api/v1/sync/schedule`
- `POST /api/v1/sync/rooms`
- `POST /api/v1/sync/employees`
- `GET /api/v1/sync/status`
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
## Карта API (high-level)
Базовый префикс: `/api/v1`.
- `/auth` — логин/refresh/logout/me
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
- `/lectures` — лекции, записи, посещаемость, отзывы
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
- `/tags` — теги + дерево тегов
- `/locations` — аудитории/локации
- `/achievements` — достижения
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
Точные схемы запросов/ответов удобнее смотреть в Swagger.
## Тестирование ## Тестирование
Backend: В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
- **xUnit** в качестве основного фреймворка для тестирования.
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
Запуск тестов:
```bash ```bash
dotnet test backend/UniVerse.sln cd backend
dotnet test
``` ```
Frontend type-check и production build:
```bash
pnpm -C frontend build
```
Frontend E2E с mock API:
```bash
pnpm -C frontend test:e2e
```
Load testing helper:
```bash
node frontend/scripts/loadtest-endpoints.js
```
@@ -47,9 +47,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
body: """{"displayName":"Test","avatarUrl":null}"""); body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student"); yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student"); yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
yield return E("users/me/enrollments/calendar-subscription [AnyAuth]", "GET", "api/v1/users/me/enrollments/calendar-subscription", "Student");
yield return E("users/me/enrollments.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments.ics", "Student");
yield return E("users/me/enrollments/{id}.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments/1.ics", "Student");
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student"); yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student"); yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student"); yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
@@ -195,7 +192,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
// dev login доступен в окружении Development // dev login доступен в окружении Development
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev", yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" }; """{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
yield return new object[] { "users/calendar/enrollments/{token}.ics GET", "GET", "api/v1/users/calendar/enrollments/bad-token.ics" };
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации // refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge) // (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется // Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
@@ -20,7 +20,6 @@ using UniVerse.Application.DTOs.Tags;
using UniVerse.Application.DTOs.Users; using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums; using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.Tests.Helpers; namespace UniVerse.Api.Tests.Helpers;
@@ -178,13 +177,6 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
3, 3,
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)])); [new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures); stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
stub.GetMyEnrollmentsIcsAsync(Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
stub.GetEnrollmentIcsAsync(Arg.Any<int>(), Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
stub.GetCalendarSubscriptionTokenAsync(Arg.Any<int>()).Returns("test-token");
stub.GetEnrollmentsIcsBySubscriptionTokenAsync("bad-token")
.Returns(Task.FromException<string>(new ForbiddenException("Invalid calendar subscription token.")));
stub.GetEnrollmentsIcsBySubscriptionTokenAsync(Arg.Is<string>(token => token != "bad-token"))
.Returns(Task.FromResult("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"));
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers); stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask); stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask); stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
@@ -1,37 +0,0 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.RateLimiting;
public class RateLimitingTests
{
[Fact]
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
{
await using var factory = new ApiWebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["RateLimiting:PermitLimit"] = "1",
["RateLimiting:WindowSeconds"] = "60",
["RateLimiting:QueueLimit"] = "0"
});
});
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
using var firstResponse = await client.GetAsync("api/v1/tags");
using var secondResponse = await client.GetAsync("api/v1/tags");
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
}
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.DTOs.Notifications;
@@ -194,56 +193,6 @@ public class UserServiceTests
Assert.Equal(2, Assert.Single(result.Items).Id); Assert.Equal(2, Assert.Single(result.Items).Id);
} }
[Fact]
public async Task CalendarSubscriptionToken_Roundtrip_ReturnsUserEnrollmentsIcs()
{
await using var db = CreateDbContext();
var startsAt = new DateTime(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
await db.SaveChangesAsync();
var service = CreateService(db);
var token = await service.GetCalendarSubscriptionTokenAsync(1);
var ics = await service.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
Assert.Contains("BEGIN:VCALENDAR", ics);
Assert.Contains("Lecture 1", ics);
}
[Fact]
public async Task CalendarSubscriptionToken_RejectsTamperedToken()
{
await using var db = CreateDbContext();
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
await db.SaveChangesAsync();
var service = CreateService(db);
var token = await service.GetCalendarSubscriptionTokenAsync(1);
var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetEnrollmentsIcsBySubscriptionTokenAsync(tampered));
}
[Fact]
public async Task GetEnrollmentIcsAsync_ReturnsLectureIcsWithoutEnrollment()
{
await using var db = CreateDbContext();
var startsAt = new DateTime(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1853, startsAt));
await db.SaveChangesAsync();
var service = CreateService(db);
var ics = await service.GetEnrollmentIcsAsync(1, 1853);
Assert.Contains("BEGIN:VCALENDAR", ics);
Assert.Contains("Lecture 1853", ics);
}
private static AppDbContext CreateDbContext() private static AppDbContext CreateDbContext()
{ {
var options = new DbContextOptionsBuilder<AppDbContext>() var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -266,13 +215,7 @@ public class UserServiceTests
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance); var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
var config = new ConfigurationBuilder() return new UserService(db, gamification);
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-calendar-subscription-secret-32chars"
})
.Build();
return new UserService(db, gamification, config);
} }
private static void SeedLevelThresholds(AppDbContext db) private static void SeedLevelThresholds(AppDbContext db)
@@ -5,7 +5,6 @@ using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums; using UniVerse.Domain.Enums;
using System.Security.Claims; using System.Security.Claims;
using System.Text;
namespace UniVerse.Api.Controllers; namespace UniVerse.Api.Controllers;
@@ -84,52 +83,6 @@ public class UsersController : ControllerBase
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) => public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination)); Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
[HttpGet("me/enrollments/calendar-subscription")]
[ProducesResponseType(typeof(CalendarSubscriptionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<CalendarSubscriptionDto>> CalendarSubscription()
{
var token = await _users.GetCalendarSubscriptionTokenAsync(CurrentUserId);
var feedUrl = Url.Action(
nameof(CalendarEnrollmentsIcs),
null,
new { token },
Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}/api/v1/users/calendar/enrollments/{token}.ics";
return Ok(new CalendarSubscriptionDto(feedUrl));
}
[AllowAnonymous]
[HttpGet("calendar/enrollments/{token}.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<FileContentResult> CalendarEnrollmentsIcs(string token)
{
var ics = await _users.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
}
[HttpGet("me/enrollments.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<FileContentResult> MyEnrollmentsIcs()
{
var ics = await _users.GetMyEnrollmentsIcsAsync(CurrentUserId);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
}
[HttpGet("me/enrollments/{lectureId:int}.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<FileContentResult> EnrollmentIcs(int lectureId)
{
var ics = await _users.GetEnrollmentIcsAsync(CurrentUserId, lectureId);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"lecture-{lectureId}.ics");
}
/// <summary>Получить отзывы текущего пользователя.</summary> /// <summary>Получить отзывы текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param> /// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response> /// <response code="200">Список отзывов (пагинированный).</response>
@@ -1,12 +0,0 @@
namespace UniVerse.Api.Options;
public class RateLimitingOptions
{
public const string SectionName = "RateLimiting";
public int PermitLimit { get; set; } = 600;
public int WindowSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 100;
}
-61
View File
@@ -1,16 +1,12 @@
using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Prometheus; using Prometheus;
using Quartz; using Quartz;
using Serilog; using Serilog;
using System.Threading.RateLimiting;
using UniVerse.Api.BackgroundServices; using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Filters; using UniVerse.Api.Filters;
using UniVerse.Api.Middleware; using UniVerse.Api.Middleware;
@@ -73,50 +69,6 @@ builder.Services.AddAuthentication(options =>
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddOptions<RateLimitingOptions>()
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
.Validate(options => options.PermitLimit >= 1,
"RateLimiting:PermitLimit must be greater than or equal to 1.")
.Validate(options => options.WindowSeconds >= 1,
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
.Validate(options => options.QueueLimit >= 0,
"RateLimiting:QueueLimit must be greater than or equal to 0.")
.ValidateOnStart();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
return RateLimitPartition.GetFixedWindowLimiter(
GetRateLimitPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = rateLimitingOptions.PermitLimit,
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = rateLimitingOptions.QueueLimit,
AutoReplenishment = true
});
});
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/429",
title = "Too Many Requests",
status = StatusCodes.Status429TooManyRequests,
detail = "Rate limit exceeded. Please try again later.",
traceId = context.HttpContext.TraceIdentifier
}, cancellationToken);
};
});
// --- CORS --- // --- CORS ---
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@@ -269,7 +221,6 @@ if (app.Environment.IsDevelopment())
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization(); app.UseAuthorization();
app.UseHttpMetrics(); app.UseHttpMetrics();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -286,15 +237,3 @@ app.UseWhen(
app.MapMetrics(); app.MapMetrics();
app.Run(); app.Run();
static string GetRateLimitPartitionKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub");
if (!string.IsNullOrWhiteSpace(userId))
return $"user:{userId}";
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
}
+1 -1
View File
@@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" /> <PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.10.0" /> <PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" /> <PackageReference Include="Quartz.Dashboard" Version="3.18.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
-5
View File
@@ -13,11 +13,6 @@
"http://localhost:3000" "http://localhost:3000"
] ]
}, },
"RateLimiting": {
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 100
},
"Llm": { "Llm": {
"BaseUrl": "https://api.openai.com/v1/", "BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "", "ApiKey": "",
-150
View File
@@ -3789,146 +3789,6 @@
] ]
} }
}, },
"/api/v1/users/me/enrollments/calendar-subscription": {
"get": {
"tags": [
"Users"
],
"description": "**Required:** any authenticated user",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarSubscriptionDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/calendar/enrollments/{token}.ics": {
"get": {
"tags": [
"Users"
],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"403": {
"description": "Forbidden",
"content": {
"text/calendar": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/users/me/enrollments.ics": {
"get": {
"tags": [
"Users"
],
"description": "**Required:** any authenticated user",
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized — JWT token missing or invalid"
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/me/enrollments/{lectureId}.ics": {
"get": {
"tags": [
"Users"
],
"description": "**Required:** any authenticated user",
"parameters": [
{
"name": "lectureId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"404": {
"description": "Not Found",
"content": {
"text/calendar": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "Unauthorized — JWT token missing or invalid"
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/me/reviews": { "/api/v1/users/me/reviews": {
"get": { "get": {
"tags": [ "tags": [
@@ -4983,16 +4843,6 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CalendarSubscriptionDto": {
"type": "object",
"properties": {
"feedUrl": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"CoinTransactionDto": { "CoinTransactionDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -51,8 +51,6 @@ public record AdminDashboardStatsDto(
public record EnrollmentSlotRuleDto(int Level, int Slots); public record EnrollmentSlotRuleDto(int Level, int Slots);
public record CalendarSubscriptionDto(string FeedUrl);
public record UpdateUserRequest( public record UpdateUserRequest(
string? DisplayName, string? DisplayName,
string? AvatarUrl string? AvatarUrl
@@ -12,10 +12,6 @@ public interface IUserService
Task<UserStatsDto> GetStatsAsync(int id); Task<UserStatsDto> GetStatsAsync(int id);
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync(); Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination); Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<string> GetMyEnrollmentsIcsAsync(int userId);
Task<string> GetEnrollmentIcsAsync(int userId, int lectureId);
Task<string> GetCalendarSubscriptionTokenAsync(int userId);
Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token);
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter); Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles); Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
Task SetActiveAsync(int id, bool isActive); Task SetActiveAsync(int id, bool isActive);
@@ -6,37 +6,11 @@ public static class ReviewPromptTemplate
public const string ReviewTextPlaceholder = "{reviewText}"; public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """ public const string Default = """
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю. Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
- quality_score: число от 0 до 1, указывающее на качество отзыва;
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста: - sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
{ - tags: массив соответствующих тематических тегов;
"quality_score": 0.0, - is_informative: логическое значение, указывающее, является ли отзыв информативным.
"sentiment": "Нейтральный",
"tags": [],
"is_informative": false
}
Правила оценки:
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
Базовые теги:
- "структура лекции"
- "понятность объяснения"
- "темп"
- "сложность"
- "практические примеры"
- "материалы"
- "актуальность темы"
- "вовлеченность"
- "организация"
- "технические проблемы"
- "польза для обучения"
- "неинформативный отзыв"
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
Контекст лекции: {lectureContext} Контекст лекции: {lectureContext}
Текст отзыва: {reviewText} Текст отзыва: {reviewText}
@@ -1,12 +1,4 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Users; using UniVerse.Application.DTOs.Users;
@@ -21,20 +13,13 @@ namespace UniVerse.Infrastructure.Services;
public class UserService : IUserService public class UserService : IUserService
{ {
private const byte CalendarTokenVersion = 1;
private const int CalendarTokenPayloadLength = 5;
private const int CalendarTokenSignatureLength = 32;
private const string CalendarTokenKeyContext = "universe-calendar-subscription-v1";
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IGamificationService _gamification; private readonly IGamificationService _gamification;
private readonly IConfiguration _config;
public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config) public UserService(AppDbContext db, IGamificationService gamification)
{ {
_db = db; _db = db;
_gamification = gamification; _gamification = gamification;
_config = config;
} }
public async Task<UserDto> GetByIdAsync(int id) public async Task<UserDto> GetByIdAsync(int id)
@@ -130,154 +115,6 @@ public class UserService : IUserService
pagination.PageSize); pagination.PageSize);
} }
public async Task<string> GetMyEnrollmentsIcsAsync(int userId)
{
if (!await _db.Users.AnyAsync(u => u.Id == userId))
throw new NotFoundException("User", userId);
var lectures = await _db.LectureEnrollments
.Where(e => e.UserId == userId)
.Include(e => e.Lecture)
.ThenInclude(l => l.Teacher)
.Include(e => e.Lecture)
.ThenInclude(l => l.Location)
.OrderBy(e => e.Lecture.StartsAt)
.Select(e => e.Lecture)
.ToListAsync();
return BuildIcs(lectures, userId);
}
public async Task<string> GetEnrollmentIcsAsync(int userId, int lectureId)
{
if (!await _db.Users.AnyAsync(u => u.Id == userId))
throw new NotFoundException("User", userId);
var lecture = await _db.Lectures
.Include(l => l.Teacher)
.Include(l => l.Location)
.FirstOrDefaultAsync(l => l.Id == lectureId)
?? throw new NotFoundException("Lecture", lectureId);
return BuildIcs([lecture], userId);
}
public async Task<string> GetCalendarSubscriptionTokenAsync(int userId)
{
if (!await _db.Users.AnyAsync(u => u.Id == userId))
throw new NotFoundException("User", userId);
Span<byte> payload = stackalloc byte[CalendarTokenPayloadLength];
payload[0] = CalendarTokenVersion;
BinaryPrimitives.WriteInt32BigEndian(payload[1..], userId);
var signature = SignCalendarTokenPayload(payload);
var tokenBytes = new byte[CalendarTokenPayloadLength + CalendarTokenSignatureLength];
payload.CopyTo(tokenBytes);
signature.CopyTo(tokenBytes.AsSpan(CalendarTokenPayloadLength));
return ToBase64Url(tokenBytes);
}
public async Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token)
{
var userId = ValidateCalendarSubscriptionToken(token);
return await GetMyEnrollmentsIcsAsync(userId);
}
private int ValidateCalendarSubscriptionToken(string token)
{
var tokenBytes = FromBase64Url(token);
if (tokenBytes.Length != CalendarTokenPayloadLength + CalendarTokenSignatureLength)
throw new ForbiddenException("Invalid calendar subscription token.");
var payload = tokenBytes.AsSpan(0, CalendarTokenPayloadLength);
var signature = tokenBytes.AsSpan(CalendarTokenPayloadLength, CalendarTokenSignatureLength);
if (payload[0] != CalendarTokenVersion)
throw new ForbiddenException("Invalid calendar subscription token.");
var expectedSignature = SignCalendarTokenPayload(payload);
if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature))
throw new ForbiddenException("Invalid calendar subscription token.");
var userId = BinaryPrimitives.ReadInt32BigEndian(payload[1..]);
if (userId <= 0)
throw new ForbiddenException("Invalid calendar subscription token.");
return userId;
}
private byte[] SignCalendarTokenPayload(ReadOnlySpan<byte> payload)
{
var calendarKey = DeriveCalendarTokenKey();
return HMACSHA256.HashData(calendarKey, payload);
}
private byte[] DeriveCalendarTokenKey()
{
var jwtSecret = _config["Jwt:Secret"];
if (string.IsNullOrWhiteSpace(jwtSecret))
throw new InvalidOperationException("Jwt:Secret is not configured.");
return HMACSHA256.HashData(
Encoding.UTF8.GetBytes(jwtSecret),
Encoding.UTF8.GetBytes(CalendarTokenKeyContext));
}
private static string ToBase64Url(ReadOnlySpan<byte> bytes) =>
Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private static byte[] FromBase64Url(string value)
{
try
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
catch (FormatException)
{
throw new ForbiddenException("Invalid calendar subscription token.");
}
}
private static string BuildIcs(List<Domain.Entities.Lecture> lectures, int userId)
{
var calendar = new Calendar
{
Method = "PUBLISH",
ProductId = "-//UniVerse//Lectures Calendar//EN"
};
foreach (var lecture in lectures)
{
var location = lecture.Location is null
? string.Empty
: $"{lecture.Location.Building}{(string.IsNullOrWhiteSpace(lecture.Location.Room) ? string.Empty : $", ауд. {lecture.Location.Room}")}";
var teacherName = lecture.Teacher?.DisplayName
?? lecture.Teacher?.Email
?? "не указан";
calendar.Events.Add(new CalendarEvent
{
Uid = $"lecture-{lecture.Id}-user-{userId}@universe.local",
Summary = lecture.Title,
Description = $"{lecture.Description}\nПреподаватель: {teacherName}",
Location = location,
DtStart = new CalDateTime(DateTime.SpecifyKind(lecture.StartsAt, DateTimeKind.Utc)),
DtEnd = new CalDateTime(DateTime.SpecifyKind(lecture.EndsAt, DateTimeKind.Utc)),
DtStamp = new CalDateTime(DateTime.UtcNow)
});
}
return new CalendarSerializer().SerializeToString(calendar) ?? string.Empty;
}
public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter) public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter)
{ {
var query = _db.Users.AsQueryable(); var query = _db.Users.AsQueryable();
@@ -13,7 +13,6 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
<PackageReference Include="Quartz" Version="3.18.1" /> <PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Ical.Net" Version="5.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
-4
View File
@@ -26,10 +26,6 @@ services:
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000} - Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
- RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600}
- RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60}
- RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100}
- Llm:BaseUrl=${LLM_BASE_URL} - Llm:BaseUrl=${LLM_BASE_URL}
- Llm:ApiKey=${LLM_API_KEY} - Llm:ApiKey=${LLM_API_KEY}
- Llm:Model=${LLM_MODEL} - Llm:Model=${LLM_MODEL}
+1 -1
View File
@@ -33,7 +33,7 @@
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.60.0", "eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.9.0",
"eslint-plugin-vue-scoped-css": "^3.1.0", "eslint-plugin-vue-scoped-css": "^3.1.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
-27
View File
@@ -81,30 +81,3 @@ export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[]
if (Array.isArray(payload)) return payload if (Array.isArray(payload)) return payload
return payload?.items ?? [] return payload?.items ?? []
} }
export async function apiRequestBlob(
path: string,
options: RequestInit & { query?: Record<string, unknown> } = {},
): Promise<Blob> {
const headers = new Headers(options.headers)
if (!headers.has('Accept')) headers.set('Accept', 'text/calendar')
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
const response = await fetch(makeUrl(path, options.query), {
...options,
headers,
credentials: 'include',
})
if (!response.ok) {
const body = await parseResponse(response)
const message =
typeof body === 'object' && body && 'message' in body
? String((body as { message: unknown }).message)
: `API request failed with status ${response.status}`
throw new ApiError(message, response.status, body)
}
return response.blob()
}
+1 -7
View File
@@ -1,4 +1,4 @@
import { apiRequest, apiRequestBlob, extractItems } from './client' import { apiRequest, extractItems } from './client'
import type { import type {
AchievementDto, AchievementDto,
AuthResponse, AuthResponse,
@@ -19,7 +19,6 @@ import type {
UpdateReviewPromptRequest, UpdateReviewPromptRequest,
UserAchievementDto, UserAchievementDto,
AdminDashboardStatsDto, AdminDashboardStatsDto,
CalendarSubscriptionDto,
CurrentUserDto, CurrentUserDto,
UserDto, UserDto,
UserQuery, UserQuery,
@@ -77,11 +76,6 @@ export const usersApi = {
) )
return extractItems(payload) return extractItems(payload)
}, },
downloadMyEnrollmentsIcs: () => apiRequestBlob('/users/me/enrollments.ics'),
downloadEnrollmentIcs: (lectureId: string | number) =>
apiRequestBlob(`/users/me/enrollments/${lectureId}.ics`),
getCalendarSubscription: () =>
apiRequest<CalendarSubscriptionDto>('/users/me/enrollments/calendar-subscription'),
async myAchievements() { async myAchievements() {
const payload = await apiRequest< const payload = await apiRequest<
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[] PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
-4
View File
@@ -83,10 +83,6 @@ export interface AdminDashboardStatsDto {
pendingReviewsCount: number pendingReviewsCount: number
} }
export interface CalendarSubscriptionDto {
feedUrl: string
}
export interface EnrollmentSlotRuleDto { export interface EnrollmentSlotRuleDto {
level: number level: number
slots: number slots: number
-10
View File
@@ -1,10 +0,0 @@
export function downloadFile(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
+1 -13
View File
@@ -2,8 +2,6 @@
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
@@ -68,16 +66,6 @@ const levelProgressText = computed(() =>
: `${userXp.value} XP`, : `${userXp.value} XP`,
) )
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
addToast?.("Файл календаря скачан", "success")
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(), lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
@@ -137,7 +125,7 @@ async function registerLecture(id: string) {
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)"> <button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
Открыть Открыть
</button> </button>
<button class="btn-secondary" @click="downloadLectureIcs(nextLecture.id)">Скачать .ics</button> <button class="btn-secondary">Добавить в календарь</button>
</div> </div>
</div> </div>
</GlassCard> </GlassCard>
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
@@ -39,16 +37,6 @@ onMounted(async () => {
await lecturesStore.fetchLecture(lectureId.value) await lecturesStore.fetchLecture(lectureId.value)
}) })
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
addToast?.("Файл календаря скачан", "success")
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
}
}
async function registerLecture() { async function registerLecture() {
if (!lecture.value) return if (!lecture.value) return
if (slotRegistrationDisabled.value) { if (slotRegistrationDisabled.value) {
@@ -102,7 +90,7 @@ async function registerLecture() {
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)"> <button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
Отменить запись Отменить запись
</button> </button>
<button class="btn-secondary" @click="downloadLectureIcs(lecture.id)">Скачать .ics</button> <button class="btn-secondary">Добавить в календарь</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)"> <button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
Оставить отзыв Оставить отзыв
</button> </button>
+3 -127
View File
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
@@ -16,11 +14,6 @@ const router = useRouter()
const activeTab = ref<'upcoming' | 'history'>('upcoming') const activeTab = ref<'upcoming' | 'history'>('upcoming')
const cancelModal = ref(false) const cancelModal = ref(false)
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const calendarSubscriptionUrl = ref<string | null>(null)
const calendarActionPending = ref(false)
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const upcoming = computed(() => const upcoming = computed(() =>
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })), lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
@@ -33,98 +26,6 @@ onMounted(async () => {
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser() if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
}) })
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
}
}
async function downloadAllIcs() {
try {
const blob = await usersApi.downloadMyEnrollmentsIcs()
downloadFile(blob, 'my-lectures.ics')
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
}
}
async function getCalendarSubscriptionUrl() {
if (calendarSubscriptionUrl.value) return calendarSubscriptionUrl.value
const subscription = await usersApi.getCalendarSubscription()
calendarSubscriptionUrl.value = subscription.feedUrl
return subscription.feedUrl
}
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value)
return true
} catch {
// Browser may block async clipboard writes after awaiting the subscription request.
}
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const copied = document.execCommand('copy')
textarea.remove()
return copied
}
async function copyCalendarSubscriptionUrl() {
try {
calendarActionPending.value = true
const feedUrl = await getCalendarSubscriptionUrl()
if (!await copyText(feedUrl)) throw new Error('Браузер заблокировал копирование ссылки')
addToast?.('ICS-ссылка скопирована', 'success')
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скопировать ссылку', 'error')
} finally {
calendarActionPending.value = false
}
}
async function syncWithGoogleCalendar() {
const googleWindow = window.open('about:blank', '_blank')
try {
calendarActionPending.value = true
const feedUrl = await getCalendarSubscriptionUrl()
const copied = await copyText(feedUrl)
const googleUrl = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feedUrl)}`
if (googleWindow) {
googleWindow.opener = null
googleWindow.location.href = googleUrl
} else {
window.open(googleUrl, '_blank', 'noopener,noreferrer')
}
addToast?.(
copied
? 'ICS-ссылка скопирована. Google Calendar открыт в новой вкладке.'
: 'Google Calendar открыт. Если ссылка не подставилась, скопируйте ICS-ссылку отдельно.',
copied ? 'success' : 'info',
)
} catch (err) {
googleWindow?.close()
addToast?.(
err instanceof Error ? err.message : 'Не удалось подготовить ссылку для Google Calendar',
'error',
)
} finally {
calendarActionPending.value = false
}
}
function openCancel(id: string) { function openCancel(id: string) {
selectedId.value = id selectedId.value = id
cancelModal.value = true cancelModal.value = true
@@ -145,25 +46,7 @@ async function confirmCancel() {
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы. Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
</p> </p>
</div> </div>
<div class="calendar-actions"> <button class="btn-secondary">Экспорт в календарь</button>
<button
class="btn-primary"
:disabled="calendarActionPending"
@click="syncWithGoogleCalendar"
>
Синхронизировать с Google Calendar
</button>
<button
class="btn-secondary"
:disabled="calendarActionPending"
@click="copyCalendarSubscriptionUrl"
>
Скопировать ICS-ссылку
</button>
<button class="btn-secondary" @click="downloadAllIcs">
Скачать все мои лекции (.ics)
</button>
</div>
</div> </div>
<div class="tabs"> <div class="tabs">
@@ -193,7 +76,7 @@ async function confirmCancel() {
</div> </div>
<div class="lecture-actions"> <div class="lecture-actions">
<StatusBadge status="registered" /> <StatusBadge status="registered" />
<button class="btn-secondary btn-sm" @click="downloadLectureIcs(item.id)">Скачать .ics</button> <button class="btn-secondary btn-sm">Добавить в календарь</button>
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button> <button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
</div> </div>
</GlassCard> </GlassCard>
@@ -250,13 +133,6 @@ async function confirmCancel() {
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.calendar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.tabs { .tabs {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;
@@ -9,6 +9,7 @@ import { mapApiReview } from '@/api/mappers'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
const lecturesStore = useLecturesStore() const lecturesStore = useLecturesStore()
const auth = useAuthStore() const auth = useAuthStore()
const reviews = ref<Review[]>([]) const reviews = ref<Review[]>([])
@@ -16,9 +17,8 @@ const reviews = ref<Review[]>([])
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length) const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length) const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length) const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
const total = computed(() => reviews.value.length) const total = computed(() => reviews.value.length || 1)
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0) const pct = (value: number) => Math.round((value / total.value) * 100)
const ratio = (value: number) => `${value}/${total.value}`
async function fetchTeacherAnalytics() { async function fetchTeacherAnalytics() {
if (!auth.user?.id) return if (!auth.user?.id) return
@@ -39,28 +39,37 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<h1 class="page-title">Аналитика преподавателя</h1> <h1 class="page-title">Аналитика преподавателя</h1>
<div class="grid"> <div class="grid">
<GlassCard>
<div class="section-title">Динамика оценок</div>
<div class="chart">
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
<span class="bar-label">Нед {{ i + 1 }}</span>
</div>
</div>
<div class="avg">Средняя оценка: 4.6</div>
</GlassCard>
<GlassCard> <GlassCard>
<div class="section-title">Sentiment-анализ отзывов</div> <div class="section-title">Sentiment-анализ отзывов</div>
<div class="sentiment"> <div class="sentiment">
<div> <div>
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div> <div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" /> <ProgressBar :value="pct(positive)" :max="100" />
</div> </div>
<div> <div>
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div> <div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<ProgressBar <ProgressBar
:value="pct(neutral)" :value="pct(neutral)"
:max="100" :max="100"
:text="ratio(neutral)"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/> />
</div> </div>
<div> <div>
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div> <div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<ProgressBar <ProgressBar
:value="pct(negative)" :value="pct(negative)"
:max="100" :max="100"
:text="ratio(negative)"
color="linear-gradient(90deg, #FCA5A5, #FECACA)" color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/> />
</div> </div>
@@ -108,6 +117,32 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px; gap: 16px;
} }
.chart {
display: flex;
gap: 12px;
align-items: flex-end;
height: 160px;
padding: 10px 0;
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.bar-fill {
width: 26px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #22c55e, #86efac);
}
.bar-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.avg {
margin-top: 6px;
font-weight: 600;
}
.sentiment { .sentiment {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -20,6 +20,9 @@ const upcoming = computed(() =>
const enrolledTotal = computed(() => const enrolledTotal = computed(() =>
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0), teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
) )
const visibility = computed(() =>
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
)
function fetchTeacherLectures() { function fetchTeacherLectures() {
if (!auth.user?.id) return if (!auth.user?.id) return
@@ -45,7 +48,13 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<div class="stats-row"> <div class="stats-row">
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" /> <StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" /> <StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" /> <StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
<StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
</div> </div>
<GlassCard> <GlassCard>