Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22520f2012 | |||
| 7050851bd4 | |||
| 450f2e2418 |
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
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;
|
||||||
@@ -69,6 +73,50 @@ 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 =>
|
||||||
{
|
{
|
||||||
@@ -221,6 +269,7 @@ 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())
|
||||||
@@ -237,3 +286,15 @@ 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}";
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
"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": "",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
|
<Project Sdk="Aspire.AppHost.Sdk/13.4.0">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
|
|||||||
@@ -6,11 +6,37 @@ 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, указывающее на качество отзыва;
|
|
||||||
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
|
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста:
|
||||||
- tags: массив соответствующих тематических тегов;
|
{
|
||||||
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
|
"quality_score": 0.0,
|
||||||
|
"sentiment": "Нейтральный",
|
||||||
|
"tags": [],
|
||||||
|
"is_informative": false
|
||||||
|
}
|
||||||
|
|
||||||
|
Правила оценки:
|
||||||
|
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
|
||||||
|
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
|
||||||
|
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
|
||||||
|
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
|
||||||
|
|
||||||
|
Базовые теги:
|
||||||
|
- "структура лекции"
|
||||||
|
- "понятность объяснения"
|
||||||
|
- "темп"
|
||||||
|
- "сложность"
|
||||||
|
- "практические примеры"
|
||||||
|
- "материалы"
|
||||||
|
- "актуальность темы"
|
||||||
|
- "вовлеченность"
|
||||||
|
- "организация"
|
||||||
|
- "технические проблемы"
|
||||||
|
- "польза для обучения"
|
||||||
|
- "неинформативный отзыв"
|
||||||
|
|
||||||
|
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
|
||||||
|
|
||||||
Контекст лекции: {lectureContext}
|
Контекст лекции: {lectureContext}
|
||||||
Текст отзыва: {reviewText}
|
Текст отзыва: {reviewText}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ 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}
|
||||||
|
|||||||
@@ -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.9.0",
|
"eslint-plugin-vue": "~10.8.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,7 +9,6 @@ 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[]>([])
|
||||||
@@ -17,8 +16,9 @@ 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 || 1)
|
const total = computed(() => reviews.value.length)
|
||||||
const pct = (value: number) => Math.round((value / total.value) * 100)
|
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0)
|
||||||
|
const ratio = (value: number) => `${value}/${total.value}`
|
||||||
|
|
||||||
async function fetchTeacherAnalytics() {
|
async function fetchTeacherAnalytics() {
|
||||||
if (!auth.user?.id) return
|
if (!auth.user?.id) return
|
||||||
@@ -39,37 +39,28 @@ 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">Позитивные {{ pct(positive) }}%</div>
|
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div>
|
||||||
<ProgressBar :value="pct(positive)" :max="100" />
|
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
|
<div class="sentiment-label">Нейтральные {{ ratio(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">Негативные {{ pct(negative) }}%</div>
|
<div class="sentiment-label">Негативные {{ ratio(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>
|
||||||
@@ -117,32 +108,6 @@ 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,9 +20,6 @@ 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
|
||||||
@@ -48,13 +45,7 @@ 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="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
|
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" />
|
||||||
<StatsWidget
|
|
||||||
label="Вовлеченность вне направления"
|
|
||||||
:value="`${visibility}%`"
|
|
||||||
icon="🌍"
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user