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
Реализовал хранение, получение и отметку прочитанными пользовательских уведомлений. Обновил фронтенд для отображения и управления уведомлениями в профиле студента.
211 lines
8.7 KiB
C#
211 lines
8.7 KiB
C#
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);
|
||
}
|
||
}
|