Files
UniVerse/backend/UniVerse.Infrastructure/Services/GamificationService.cs
T
serega404 b0a4a6d259
🚀 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 26s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
feat: добавил личные уведомления для пользователей
Реализовал хранение, получение и отметку прочитанными пользовательских уведомлений. Обновил фронтенд для отображения и управления уведомлениями в профиле студента.
2026-05-12 23:54:55 +03:00

211 lines
8.7 KiB
C#
Raw 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 Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Globalization;
using UniVerse.Application.DTOs.Achievements;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Gamification;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Mappings;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Infrastructure.Services;
public class GamificationService : IGamificationService
{
private readonly AppDbContext _db;
private readonly IConfiguration _config;
private readonly INotificationService _notifications;
private readonly ILogger<GamificationService> _logger;
public GamificationService(
AppDbContext db,
IConfiguration config,
INotificationService notifications,
ILogger<GamificationService> logger)
{
_db = db; _config = config; _notifications = notifications; _logger = logger;
}
public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
int? reviewId = null, int? achievementId = null, string? description = null)
{
var user = await _db.Users.FindAsync(userId);
if (user == null) return;
user.Coins += amount;
user.Xp += amount;
_db.CoinTransactions.Add(new CoinTransaction
{
UserId = userId, Amount = amount, Type = type,
ReviewId = reviewId, AchievementId = achievementId, Description = description
});
await _db.SaveChangesAsync();
_logger.LogInformation("Awarded {Amount} coins to user {UserId} ({Type})", amount, userId, type);
}
public async Task CheckAndAwardAchievementsAsync(int userId)
{
var user = await _db.Users
.Include(u => u.StudentProfile)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return;
var achievements = await _db.Achievements.OrderBy(a => a.Id).ToListAsync();
var existing = await _db.UserAchievements.Where(ua => ua.UserId == userId)
.Select(ua => ua.AchievementId).ToListAsync();
var reviews = await _db.Reviews.CountAsync(r => r.UserId == userId);
var registered = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId);
var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId && e.Attended);
var activeRegistrations = await _db.LectureEnrollments
.Include(e => e.Lecture)
.CountAsync(e => e.UserId == userId && e.Lecture.StartsAt > DateTime.UtcNow);
var earnedCoins = await _db.CoinTransactions
.Where(ct => ct.UserId == userId && ct.Amount > 0)
.SumAsync(ct => (int?)ct.Amount) ?? 0;
var attendanceStreakWeeks = await CalculateAttendanceStreakWeeksAsync(userId);
var profileCompleted = !string.IsNullOrWhiteSpace(user.DisplayName)
&& !string.IsNullOrWhiteSpace(user.AvatarUrl);
var firstActivity = registered > 0 || reviews > 0 || attended > 0;
foreach (var achievement in achievements.Where(a => !existing.Contains(a.Id)))
{
if (!TryParseCondition(achievement.Condition, out var type, out var value)) continue;
var earned = type switch
{
"lectures_attended" => attended >= value,
"reviews_written" => reviews >= value,
"lectures_registered" => registered >= value,
"active_registrations" => activeRegistrations >= value,
"attendance_streak_weeks" => attendanceStreakWeeks >= value,
"attended_registered" => attended >= value,
"coins_earned" => earnedCoins >= value,
"level_reached" => CalculateLevel(user.Xp) >= value,
"profile_completed" => profileCompleted && value <= 1,
"first_activity" => firstActivity && value <= 1,
_ => false
};
if (!earned) continue;
_db.UserAchievements.Add(new UserAchievement { UserId = userId, AchievementId = achievement.Id });
if (achievement.CoinReward > 0)
{
await AwardCoinsAsync(userId, achievement.CoinReward, CoinTransactionType.AchievementReward,
achievementId: achievement.Id, description: $"Achievement: {achievement.Name}");
earnedCoins += achievement.CoinReward;
}
await TryNotifyAchievementAsync(user, achievement);
}
await _db.SaveChangesAsync();
}
private async Task TryNotifyAchievementAsync(User user, Achievement achievement)
{
try
{
var title = $"Новое достижение: {achievement.Name}";
var rewardText = achievement.CoinReward > 0
? $" Награда: {achievement.CoinReward} монет."
: string.Empty;
var body = $"{achievement.Description ?? "Вы выполнили условие достижения."}{rewardText}";
await _notifications.CreateUserNotificationAsync(user.Id, "achievement", title, body);
await _notifications.SendAsync(new NotificationMessage(
NotificationChannels.Email,
user.Email,
title,
$"Здравствуйте, {user.DisplayName ?? user.Email}!\n\nПоздравляем: вы получили достижение «{achievement.Name}» в UniVerse.\n\n{body}",
user.DisplayName,
new Dictionary<string, string>
{
["event"] = "achievement_earned",
["achievement_id"] = achievement.Id.ToString(),
["achievement_name"] = achievement.Name
}));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send achievement notification {AchievementId} to user {UserId}", achievement.Id, user.Id);
}
}
private static bool TryParseCondition(string? condition, out string type, out int value)
{
type = string.Empty;
value = 0;
var parts = condition?.Split(':', 2, StringSplitOptions.TrimEntries);
if (parts is not { Length: 2 } || string.IsNullOrWhiteSpace(parts[0])) return false;
if (!int.TryParse(parts[1], out value)) return false;
type = parts[0];
return value >= 0;
}
private async Task<int> CalculateAttendanceStreakWeeksAsync(int userId)
{
var lectureDates = await _db.LectureEnrollments
.Include(e => e.Lecture)
.Where(e => e.UserId == userId && e.Attended)
.Select(e => e.Lecture.StartsAt)
.ToListAsync();
var weekStarts = lectureDates
.Select(GetIsoWeekStart)
.Distinct()
.OrderBy(d => d)
.ToList();
var best = 0;
var current = 0;
DateOnly? previous = null;
foreach (var weekStart in weekStarts)
{
current = previous.HasValue && weekStart.DayNumber - previous.Value.DayNumber == 7
? current + 1
: 1;
best = Math.Max(best, current);
previous = weekStart;
}
return best;
}
private static DateOnly GetIsoWeekStart(DateTime date)
{
var isoYear = ISOWeek.GetYear(date);
var isoWeek = ISOWeek.GetWeekOfYear(date);
return DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
}
public int CalculateLevel(int xp)
{
var thresholds = _config.GetSection("Gamification:XpThresholds").Get<int[]>()
?? [0, 100, 300, 600, 1000, 1500, 2500, 4000];
for (int i = thresholds.Length - 1; i >= 0; i--)
if (xp >= thresholds[i]) return i + 1;
return 1;
}
public async Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId) =>
await _db.UserAchievements.Include(ua => ua.Achievement)
.Where(ua => ua.UserId == userId).OrderByDescending(ua => ua.AwardedAt)
.Select(ua => ua.ToDto()).ToListAsync();
public async Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination)
{
var query = _db.CoinTransactions.Where(ct => ct.UserId == userId);
var total = await query.CountAsync();
var items = await query.OrderByDescending(ct => ct.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize)
.Select(ct => ct.ToDto()).ToListAsync();
return PagedResult<CoinTransactionDto>.Create(items, total, pagination.Page, pagination.PageSize);
}
}