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
9 changed files with 61 additions and 162 deletions
@@ -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,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}";
}
-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": "",
@@ -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}
-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}
+2 -2
View File
@@ -32,8 +32,8 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.68.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",
@@ -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>