11 Commits

Author SHA1 Message Date
serega404 3160832756 Merge branch 'dev' into renovate/microsoft.extensions.http.resilience-10.x
Backend CI / build-and-test (pull_request) Successful in 45s
Frontend Playwright / e2e (pull_request) Successful in 10m17s
2026-06-01 13:41:07 +03:00
serega404 7050851bd4 feat: добавил поддержку ограничения частоты запросов
Backend CI / build-and-test (push) Successful in 48s
Frontend CI / build-and-check (push) Failing after 23s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m21s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 30s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
2026-06-01 12:40:28 +03:00
serega404 450f2e2418 feat: обновил шаблон отзыва и улучшил анализ отзывов преподавателя 2026-06-01 12:31:30 +03:00
Renovate Bot a66c1197a5 chore(deps): update dependency microsoft.extensions.http.resilience to 10.6.0
Backend CI / build-and-test (pull_request) Successful in 47s
Frontend Playwright / e2e (pull_request) Successful in 10m15s
2026-05-31 05:00:49 +00:00
serega404 09d3d2778d feat: подтянул занятость из Modeus
Backend CI / build-and-test (push) Successful in 53s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 6m26s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-30 14:08:49 +03:00
serega404 a8d51df3f1 feat: добавил эндпоинт для получения статистики админского дашборда
Backend CI / build-and-test (push) Successful in 40s
Frontend CI / build-and-check (push) Failing after 19s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 24s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 5s
2026-05-30 01:23:57 +03:00
serega404 e8f7a64d15 Merge pull request 'chore(deps): update dependency aspire.apphost.sdk to 13.3.5' (#26) from renovate/aspire.apphost.sdk-13.x into dev
Backend CI / build-and-test (push) Has been cancelled
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been cancelled
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been cancelled
Reviewed-on: #26
2026-05-30 01:18:26 +03:00
serega404 c1597a8649 Merge pull request 'chore(deps): update dependency xunit.runner.visualstudio to 3.1.5' (#25) from renovate/xunit.runner.visualstudio-3.x into dev
Backend CI / build-and-test (push) Has been cancelled
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been cancelled
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been cancelled
Reviewed-on: #25
2026-05-30 01:18:08 +03:00
serega404 f9d3f1ac56 feat: добавил prometheus экспортер
Backend CI / build-and-test (push) Successful in 43s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
Backend CI / build-and-test (pull_request) Successful in 44s
Frontend Playwright / e2e (pull_request) Has been cancelled
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 59s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been skipped
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 4s
2026-05-30 01:16:58 +03:00
Renovate Bot 62dbeecd9f chore(deps): update dependency aspire.apphost.sdk to 13.3.5
Backend CI / build-and-test (pull_request) Successful in 41s
Frontend Playwright / e2e (pull_request) Successful in 10m13s
2026-05-29 05:00:15 +00:00
Renovate Bot edd276664c chore(deps): update dependency xunit.runner.visualstudio to 3.1.5
Backend CI / build-and-test (pull_request) Successful in 46s
Frontend Playwright / e2e (pull_request) Successful in 10m13s
2026-05-29 05:00:14 +00:00
35 changed files with 1715 additions and 133 deletions
@@ -53,6 +53,7 @@ public class MappingExtensionsTests
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 25,
MandatoryAttendeesCount = 30,
Enrollments =
[
new LectureEnrollment { UserId = 1 },
@@ -66,7 +67,7 @@ public class MappingExtensionsTests
Assert.Equal("", dto.CourseName);
Assert.Null(dto.TeacherName);
Assert.Null(dto.LocationName);
Assert.Equal(2, dto.EnrollmentsCount);
Assert.Equal(32, dto.EnrollmentsCount);
Assert.True(dto.IsEnrolled);
Assert.False(detail.IsEnrolled);
}
@@ -166,6 +166,29 @@ public class LectureServiceTests
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
}
[Fact]
public async Task EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.MaxEnrollments = 31;
lecture.MandatoryAttendeesCount = 30;
db.Users.AddRange(
new User { Id = 1, Email = "first@test.local" },
new User { Id = 2, Email = "second@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(1, 2));
}
[Fact]
public async Task UnenrollAsync_CancelsLectureReminders()
{
@@ -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,62 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Infrastructure.ExternalServices;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ModeusApiClientTests
{
[Fact]
public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://schedule.test")
};
var config = new ConfigurationBuilder().Build();
var client = new ModeusApiClient(http, config, NullLogger<ModeusApiClient>.Instance);
await client.SearchEventsAsync(new SyncScheduleRequest(
SpecialtyCode: ["09.03.04"],
TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc),
TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc),
TypeId: ["LECT"],
Size: 50));
Assert.Equal(HttpMethod.Post, handler.RequestMethod);
Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery);
Assert.NotNull(handler.RequestBody);
using var body = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32());
Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString());
Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString());
}
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpMethod? RequestMethod { get; private set; }
public string? RequestPathAndQuery { get; private set; }
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestMethod = request.Method;
RequestPathAndQuery = request.RequestUri?.PathAndQuery;
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"events":[]}""")
};
}
}
}
@@ -129,6 +129,56 @@ public class ScheduleSyncServiceTests
Assert.Equal(48, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc),
IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 120, WorkingCapacity: 120)
]
}
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(120, lecture.MaxEnrollments);
Assert.Equal(31, lecture.MandatoryAttendeesCount);
}
[Fact]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{
@@ -14,7 +14,7 @@
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -159,6 +159,19 @@ public class UsersController : ControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
/// <summary>Получить статистику для админского дашборда.</summary>
/// <remarks>Только Admin.</remarks>
/// <response code="200">Агрегированная статистика дашборда.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("admin/stats")]
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
Ok(await _users.GetAdminDashboardStatsAsync());
/// <summary>Получить список записей пользователя на лекции.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
/// <param name="id">ID пользователя.</param>
@@ -0,0 +1,71 @@
using System.Net;
using System.Net.Sockets;
namespace UniVerse.Api.Middleware;
public sealed class LocalNetworksOnlyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var remoteIpAddress = context.Connection.RemoteIpAddress;
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
{
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
return;
}
await _next(context);
}
private static bool IsLocalNetwork(IPAddress ipAddress)
{
if (IPAddress.IsLoopback(ipAddress))
{
return true;
}
if (ipAddress.IsIPv4MappedToIPv6)
{
ipAddress = ipAddress.MapToIPv4();
}
return ipAddress.AddressFamily switch
{
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
_ => false
};
}
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return bytes[0] == 10
|| bytes[0] == 127
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 169 && bytes[1] == 254);
}
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return ipAddress.IsIPv6LinkLocal
|| ipAddress.IsIPv6SiteLocal
|| (bytes[0] & 0xfe) == 0xfc;
}
}
@@ -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;
}
+70 -1
View File
@@ -1,11 +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;
@@ -68,6 +73,50 @@ builder.Services.AddAuthentication(options =>
});
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 ---
builder.Services.AddCors(options =>
{
@@ -135,7 +184,7 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
client.Timeout = TimeSpan.FromSeconds(30);
client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
});
// --- Background Services ---
@@ -220,7 +269,9 @@ if (app.Environment.IsDevelopment())
app.UseCors();
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.UseHttpMetrics();
if (app.Environment.IsDevelopment())
{
app.UseAntiforgery();
@@ -228,4 +279,22 @@ if (app.Environment.IsDevelopment())
}
app.MapControllers();
// Restrict Prometheus scrape endpoint to local and private networks.
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
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}";
}
+1
View File
@@ -30,6 +30,7 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
</ItemGroup>
<ItemGroup>
+7 -1
View File
@@ -13,6 +13,11 @@
"http://localhost:3000"
]
},
"RateLimiting": {
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 100
},
"Llm": {
"BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "",
@@ -23,7 +28,8 @@
},
"ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": ""
"ApiKey": "",
"TimeoutSeconds": 180
},
"Serilog": {
"MinimumLevel": {
+68
View File
@@ -4158,6 +4158,52 @@
]
}
},
"/api/v1/users/admin/stats": {
"get": {
"tags": [
"Users"
],
"summary": "Получить статистику для админского дашборда.",
"description": "Только Admin.\n\n**Required roles:** Admin",
"responses": {
"200": {
"description": "Агрегированная статистика дашборда.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminDashboardStatsDto"
}
}
}
},
"401": {
"description": "Требуется аутентификация.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Требуется роль Admin.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/{id}/enrollments": {
"get": {
"tags": [
@@ -4758,6 +4804,28 @@
},
"additionalProperties": false
},
"AdminDashboardStatsDto": {
"type": "object",
"properties": {
"usersCount": {
"type": "integer",
"format": "int32"
},
"lecturesCount": {
"type": "integer",
"format": "int32"
},
"enrollmentsCount": {
"type": "integer",
"format": "int32"
},
"pendingReviewsCount": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"AuthResponse": {
"type": "object",
"properties": {
@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -42,6 +42,13 @@ public record UserStatsDto(
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
);
public record AdminDashboardStatsDto(
int UsersCount,
int LecturesCount,
int EnrollmentsCount,
int PendingReviewsCount
);
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record UpdateUserRequest(
@@ -29,11 +29,14 @@ public class ModeusEvent
public string? TypeId { get; init; }
public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; }
public ModeusIctisStats? IctisStats { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
public class ModeusEventLinks
{
[JsonPropertyName("course-unit-realization")]
@@ -10,6 +10,7 @@ public interface IUserService
Task<UserDto> GetByIdAsync(int id);
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
Task<UserStatsDto> GetStatsAsync(int id);
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
@@ -13,6 +13,9 @@ namespace UniVerse.Application.Mappings;
public static class MappingExtensions
{
private static int OccupiedSeatsCount(this Lecture lecture) =>
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
// --- User ---
public static UserDto ToDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
@@ -57,7 +60,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
@@ -67,7 +70,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
@@ -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}
@@ -15,6 +15,7 @@ public class Lecture
public DateTime EndsAt { get; set; }
public bool IsOpen { get; set; } = true;
public int MaxEnrollments { get; set; }
public int MandatoryAttendeesCount { get; set; }
public string? ExternalId { get; set; }
public string? OnlineUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -22,6 +22,7 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0);
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
@@ -42,12 +42,11 @@ public class ModeusApiClient : IModeusApiClient
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
var requestJson = JsonSerializer.Serialize(body);
await EnsureSuccessAsync(response, "Modeus events search",
BuildEventsRequestSummary(requestJson));
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
BuildEventsRequestSummary(requestJson))
var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}";
var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body);
await EnsureSuccessAsync(response, "ICTIS events search", requestSummary);
return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary)
?? new ModeusEventsResponse();
}
@@ -98,8 +97,6 @@ public class ModeusApiClient : IModeusApiClient
response.StatusCode);
}
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MandatoryAttendeesCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "mandatory_attendees_count",
table: "lectures",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mandatory_attendees_count",
table: "lectures");
}
}
}
@@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
@@ -250,6 +250,12 @@ namespace UniVerse.Infrastructure.Migrations
.HasColumnType("integer")
.HasColumnName("location_id");
b.Property<int>("MandatoryAttendeesCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("mandatory_attendees_count");
b.Property<int>("MaxEnrollments")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
@@ -122,7 +122,8 @@ public class LectureService : ILectureService
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments)
var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments)
throw new ConflictException("Lecture is full.");
if (lecture.Enrollments.Any(e => e.UserId == userId))
throw new ConflictException("Already enrolled.");
@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
@@ -55,6 +54,7 @@ public class ScheduleSyncService : IScheduleSyncService
}
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
var startsAt = EnsureUtc(ev.StartsAt);
var endsAt = EnsureUtc(ev.EndsAt);
@@ -68,6 +68,7 @@ public class ScheduleSyncService : IScheduleSyncService
existing.LocationId = location?.Id;
existing.TeacherId = teacher?.Id;
existing.MaxEnrollments = lectureCapacity;
existing.MandatoryAttendeesCount = mandatoryAttendeesCount;
existing.UpdatedAt = DateTime.UtcNow;
updated++;
}
@@ -91,7 +92,8 @@ public class ScheduleSyncService : IScheduleSyncService
ExternalId = ev.Id,
StartsAt = startsAt,
EndsAt = endsAt,
MaxEnrollments = lectureCapacity
MaxEnrollments = lectureCapacity,
MandatoryAttendeesCount = mandatoryAttendeesCount
});
created++;
}
@@ -111,7 +113,7 @@ public class ScheduleSyncService : IScheduleSyncService
updated,
skipped,
[
$"requestJson={BuildScheduleRequestJson(request)}",
"endpoint=POST /api/ictis?includeCounts=true",
$"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}"
]));
@@ -443,6 +445,9 @@ public class ScheduleSyncService : IScheduleSyncService
private static int? NormalizeCapacity(int? capacity) =>
capacity is > 0 ? capacity : null;
private static int GetMandatoryAttendeesCount(ModeusIctisStats? stats) =>
Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0);
private static string BuildModeusTeacherEmail(string personId) =>
$"modeus-{personId}@modeus.local".ToLowerInvariant();
@@ -488,37 +493,6 @@ public class ScheduleSyncService : IScheduleSyncService
return details;
}
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
{
var body = new Dictionary<string, object?>
{
["size"] = request.Size is > 0 ? request.Size.Value : 900,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax
};
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
return JsonSerializer.Serialize(body);
}
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
if (values is { Count: > 0 })
body[key] = values;
}
private static string? GetHrefId(string? href)
{
if (string.IsNullOrWhiteSpace(href))
@@ -74,6 +74,17 @@ public class UserService : IUserService
);
}
public async Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync()
{
var usersCount = await _db.Users
.CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher));
var lecturesCount = await _db.Lectures.CountAsync();
var enrollmentsCount = await _db.LectureEnrollments.CountAsync();
var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending);
return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount);
}
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
{
if (!await _db.Users.AnyAsync(u => u.Id == id))
@@ -11,7 +11,7 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
+4
View File
@@ -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}
+2
View File
@@ -18,6 +18,7 @@ import type {
TagDto,
UpdateReviewPromptRequest,
UserAchievementDto,
AdminDashboardStatsDto,
CurrentUserDto,
UserDto,
UserQuery,
@@ -68,6 +69,7 @@ export const usersApi = {
body: JSON.stringify(payload),
}),
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
adminStats: () => apiRequest<AdminDashboardStatsDto>('/users/admin/stats'),
async myEnrollments() {
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
'/users/me/enrollments',
+7
View File
@@ -76,6 +76,13 @@ export interface UserStatsDto {
enrollmentSlotRules: EnrollmentSlotRuleDto[]
}
export interface AdminDashboardStatsDto {
usersCount: number
lecturesCount: number
enrollmentsCount: number
pendingReviewsCount: number
}
export interface EnrollmentSlotRuleDto {
level: number
slots: number
+14 -23
View File
@@ -3,17 +3,11 @@ import { computed, onMounted, ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
import { syncApi, usersApi } from '@/api'
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types'
const users = ref<UserDto[]>([])
const lectures = ref<LectureDto[]>([])
const pendingReviewsCount = ref(0)
const stats = ref<AdminDashboardStatsDto | null>(null)
const syncStatus = ref<SyncStatusDto | null>(null)
const enrollmentCount = computed(() =>
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
)
const syncMeta = computed(() =>
syncStatus.value?.lastSyncAt
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
@@ -21,16 +15,8 @@ const syncMeta = computed(() =>
)
onMounted(async () => {
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
usersApi.list({ PageSize: 100 }),
lecturesApi.list({ PageSize: 100 }),
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
syncApi.status(),
])
if (usersResult.status === 'fulfilled') users.value = usersResult.value
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (reviewsResult.status === 'fulfilled')
pendingReviewsCount.value = reviewsResult.value.totalCount
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()])
if (statsResult.status === 'fulfilled') stats.value = statsResult.value
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
})
</script>
@@ -40,12 +26,17 @@ onMounted(async () => {
<h1 class="page-title">Дашборд администратора</h1>
<div class="stats-row">
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" />
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" />
<StatsWidget
label="Записей"
:value="stats?.enrollmentsCount ?? 0"
icon="calendar-event"
color="orange"
/>
<StatsWidget
label="Отзывы на проверке"
:value="pendingReviewsCount"
:value="stats?.pendingReviewsCount ?? 0"
icon="message-circle"
color="purple"
/>
@@ -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<Review[]>([])
@@ -17,8 +16,9 @@ const reviews = ref<Review[]>([])
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)
<h1 class="page-title">Аналитика преподавателя</h1>
<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>
<div class="section-title">Sentiment-анализ отзывов</div>
<div class="sentiment">
<div>
<div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
<ProgressBar :value="pct(positive)" :max="100" />
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div>
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" />
</div>
<div>
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div>
<ProgressBar
:value="pct(neutral)"
:max="100"
:text="ratio(neutral)"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/>
</div>
<div>
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div>
<ProgressBar
:value="pct(negative)"
:max="100"
:text="ratio(negative)"
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/>
</div>
@@ -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;
@@ -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)
<div class="stats-row">
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
<StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" />
</div>
<GlassCard>