fix: перенёс уровни в бд и пофиксид их отображение на фронте
Backend CI / build-and-test (push) Successful in 52s
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m0s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s

This commit is contained in:
2026-05-18 02:28:05 +03:00
parent 302e01d705
commit 811b6ef51a
27 changed files with 1526 additions and 51 deletions
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
public DbSet<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
public DbSet<LevelThreshold> LevelThresholds { get; set; } = null!;
public DbSet<UserNotification> UserNotifications { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class LevelThresholdConfiguration : IEntityTypeConfiguration<LevelThreshold>
{
public void Configure(EntityTypeBuilder<LevelThreshold> builder)
{
builder.ToTable("level_thresholds", table =>
{
table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
builder.HasKey(t => t.Level);
builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever();
builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired();
builder.HasIndex(t => t.RequiredXp).IsUnique();
builder.HasData(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 },
new LevelThreshold { Level = 4, RequiredXp = 600 },
new LevelThreshold { Level = 5, RequiredXp = 1000 },
new LevelThreshold { Level = 6, RequiredXp = 1500 },
new LevelThreshold { Level = 7, RequiredXp = 2500 },
new LevelThreshold { Level = 8, RequiredXp = 4000 }
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class LevelThresholds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "level_thresholds",
columns: table => new
{
level = table.Column<int>(type: "integer", nullable: false),
required_xp = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_level_thresholds", x => x.level);
table.CheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.CheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
migrationBuilder.InsertData(
table: "level_thresholds",
columns: new[] { "level", "required_xp" },
values: new object[,]
{
{ 1, 0 },
{ 2, 100 },
{ 3, 300 },
{ 4, 600 },
{ 5, 1000 },
{ 6, 1500 },
{ 7, 2500 },
{ 8, 4000 }
});
migrationBuilder.CreateIndex(
name: "IX_level_thresholds_required_xp",
table: "level_thresholds",
column: "required_xp",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "level_thresholds");
}
}
}
@@ -334,6 +334,71 @@ namespace UniVerse.Infrastructure.Migrations
b.ToTable("lecture_enrollments", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.LevelThreshold", b =>
{
b.Property<int>("Level")
.HasColumnType("integer")
.HasColumnName("level");
b.Property<int>("RequiredXp")
.HasColumnType("integer")
.HasColumnName("required_xp");
b.HasKey("Level");
b.HasIndex("RequiredXp")
.IsUnique();
b.ToTable("level_thresholds", null, t =>
{
t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
b.HasData(
new
{
Level = 1,
RequiredXp = 0
},
new
{
Level = 2,
RequiredXp = 100
},
new
{
Level = 3,
RequiredXp = 300
},
new
{
Level = 4,
RequiredXp = 600
},
new
{
Level = 5,
RequiredXp = 1000
},
new
{
Level = 6,
RequiredXp = 1500
},
new
{
Level = 7,
RequiredXp = 2500
},
new
{
Level = 8,
RequiredXp = 4000
});
});
modelBuilder.Entity("UniVerse.Domain.Entities.Location", b =>
{
b.Property<int>("Id")
@@ -221,7 +221,7 @@ public class AuthService : IAuthService
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == userId)
?? throw new NotFoundException("User", userId);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Globalization;
using UniVerse.Application.DTOs.Achievements;
@@ -17,17 +16,16 @@ 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;
private List<LevelThreshold>? _levelThresholds;
public GamificationService(
AppDbContext db,
IConfiguration config,
INotificationService notifications,
ILogger<GamificationService> logger)
{
_db = db; _config = config; _notifications = notifications; _logger = logger;
_db = db; _notifications = notifications; _logger = logger;
}
public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
@@ -83,7 +81,7 @@ public class GamificationService : IGamificationService
"attendance_streak_weeks" => attendanceStreakWeeks >= value,
"attended_registered" => attended >= value,
"coins_earned" => earnedCoins >= value,
"level_reached" => CalculateLevel(user.Xp) >= value,
"level_reached" => await CalculateLevelAsync(user.Xp) >= value,
"profile_completed" => profileCompleted && value <= 1,
"first_activity" => firstActivity && value <= 1,
_ => false
@@ -184,13 +182,47 @@ public class GamificationService : IGamificationService
return DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
}
public int CalculateLevel(int xp)
public async Task<int> CalculateLevelAsync(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;
var thresholds = await GetLevelThresholdsAsync();
return thresholds
.Where(t => xp >= t.RequiredXp)
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.LastOrDefault()?.Level ?? thresholds[0].Level;
}
public async Task<LevelProgressDto> GetLevelProgressAsync(int xp)
{
var thresholds = await GetLevelThresholdsAsync();
var current = thresholds
.Where(t => xp >= t.RequiredXp)
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.LastOrDefault() ?? thresholds[0];
var next = thresholds
.Where(t => t.RequiredXp > current.RequiredXp)
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.FirstOrDefault();
return new LevelProgressDto(current.RequiredXp, next?.RequiredXp);
}
private async Task<List<LevelThreshold>> GetLevelThresholdsAsync()
{
if (_levelThresholds is { Count: > 0 }) return _levelThresholds;
_levelThresholds = await _db.LevelThresholds
.AsNoTracking()
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.ToListAsync();
if (_levelThresholds.Count == 0)
_levelThresholds.Add(new LevelThreshold { Level = 1, RequiredXp = 0 });
return _levelThresholds;
}
public async Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId) =>
@@ -26,7 +26,7 @@ public class UserService : IUserService
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == id)
?? throw new NotFoundException("User", id);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
public async Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request)
@@ -42,7 +42,7 @@ public class UserService : IUserService
await _db.SaveChangesAsync();
await _gamification.CheckAndAwardAchievementsAsync(id);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
public async Task<UserStatsDto> GetStatsAsync(int id)
@@ -55,9 +55,13 @@ public class UserService : IUserService
var reviews = await _db.Reviews.CountAsync(r => r.UserId == id);
var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id);
var level = await _gamification.CalculateLevelAsync(user.Xp);
var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp);
return new UserStatsDto(
totalLectures, attended, reviews,
user.Xp, user.Coins, _gamification.CalculateLevel(user.Xp), achievements
user.Xp, user.Coins, level, achievements,
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp
);
}
@@ -94,7 +98,10 @@ public class UserService : IUserService
.Take(filter.PageSize)
.ToListAsync();
var items = users.Select(u => u.ToDto(_gamification.CalculateLevel(u.Xp))).ToList();
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);
}