Files
serega404 136bcce7db
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
feat: добавил поддержку подписки на календарь и экспорт расписания лекций в формате .ics
2026-06-02 22:10:15 +03:00

372 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.Extensions.Configuration;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Mappings;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Domain.Services;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Infrastructure.Services;
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 IGamificationService _gamification;
private readonly IConfiguration _config;
public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config)
{
_db = db;
_gamification = gamification;
_config = config;
}
public async Task<UserDto> GetByIdAsync(int id)
{
var user = await _db.Users
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == id)
?? throw new NotFoundException("User", id);
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
public async Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request)
{
var user = await _db.Users
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == id)
?? throw new NotFoundException("User", id);
if (request.DisplayName != null) user.DisplayName = request.DisplayName;
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _gamification.CheckAndAwardAchievementsAsync(id);
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
public async Task<UserStatsDto> GetStatsAsync(int id)
{
var user = await _db.Users.FindAsync(id)
?? throw new NotFoundException("User", id);
var totalLectures = await _db.LectureEnrollments.CountAsync(e => e.UserId == id);
var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended);
var reviews = await _db.Reviews.CountAsync(r => r.UserId == id);
var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id);
var activeEnrollments = await _db.LectureEnrollments
.CountAsync(e => e.UserId == id && !e.Attended);
var level = await _gamification.CalculateLevelAsync(user.Xp);
var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp);
var slotLimit = EnrollmentSlotPolicy.GetLimitForLevel(level);
var slotRules = EnrollmentSlotPolicy.Rules
.Select(rule => new EnrollmentSlotRuleDto(rule.Level, rule.Slots))
.ToList();
return new UserStatsDto(
totalLectures, attended, reviews,
user.Xp, user.Coins, level, achievements,
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp,
activeEnrollments, slotLimit, slotRules
);
}
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))
throw new NotFoundException("User", id);
var query = _db.LectureEnrollments
.Where(e => e.UserId == id)
.Include(e => e.Lecture)
.ThenInclude(l => l.Course)
.Include(e => e.Lecture)
.ThenInclude(l => l.Teacher)
.Include(e => e.Lecture)
.ThenInclude(l => l.Location)
.Include(e => e.Lecture)
.ThenInclude(l => l.Enrollments);
var total = await query.CountAsync();
var enrollments = await query
.OrderBy(e => e.Lecture.StartsAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync();
return PagedResult<LectureDto>.Create(
enrollments.Select(e => e.Lecture.ToDto(isEnrolled: true)).ToList(),
total,
pagination.Page,
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)
{
var query = _db.Users.AsQueryable();
if (!string.IsNullOrEmpty(filter.Search))
{
var search = filter.Search.ToLower();
query = query.Where(u =>
u.Email.ToLower().Contains(search) ||
(u.DisplayName != null && u.DisplayName.ToLower().Contains(search)));
}
query = query.Include(u => u.Roles);
if (filter.Role.HasValue)
{
var role = filter.Role.Value;
query = query.Where(u =>
u.Roles.Count == 1 &&
u.Roles.Any(ur => ur.Role == role));
}
if (filter.IsActive.HasValue)
query = query.Where(u => u.IsActive == filter.IsActive.Value);
var total = await query.CountAsync();
var users = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync();
var items = new List<UserDto>(users.Count);
foreach (var user in users)
items.Add(user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)));
return PagedResult<UserDto>.Create(items, total, filter.Page, filter.PageSize);
}
public async Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles)
{
var normalizedRoles = roles.Distinct().ToList();
if (normalizedRoles.Count == 0)
throw new ForbiddenException("At least one role is required.");
var user = await _db.Users
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == id)
?? throw new NotFoundException("User", id);
var existing = user.Roles.Select(r => r.Role).ToHashSet();
var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList();
foreach (var item in toRemove)
user.Roles.Remove(item);
var toAdd = normalizedRoles.Where(r => !existing.Contains(r)).ToList();
foreach (var role in toAdd)
user.Roles.Add(new Domain.Entities.UserRoleAssignment { UserId = user.Id, Role = role });
await EnsureProfilesForRolesAsync(user.Id, normalizedRoles);
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
public async Task SetActiveAsync(int id, bool isActive)
{
var user = await _db.Users.FindAsync(id)
?? throw new NotFoundException("User", id);
user.IsActive = isActive;
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection<UserRole> roles)
{
if (roles.Contains(UserRole.Student))
{
var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId);
if (!hasStudentProfile)
_db.StudentProfiles.Add(new Domain.Entities.StudentProfile { UserId = userId });
}
if (roles.Contains(UserRole.Teacher))
{
var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId);
if (!hasTeacherProfile)
_db.TeacherProfiles.Add(new Domain.Entities.TeacherProfile { UserId = userId });
}
}
}