From 450f2e241881e89530d6044b8aaebda91f1cc3b2 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Mon, 1 Jun 2026 12:31:30 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B7=D1=8B=D0=B2=D0=B0=20=D0=B8=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B8=D0=BB=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2=D0=BE=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Prompts/ReviewPromptTemplate.cs | 36 +++++++++++-- .../views/teacher/TeacherAnalyticsView.vue | 53 ++++--------------- .../views/teacher/TeacherDashboardView.vue | 11 +--- 3 files changed, 41 insertions(+), 59 deletions(-) diff --git a/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs b/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs index db01367..8c1d2ea 100644 --- a/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs +++ b/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs @@ -6,11 +6,37 @@ public static class ReviewPromptTemplate public const string ReviewTextPlaceholder = "{reviewText}"; public const string Default = """ - Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями: - - quality_score: число от 0 до 1, указывающее на качество отзыва; - - sentiment: «Положительный», «Нейтральный» или «Отрицательный»; - - tags: массив соответствующих тематических тегов; - - is_informative: логическое значение, указывающее, является ли отзыв информативным. + Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю. + + Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста: + { + "quality_score": 0.0, + "sentiment": "Нейтральный", + "tags": [], + "is_informative": false + } + + Правила оценки: + - quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой. + - is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов. + - sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный". + - tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив. + + Базовые теги: + - "структура лекции" + - "понятность объяснения" + - "темп" + - "сложность" + - "практические примеры" + - "материалы" + - "актуальность темы" + - "вовлеченность" + - "организация" + - "технические проблемы" + - "польза для обучения" + - "неинформативный отзыв" + + Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции. Контекст лекции: {lectureContext} Текст отзыва: {reviewText} diff --git a/frontend/src/views/teacher/TeacherAnalyticsView.vue b/frontend/src/views/teacher/TeacherAnalyticsView.vue index bbf4798..ea8aba3 100644 --- a/frontend/src/views/teacher/TeacherAnalyticsView.vue +++ b/frontend/src/views/teacher/TeacherAnalyticsView.vue @@ -9,7 +9,6 @@ import { mapApiReview } from '@/api/mappers' import { useLecturesStore } from '@/stores/lectures' import { useAuthStore } from '@/stores/auth' -const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7] const lecturesStore = useLecturesStore() const auth = useAuthStore() const reviews = ref([]) @@ -17,8 +16,9 @@ const reviews = ref([]) const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length) const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length) const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length) -const total = computed(() => reviews.value.length || 1) -const pct = (value: number) => Math.round((value / total.value) * 100) +const total = computed(() => reviews.value.length) +const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0) +const ratio = (value: number) => `${value}/${total.value}` async function fetchTeacherAnalytics() { if (!auth.user?.id) return @@ -39,37 +39,28 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)

Аналитика преподавателя

- -
Динамика оценок
-
-
-
- Нед {{ i + 1 }} -
-
-
Средняя оценка: 4.6
-
-
Sentiment-анализ отзывов
-
Позитивные {{ pct(positive) }}%
- +
Позитивные {{ ratio(positive) }}
+
-
Нейтральные {{ pct(neutral) }}%
+
Нейтральные {{ ratio(neutral) }}
-
Негативные {{ pct(negative) }}%
+
Негативные {{ ratio(negative) }}
@@ -117,32 +108,6 @@ watch(() => auth.user?.id, fetchTeacherAnalytics) grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 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 { display: flex; flex-direction: column; diff --git a/frontend/src/views/teacher/TeacherDashboardView.vue b/frontend/src/views/teacher/TeacherDashboardView.vue index 778eec1..244c08b 100644 --- a/frontend/src/views/teacher/TeacherDashboardView.vue +++ b/frontend/src/views/teacher/TeacherDashboardView.vue @@ -20,9 +20,6 @@ const upcoming = computed(() => const enrolledTotal = computed(() => 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() { if (!auth.user?.id) return @@ -48,13 +45,7 @@ watch(() => auth.user?.id, fetchTeacherLectures)
- - +
From 7050851bd46f6618f6201d09566a09fe2ae75674 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Mon, 1 Jun 2026 12:40:28 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA?= =?UTF-8?q?=D1=83=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=87=D0=B0=D1=81=D1=82=D0=BE=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RateLimiting/RateLimitingTests.cs | 37 +++++++++++ .../Options/RateLimitingOptions.cs | 12 ++++ backend/UniVerse.Api/Program.cs | 61 +++++++++++++++++++ backend/UniVerse.Api/appsettings.json | 5 ++ docker-compose-prod.yml | 4 ++ 5 files changed, 119 insertions(+) create mode 100644 backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs create mode 100644 backend/UniVerse.Api/Options/RateLimitingOptions.cs diff --git a/backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs b/backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs new file mode 100644 index 0000000..2d82143 --- /dev/null +++ b/backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs @@ -0,0 +1,37 @@ +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 + { + ["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); + } +} diff --git a/backend/UniVerse.Api/Options/RateLimitingOptions.cs b/backend/UniVerse.Api/Options/RateLimitingOptions.cs new file mode 100644 index 0000000..851154b --- /dev/null +++ b/backend/UniVerse.Api/Options/RateLimitingOptions.cs @@ -0,0 +1,12 @@ +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; +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 400b497..9e98a79 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -1,12 +1,16 @@ +using System.Security.Claims; using System.Text; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using Prometheus; using Quartz; using Serilog; +using System.Threading.RateLimiting; using UniVerse.Api.BackgroundServices; using UniVerse.Api.Filters; using UniVerse.Api.Middleware; @@ -69,6 +73,50 @@ builder.Services.AddAuthentication(options => }); builder.Services.AddAuthorization(); +builder.Services.AddOptions() + .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(context => + { + var rateLimitingOptions = context.RequestServices.GetRequiredService>().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 --- builder.Services.AddCors(options => { @@ -221,6 +269,7 @@ if (app.Environment.IsDevelopment()) app.UseCors(); app.UseAuthentication(); +app.UseRateLimiter(); app.UseAuthorization(); app.UseHttpMetrics(); if (app.Environment.IsDevelopment()) @@ -237,3 +286,15 @@ app.UseWhen( app.MapMetrics(); 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}"; +} diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index 6864a91..a40dc9a 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -13,6 +13,11 @@ "http://localhost:3000" ] }, + "RateLimiting": { + "PermitLimit": 600, + "WindowSeconds": 60, + "QueueLimit": 100 + }, "Llm": { "BaseUrl": "https://api.openai.com/v1/", "ApiKey": "", diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index b6ec100..9c943d7 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -26,6 +26,10 @@ services: - 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:ApiKey=${LLM_API_KEY} - Llm:Model=${LLM_MODEL}