Dev #11
@@ -0,0 +1,136 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using UniVerse.Domain.Entities;
|
||||||
|
using UniVerse.Domain.Enums;
|
||||||
|
using UniVerse.Infrastructure.Data;
|
||||||
|
using UniVerse.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Tests.Gamification;
|
||||||
|
|
||||||
|
public class GamificationServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = CreateService(db);
|
||||||
|
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Email = "student@test.local",
|
||||||
|
DisplayName = "Student",
|
||||||
|
AvatarUrl = "avatar.png",
|
||||||
|
Xp = 100,
|
||||||
|
Coins = 510
|
||||||
|
});
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
db.Lectures.Add(new Lecture
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
CourseId = 1,
|
||||||
|
Title = "Future lecture",
|
||||||
|
StartsAt = DateTime.UtcNow.AddDays(1),
|
||||||
|
EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2),
|
||||||
|
IsOpen = true
|
||||||
|
});
|
||||||
|
db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 });
|
||||||
|
db.Reviews.AddRange(
|
||||||
|
new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like },
|
||||||
|
new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral },
|
||||||
|
new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike });
|
||||||
|
db.CoinTransactions.Add(new CoinTransaction
|
||||||
|
{
|
||||||
|
UserId = 1,
|
||||||
|
Amount = 510,
|
||||||
|
Type = CoinTransactionType.AdminAdjustment,
|
||||||
|
Description = "Initial coins"
|
||||||
|
});
|
||||||
|
db.Achievements.AddRange(
|
||||||
|
Achievement(1001, "First activity", "first_activity:1", 10),
|
||||||
|
Achievement(1002, "Reviews", "reviews_written:3", 20),
|
||||||
|
Achievement(1003, "Active registrations", "active_registrations:1", 30),
|
||||||
|
Achievement(1004, "Coins earned", "coins_earned:500", 40),
|
||||||
|
Achievement(1005, "Level reached", "level_reached:2", 50),
|
||||||
|
Achievement(1006, "Profile completed", "profile_completed:1", 60),
|
||||||
|
Achievement(1007, "Old condition", "reviews_1", 100));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await service.CheckAndAwardAchievementsAsync(1);
|
||||||
|
await service.CheckAndAwardAchievementsAsync(1);
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(1);
|
||||||
|
Assert.NotNull(user);
|
||||||
|
Assert.Equal(720, user!.Coins);
|
||||||
|
Assert.Equal(310, user.Xp);
|
||||||
|
Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1));
|
||||||
|
Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007));
|
||||||
|
Assert.Equal(6, await db.CoinTransactions.CountAsync(ct =>
|
||||||
|
ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = CreateService(db);
|
||||||
|
|
||||||
|
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
db.Lectures.AddRange(
|
||||||
|
Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)),
|
||||||
|
Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)),
|
||||||
|
Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc)));
|
||||||
|
db.LectureEnrollments.AddRange(
|
||||||
|
new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true },
|
||||||
|
new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true },
|
||||||
|
new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true });
|
||||||
|
db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await service.CheckAndAwardAchievementsAsync(1);
|
||||||
|
|
||||||
|
Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}")
|
||||||
|
.Options;
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GamificationService CreateService(AppDbContext db)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Gamification:XpThresholds:0"] = "0",
|
||||||
|
["Gamification:XpThresholds:1"] = "100",
|
||||||
|
["Gamification:XpThresholds:2"] = "300"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
return new GamificationService(db, configuration, NullLogger<GamificationService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Achievement Achievement(int id, string name, string condition, int coinReward) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Condition = condition,
|
||||||
|
CoinReward = coinReward
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Lecture Lecture(int id, DateTime startsAt) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
CourseId = 1,
|
||||||
|
Title = $"Lecture {id}",
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = startsAt.AddHours(2)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using UniVerse.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.BackgroundServices;
|
||||||
|
|
||||||
|
public class AchievementCatalogHostedService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
public AchievementCatalogHostedService(IServiceProvider services) => _services = services;
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await AchievementCatalogSeeder.SeedAsync(_services, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
|||||||
|
|
||||||
// --- Background Services ---
|
// --- Background Services ---
|
||||||
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
|
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<AchievementCatalogHostedService>();
|
||||||
|
|
||||||
// --- Controllers ---
|
// --- Controllers ---
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using UniVerse.Domain.Entities;
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Data;
|
||||||
|
|
||||||
|
public static class AchievementCatalogSeeder
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<AchievementSeed> Catalog =
|
||||||
|
[
|
||||||
|
new(1001, "Добро пожаловать в UniVerse", "Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.", "sparkles", 10, "first_activity:1"),
|
||||||
|
new(1002, "Первый шаг", "Посетить первую открытую лекцию.", "book-2", 10, "lectures_attended:1"),
|
||||||
|
new(1003, "Вошел во вкус", "Посетить 3 открытые лекции.", "books", 20, "lectures_attended:3"),
|
||||||
|
new(1004, "Постоянный слушатель", "Посетить 5 открытых лекций.", "calendar-event", 35, "lectures_attended:5"),
|
||||||
|
new(1005, "Академический марафон", "Посетить 10 открытых лекций.", "stopwatch", 60, "lectures_attended:10"),
|
||||||
|
new(1006, "Грандмастер лекций", "Посетить 25 открытых лекций.", "trophy", 120, "lectures_attended:25"),
|
||||||
|
new(1007, "Первый отзыв", "Оставить первый отзыв о посещенной лекции.", "message-circle", 10, "reviews_written:1"),
|
||||||
|
new(1008, "Голос аудитории", "Оставить 3 отзыва о лекциях.", "thumb-up", 25, "reviews_written:3"),
|
||||||
|
new(1009, "Рецензент", "Оставить 10 отзывов о лекциях.", "clipboard-list", 70, "reviews_written:10"),
|
||||||
|
new(1010, "Голос перемен", "Оставить 25 отзывов о лекциях.", "chart-line", 150, "reviews_written:25"),
|
||||||
|
new(1011, "Смелый выбор", "Записаться на первую открытую лекцию.", "calendar", 5, "lectures_registered:1"),
|
||||||
|
new(1012, "План на неделю", "Иметь 3 активные записи на будущие лекции.", "calendar-event", 15, "active_registrations:3"),
|
||||||
|
new(1013, "Полный календарь", "Иметь 5 активных записей на будущие лекции.", "alarm", 30, "active_registrations:5"),
|
||||||
|
new(1014, "Серия интереса", "Посещать открытые лекции 3 недели подряд.", "star", 50, "attendance_streak_weeks:3"),
|
||||||
|
new(1015, "Учебный месяц", "Посещать открытые лекции 4 недели подряд.", "sparkles", 80, "attendance_streak_weeks:4"),
|
||||||
|
new(1016, "Без пропусков", "Посетить 5 лекций, на которые была оформлена запись.", "circle-check", 40, "attended_registered:5"),
|
||||||
|
new(1017, "Надежный участник", "Посетить 10 лекций, на которые была оформлена запись.", "shield", 75, "attended_registered:10"),
|
||||||
|
new(1018, "Капитал знаний", "Получить 500 монет за активность на платформе.", "coin", 80, "coins_earned:500"),
|
||||||
|
new(1019, "Новый уровень", "Достигнуть 2 уровня.", "star", 25, "level_reached:2"),
|
||||||
|
new(1020, "Уверенный рост", "Достигнуть 5 уровня.", "chart-bar", 100, "level_reached:5"),
|
||||||
|
new(1021, "Профиль заполнен", "Заполнить имя и аватар в профиле.", "user", 10, "profile_completed:1")
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> LegacyConditions = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["reviews_1"] = "reviews_written:1",
|
||||||
|
["reviews_5"] = "reviews_written:5",
|
||||||
|
["reviews_10"] = "reviews_written:10",
|
||||||
|
["attended_5"] = "lectures_attended:5",
|
||||||
|
["attended_10"] = "lectures_attended:10"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var legacyConditionKeys = LegacyConditions.Keys.ToArray();
|
||||||
|
|
||||||
|
var legacyAchievements = await db.Achievements
|
||||||
|
.Where(a => a.Condition != null && legacyConditionKeys.Contains(a.Condition))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var achievement in legacyAchievements)
|
||||||
|
achievement.Condition = LegacyConditions[achievement.Condition!];
|
||||||
|
|
||||||
|
foreach (var seed in Catalog)
|
||||||
|
{
|
||||||
|
var achievement = await db.Achievements.FindAsync([seed.Id], cancellationToken);
|
||||||
|
if (achievement == null)
|
||||||
|
{
|
||||||
|
db.Achievements.Add(new Achievement
|
||||||
|
{
|
||||||
|
Id = seed.Id,
|
||||||
|
Name = seed.Name,
|
||||||
|
Description = seed.Description,
|
||||||
|
IconUrl = seed.IconUrl,
|
||||||
|
XpReward = 0,
|
||||||
|
CoinReward = seed.CoinReward,
|
||||||
|
Condition = seed.Condition
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
achievement.Name = seed.Name;
|
||||||
|
achievement.Description = seed.Description;
|
||||||
|
achievement.IconUrl = seed.IconUrl;
|
||||||
|
achievement.XpReward = 0;
|
||||||
|
achievement.CoinReward = seed.CoinReward;
|
||||||
|
achievement.Condition = seed.Condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AchievementSeed(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
string IconUrl,
|
||||||
|
int CoinReward,
|
||||||
|
string Condition);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using UniVerse.Infrastructure.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260512120000_SeedAchievements")]
|
||||||
|
public partial class SeedAchievements : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE achievements
|
||||||
|
SET condition = CASE condition
|
||||||
|
WHEN 'reviews_1' THEN 'reviews_written:1'
|
||||||
|
WHEN 'reviews_5' THEN 'reviews_written:5'
|
||||||
|
WHEN 'reviews_10' THEN 'reviews_written:10'
|
||||||
|
WHEN 'attended_5' THEN 'lectures_attended:5'
|
||||||
|
WHEN 'attended_10' THEN 'lectures_attended:10'
|
||||||
|
ELSE condition
|
||||||
|
END
|
||||||
|
WHERE condition IN ('reviews_1', 'reviews_5', 'reviews_10', 'attended_5', 'attended_10');
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT INTO achievements (id, name, description, icon_url, xp_reward, coin_reward, condition, created_at)
|
||||||
|
VALUES
|
||||||
|
(1001, 'Добро пожаловать в UniVerse', 'Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.', 'sparkles', 0, 10, 'first_activity:1', NOW()),
|
||||||
|
(1002, 'Первый шаг', 'Посетить первую открытую лекцию.', 'book-2', 0, 10, 'lectures_attended:1', NOW()),
|
||||||
|
(1003, 'Вошел во вкус', 'Посетить 3 открытые лекции.', 'books', 0, 20, 'lectures_attended:3', NOW()),
|
||||||
|
(1004, 'Постоянный слушатель', 'Посетить 5 открытых лекций.', 'calendar-event', 0, 35, 'lectures_attended:5', NOW()),
|
||||||
|
(1005, 'Академический марафон', 'Посетить 10 открытых лекций.', 'stopwatch', 0, 60, 'lectures_attended:10', NOW()),
|
||||||
|
(1006, 'Грандмастер лекций', 'Посетить 25 открытых лекций.', 'trophy', 0, 120, 'lectures_attended:25', NOW()),
|
||||||
|
(1007, 'Первый отзыв', 'Оставить первый отзыв о посещенной лекции.', 'message-circle', 0, 10, 'reviews_written:1', NOW()),
|
||||||
|
(1008, 'Голос аудитории', 'Оставить 3 отзыва о лекциях.', 'thumb-up', 0, 25, 'reviews_written:3', NOW()),
|
||||||
|
(1009, 'Рецензент', 'Оставить 10 отзывов о лекциях.', 'clipboard-list', 0, 70, 'reviews_written:10', NOW()),
|
||||||
|
(1010, 'Голос перемен', 'Оставить 25 отзывов о лекциях.', 'chart-line', 0, 150, 'reviews_written:25', NOW()),
|
||||||
|
(1011, 'Смелый выбор', 'Записаться на первую открытую лекцию.', 'calendar', 0, 5, 'lectures_registered:1', NOW()),
|
||||||
|
(1012, 'План на неделю', 'Иметь 3 активные записи на будущие лекции.', 'calendar-event', 0, 15, 'active_registrations:3', NOW()),
|
||||||
|
(1013, 'Полный календарь', 'Иметь 5 активных записей на будущие лекции.', 'alarm', 0, 30, 'active_registrations:5', NOW()),
|
||||||
|
(1014, 'Серия интереса', 'Посещать открытые лекции 3 недели подряд.', 'star', 0, 50, 'attendance_streak_weeks:3', NOW()),
|
||||||
|
(1015, 'Учебный месяц', 'Посещать открытые лекции 4 недели подряд.', 'sparkles', 0, 80, 'attendance_streak_weeks:4', NOW()),
|
||||||
|
(1016, 'Без пропусков', 'Посетить 5 лекций, на которые была оформлена запись.', 'circle-check', 0, 40, 'attended_registered:5', NOW()),
|
||||||
|
(1017, 'Надежный участник', 'Посетить 10 лекций, на которые была оформлена запись.', 'shield', 0, 75, 'attended_registered:10', NOW()),
|
||||||
|
(1018, 'Капитал знаний', 'Получить 500 монет за активность на платформе.', 'coin', 0, 80, 'coins_earned:500', NOW()),
|
||||||
|
(1019, 'Новый уровень', 'Достигнуть 2 уровня.', 'star', 0, 25, 'level_reached:2', NOW()),
|
||||||
|
(1020, 'Уверенный рост', 'Достигнуть 5 уровня.', 'chart-bar', 0, 100, 'level_reached:5', NOW()),
|
||||||
|
(1021, 'Профиль заполнен', 'Заполнить имя и аватар в профиле.', 'user', 0, 10, 'profile_completed:1', NOW())
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
icon_url = EXCLUDED.icon_url,
|
||||||
|
xp_reward = EXCLUDED.xp_reward,
|
||||||
|
coin_reward = EXCLUDED.coin_reward,
|
||||||
|
condition = EXCLUDED.condition;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM achievements WHERE id BETWEEN 1001 AND 1021;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ public class AchievementService : IAchievementService
|
|||||||
public AchievementService(AppDbContext db) => _db = db;
|
public AchievementService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
public async Task<List<AchievementDto>> GetAllAsync() =>
|
public async Task<List<AchievementDto>> GetAllAsync() =>
|
||||||
await _db.Achievements.OrderBy(a => a.Name).Select(a => a.ToDto()).ToListAsync();
|
await _db.Achievements.OrderBy(a => a.Id).Select(a => a.ToDto()).ToListAsync();
|
||||||
|
|
||||||
public async Task<AchievementDto> GetByIdAsync(int id)
|
public async Task<AchievementDto> GetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Globalization;
|
||||||
using UniVerse.Application.DTOs.Achievements;
|
using UniVerse.Application.DTOs.Achievements;
|
||||||
using UniVerse.Application.DTOs.Common;
|
using UniVerse.Application.DTOs.Common;
|
||||||
using UniVerse.Application.DTOs.Gamification;
|
using UniVerse.Application.DTOs.Gamification;
|
||||||
@@ -41,32 +42,109 @@ public class GamificationService : IGamificationService
|
|||||||
|
|
||||||
public async Task CheckAndAwardAchievementsAsync(int userId)
|
public async Task CheckAndAwardAchievementsAsync(int userId)
|
||||||
{
|
{
|
||||||
var achievements = await _db.Achievements.ToListAsync();
|
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)
|
var existing = await _db.UserAchievements.Where(ua => ua.UserId == userId)
|
||||||
.Select(ua => ua.AchievementId).ToListAsync();
|
.Select(ua => ua.AchievementId).ToListAsync();
|
||||||
var reviews = await _db.Reviews.CountAsync(r => r.UserId == userId);
|
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 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)))
|
foreach (var achievement in achievements.Where(a => !existing.Contains(a.Id)))
|
||||||
{
|
{
|
||||||
var earned = achievement.Condition switch
|
if (!TryParseCondition(achievement.Condition, out var type, out var value)) continue;
|
||||||
|
|
||||||
|
var earned = type switch
|
||||||
{
|
{
|
||||||
"reviews_1" => reviews >= 1,
|
"lectures_attended" => attended >= value,
|
||||||
"reviews_5" => reviews >= 5,
|
"reviews_written" => reviews >= value,
|
||||||
"reviews_10" => reviews >= 10,
|
"lectures_registered" => registered >= value,
|
||||||
"attended_5" => attended >= 5,
|
"active_registrations" => activeRegistrations >= value,
|
||||||
"attended_10" => attended >= 10,
|
"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
|
_ => false
|
||||||
};
|
};
|
||||||
if (!earned) continue;
|
if (!earned) continue;
|
||||||
|
|
||||||
_db.UserAchievements.Add(new UserAchievement { UserId = userId, AchievementId = achievement.Id });
|
_db.UserAchievements.Add(new UserAchievement { UserId = userId, AchievementId = achievement.Id });
|
||||||
if (achievement.CoinReward > 0)
|
if (achievement.CoinReward > 0)
|
||||||
|
{
|
||||||
await AwardCoinsAsync(userId, achievement.CoinReward, CoinTransactionType.AchievementReward,
|
await AwardCoinsAsync(userId, achievement.CoinReward, CoinTransactionType.AchievementReward,
|
||||||
achievementId: achievement.Id, description: $"Achievement: {achievement.Name}");
|
achievementId: achievement.Id, description: $"Achievement: {achievement.Name}");
|
||||||
|
earnedCoins += achievement.CoinReward;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public int CalculateLevel(int xp)
|
||||||
{
|
{
|
||||||
var thresholds = _config.GetSection("Gamification:XpThresholds").Get<int[]>()
|
var thresholds = _config.GetSection("Gamification:XpThresholds").Get<int[]>()
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ namespace UniVerse.Infrastructure.Services;
|
|||||||
public class LectureService : ILectureService
|
public class LectureService : ILectureService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public LectureService(AppDbContext db) => _db = db;
|
private readonly IGamificationService _gamification;
|
||||||
|
|
||||||
|
public LectureService(AppDbContext db, IGamificationService gamification)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_gamification = gamification;
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<Lecture> BaseQuery() => _db.Lectures
|
private IQueryable<Lecture> BaseQuery() => _db.Lectures
|
||||||
.Include(l => l.Course).Include(l => l.Teacher)
|
.Include(l => l.Course).Include(l => l.Teacher)
|
||||||
@@ -96,6 +102,7 @@ public class LectureService : ILectureService
|
|||||||
throw new ConflictException("Already enrolled.");
|
throw new ConflictException("Already enrolled.");
|
||||||
_db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId });
|
_db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId });
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UnenrollAsync(int lectureId, int userId)
|
public async Task UnenrollAsync(int lectureId, int userId)
|
||||||
@@ -114,6 +121,8 @@ public class LectureService : ILectureService
|
|||||||
?? throw new NotFoundException("Enrollment not found.");
|
?? throw new NotFoundException("Enrollment not found.");
|
||||||
enrollment.Attended = attended;
|
enrollment.Attended = attended;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
if (attended)
|
||||||
|
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination)
|
public async Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination)
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ namespace UniVerse.Infrastructure.Services;
|
|||||||
public class ReviewService : IReviewService
|
public class ReviewService : IReviewService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public ReviewService(AppDbContext db) => _db = db;
|
private readonly IGamificationService _gamification;
|
||||||
|
|
||||||
|
public ReviewService(AppDbContext db, IGamificationService gamification)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_gamification = gamification;
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<Review> BaseQuery() => _db.Reviews
|
private IQueryable<Review> BaseQuery() => _db.Reviews
|
||||||
.Include(r => r.Lecture).Include(r => r.User);
|
.Include(r => r.Lecture).Include(r => r.User);
|
||||||
@@ -31,6 +37,7 @@ public class ReviewService : IReviewService
|
|||||||
};
|
};
|
||||||
_db.Reviews.Add(review);
|
_db.Reviews.Add(review);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
||||||
var full = await BaseQuery().FirstAsync(r => r.Id == review.Id);
|
var full = await BaseQuery().FirstAsync(r => r.Id == review.Id);
|
||||||
return full.ToDto();
|
return full.ToDto();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class UserService : IUserService
|
|||||||
user.UpdatedAt = DateTime.UtcNow;
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
await _gamification.CheckAndAwardAchievementsAsync(id);
|
||||||
return user.ToDto(_gamification.CalculateLevel(user.Xp));
|
return user.ToDto(_gamification.CalculateLevel(user.Xp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export const usersApi = {
|
|||||||
apiRequest<void>(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }),
|
apiRequest<void>(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const achievementsApi = {
|
||||||
|
async list() {
|
||||||
|
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>('/achievements')
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const reviewsApi = {
|
export const reviewsApi = {
|
||||||
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
||||||
apiRequest<ReviewDto>('/reviews', {
|
apiRequest<ReviewDto>('/reviews', {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { usersApi } from '@/api'
|
import { achievementsApi, usersApi } from '@/api'
|
||||||
import { mapApiAchievement, mapApiCoinTransaction } from '@/api/mappers'
|
import { mapApiAchievement, mapApiCoinTransaction } from '@/api/mappers'
|
||||||
import type { Achievement, CoinTransaction, Notification } from '@/types'
|
import type { Achievement, CoinTransaction, Notification } from '@/types'
|
||||||
import { useAuthStore } from './auth'
|
import { useAuthStore } from './auth'
|
||||||
@@ -25,6 +25,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
usersApi.achievements(id),
|
usersApi.achievements(id),
|
||||||
usersApi.transactions(id),
|
usersApi.transactions(id),
|
||||||
])
|
])
|
||||||
|
const achievementCatalog = await achievementsApi.list()
|
||||||
|
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
auth.setUser({
|
auth.setUser({
|
||||||
@@ -37,7 +38,22 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)),
|
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
achievements.value = achievementPayload.map(mapApiAchievement)
|
const unlocked = new Map(achievementPayload.map(item => {
|
||||||
|
const achievement = mapApiAchievement(item)
|
||||||
|
return [achievement.id, achievement]
|
||||||
|
}))
|
||||||
|
const catalogIds = new Set(achievementCatalog.map(item => String(item.id)))
|
||||||
|
const lockedAndUnlocked = achievementCatalog.map(item => {
|
||||||
|
const achievement = mapApiAchievement(item)
|
||||||
|
return unlocked.get(achievement.id) ?? achievement
|
||||||
|
})
|
||||||
|
const unlockedOutsideCatalog = achievementPayload
|
||||||
|
.map(mapApiAchievement)
|
||||||
|
.filter(item => !catalogIds.has(item.id))
|
||||||
|
|
||||||
|
achievements.value = [...lockedAndUnlocked, ...unlockedOutsideCatalog].sort(
|
||||||
|
(a, b) => Number(a.id) - Number(b.id),
|
||||||
|
)
|
||||||
coinHistory.value = transactions.map(mapApiCoinTransaction)
|
coinHistory.value = transactions.map(mapApiCoinTransaction)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.'
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.'
|
||||||
|
|||||||
Reference in New Issue
Block a user