feat: Project preready

This commit is contained in:
ereshk1gal
2025-10-01 01:42:16 +03:00
parent 06cb66624c
commit 504f03bd32
37 changed files with 1671 additions and 147 deletions

View File

@@ -2,6 +2,7 @@ using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Models;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
@@ -15,16 +16,26 @@ public class AnalyticsService : IAnalyticsService
public async Task<AnalyticsSummary> GetSummaryAsync(CancellationToken ct = default)
{
var totalUsers = await _uow.Users.Query().CountAsync(ct);
var totalMissions = await _uow.Missions.Query().CountAsync(ct);
var totalStoreItems = await _uow.StoreItems.Query().CountAsync(ct);
var totalExperience = await _uow.Users.Query().SumAsync(u => (long)u.Experience, ct);
return new AnalyticsSummary
try
{
TotalUsers = totalUsers,
TotalMissions = totalMissions,
TotalStoreItems = totalStoreItems,
TotalExperience = totalExperience
};
var totalUsers = await _uow.Users.Query().CountAsync(ct);
var totalMissions = await _uow.Missions.Query().CountAsync(ct);
var totalStoreItems = await _uow.StoreItems.Query().CountAsync(ct);
var totalExperience = await _uow.Players.Query().SumAsync(p => (long)p.Experience, ct);
var completedMissions = await _uow.PlayerMissions.Query(pm => pm.Completed != null).CountAsync(ct);
return new AnalyticsSummary
{
TotalUsers = totalUsers,
TotalMissions = totalMissions,
TotalStoreItems = totalStoreItems,
TotalExperience = totalExperience,
CompletedMissions = completedMissions
};
}
catch (Exception ex)
{
Log.Error(ex, "Failed to build analytics summary");
throw;
}
}
}

View File

@@ -0,0 +1,55 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class DialogueService : IDialogueService
{
private readonly IUnitOfWork _uow;
public DialogueService(IUnitOfWork uow) => _uow = uow;
public async Task<Dialogue?> GetDialogueByMissionIdAsync(Guid missionId)
{
try { return await _uow.Dialogues.Query(d => d.MissionId == missionId).FirstOrDefaultAsync(); }
catch (Exception ex) { Log.Error(ex, "GetDialogueByMissionIdAsync failed {MissionId}", missionId); throw; }
}
public async Task<Dialogue> CreateDialogueAsync(Dialogue dialogue)
{
try
{
dialogue.Id = Guid.NewGuid();
await _uow.Dialogues.AddAsync(dialogue);
await _uow.SaveChangesAsync();
return dialogue;
}
catch (Exception ex) { Log.Error(ex, "CreateDialogueAsync failed {MissionId}", dialogue.MissionId); throw; }
}
public async Task<DialogueMessage?> GetDialogueMessageByIdAsync(Guid messageId)
{
try { return await _uow.DialogueMessages.GetByIdAsync(messageId); }
catch (Exception ex) { Log.Error(ex, "GetDialogueMessageByIdAsync failed {MessageId}", messageId); throw; }
}
public async Task<IEnumerable<DialogueMessageResponseOption>> GetResponseOptionsAsync(Guid messageId)
{
try { return await _uow.DialogueMessageResponseOptions.Query(o => o.ParentDialogueMessageId == messageId).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetResponseOptionsAsync failed {MessageId}", messageId); throw; }
}
public async Task<DialogueMessage?> ProcessDialogueResponseAsync(Guid messageId, Guid responseOptionId, Guid playerId)
{
try
{
var option = await _uow.DialogueMessageResponseOptions.Query(o => o.Id == responseOptionId && o.ParentDialogueMessageId == messageId).FirstOrDefaultAsync();
if (option == null) return null;
if (option.DestinationDialogueMessageId == null) return null; // end branch
return await _uow.DialogueMessages.GetByIdAsync(option.DestinationDialogueMessageId);
}
catch (Exception ex) { Log.Error(ex, "ProcessDialogueResponseAsync failed {MessageId} {ResponseId}", messageId, responseOptionId); throw; }
}
}

View File

@@ -0,0 +1,26 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Contracts;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class InventoryService : IInventoryService
{
private readonly IUnitOfWork _uow;
public InventoryService(IUnitOfWork uow) => _uow = uow;
public async Task<IEnumerable<UserInventoryItem>> GetStoreInventoryAsync(Guid userId, CancellationToken ct = default)
{
try
{
return await _uow.UserInventoryItems.Query(i => i.UserId == userId, null, i => i.StoreItem).ToListAsync(ct);
}
catch (Exception ex)
{
Log.Error(ex, "GetStoreInventoryAsync failed {UserId}", userId);
throw;
}
}
}

View File

@@ -0,0 +1,49 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class MissionCategoryService : IMissionCategoryService
{
private readonly IUnitOfWork _uow;
public MissionCategoryService(IUnitOfWork uow) => _uow = uow;
public async Task<MissionCategory?> GetCategoryByIdAsync(Guid categoryId)
{
try { return await _uow.MissionCategories.GetByIdAsync(categoryId); }
catch (Exception ex) { Log.Error(ex, "GetCategoryByIdAsync failed {CategoryId}", categoryId); throw; }
}
public async Task<MissionCategory?> GetCategoryByTitleAsync(string title)
{
try { return await _uow.MissionCategories.Query(c => c.Title == title).FirstOrDefaultAsync(); }
catch (Exception ex) { Log.Error(ex, "GetCategoryByTitleAsync failed {Title}", title); throw; }
}
public async Task<IEnumerable<MissionCategory>> GetAllCategoriesAsync()
{
try { return await _uow.MissionCategories.Query().OrderBy(c => c.Title).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetAllCategoriesAsync failed"); throw; }
}
public async Task<MissionCategory> CreateCategoryAsync(MissionCategory category)
{
try { category.Id = Guid.NewGuid(); await _uow.MissionCategories.AddAsync(category); await _uow.SaveChangesAsync(); return category; }
catch (Exception ex) { Log.Error(ex, "CreateCategoryAsync failed {Title}", category.Title); throw; }
}
public async Task<MissionCategory> UpdateCategoryAsync(MissionCategory category)
{
try { _uow.MissionCategories.Update(category); await _uow.SaveChangesAsync(); return category; }
catch (Exception ex) { Log.Error(ex, "UpdateCategoryAsync failed {Id}", category.Id); throw; }
}
public async Task<bool> DeleteCategoryAsync(Guid categoryId)
{
try { var c = await _uow.MissionCategories.GetByIdAsync(categoryId); if (c == null) return false; _uow.MissionCategories.Remove(c); await _uow.SaveChangesAsync(); return true; }
catch (Exception ex) { Log.Error(ex, "DeleteCategoryAsync failed {CategoryId}", categoryId); throw; }
}
}

View File

@@ -0,0 +1,165 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Models.DTO;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class MissionService : IMissionService
{
private readonly IUnitOfWork _uow;
private readonly IRewardService _rewardService;
private readonly IRuleValidationService _ruleValidationService;
public MissionService(IUnitOfWork uow, IRewardService rewardService, IRuleValidationService ruleValidationService)
{
_uow = uow;
_rewardService = rewardService;
_ruleValidationService = ruleValidationService;
}
public async Task<Mission?> GetMissionByIdAsync(Guid missionId)
{
try { return await _uow.Missions.GetByIdAsync(missionId); }
catch (Exception ex) { Log.Error(ex, "GetMissionByIdAsync failed {MissionId}", missionId); throw; }
}
public async Task<IEnumerable<Mission>> GetMissionsByCategoryAsync(Guid categoryId)
{
try { return await _uow.Missions.Query(m => m.MissionCategoryId == categoryId).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetMissionsByCategoryAsync failed {Category}", categoryId); throw; }
}
public async Task<IEnumerable<Mission>> GetAvailableMissionsForPlayerAsync(Guid playerId)
{
try
{
var missions = await _uow.Missions.Query().ToListAsync();
var result = new List<Mission>();
foreach (var m in missions)
{
if (await IsMissionAvailableForPlayerAsync(m.Id, playerId)) result.Add(m);
}
return result;
}
catch (Exception ex) { Log.Error(ex, "GetAvailableMissionsForPlayerAsync failed {Player}", playerId); throw; }
}
public async Task<IEnumerable<Mission>> GetChildMissionsAsync(Guid parentMissionId)
{
try { return await _uow.Missions.Query(m => m.ParentMissionId == parentMissionId).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetChildMissionsAsync failed {ParentMission}", parentMissionId); throw; }
}
public async Task<Mission> CreateMissionAsync(Mission mission)
{
try
{
mission.Id = Guid.NewGuid();
await _uow.Missions.AddAsync(mission);
await _uow.SaveChangesAsync();
return mission;
}
catch (Exception ex) { Log.Error(ex, "CreateMissionAsync failed {Title}", mission.Title); throw; }
}
public async Task<Mission> UpdateMissionAsync(Mission mission)
{
try { _uow.Missions.Update(mission); await _uow.SaveChangesAsync(); return mission; }
catch (Exception ex) { Log.Error(ex, "UpdateMissionAsync failed {MissionId}", mission.Id); throw; }
}
public async Task<bool> DeleteMissionAsync(Guid missionId)
{
try { var m = await _uow.Missions.GetByIdAsync(missionId); if (m == null) return false; _uow.Missions.Remove(m); await _uow.SaveChangesAsync(); return true; }
catch (Exception ex) { Log.Error(ex, "DeleteMissionAsync failed {MissionId}", missionId); throw; }
}
public async Task<bool> IsMissionAvailableForPlayerAsync(Guid missionId, Guid playerId)
{
try
{
var mission = await _uow.Missions.GetByIdAsync(missionId);
if (mission == null) return false;
// rule validation
if (!await _ruleValidationService.ValidateMissionRankRulesAsync(missionId, playerId)) return false;
// already completed? then not available
var completed = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId && pm.Completed != null).AnyAsync();
if (completed) return false;
// if parent mission required ensure parent completed
if (mission.ParentMissionId.HasValue)
{
var parentDone = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == mission.ParentMissionId && pm.Completed != null).AnyAsync();
if (!parentDone) return false;
}
return true;
}
catch (Exception ex) { Log.Error(ex, "IsMissionAvailableForPlayerAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; }
}
public async Task<MissionCompletionResult> CompleteMissionAsync(Guid missionId, Guid playerId, object? missionProof = null)
{
try
{
if (!await IsMissionAvailableForPlayerAsync(missionId, playerId))
{
return new MissionCompletionResult { Success = false, Message = "Mission not available" };
}
var mission = await _uow.Missions.GetByIdAsync(missionId) ?? throw new KeyNotFoundException("Mission not found");
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
// snapshot skill levels before
var skillRewards = await _uow.MissionSkillRewards.Query(r => r.MissionId == missionId).ToListAsync();
var beforeSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync();
// mark PlayerMission
var pm = await _uow.PlayerMissions.Query(x => x.PlayerId == playerId && x.MissionId == missionId).FirstOrDefaultAsync();
if (pm == null)
{
pm = new PlayerMission { Id = Guid.NewGuid(), PlayerId = playerId, MissionId = missionId, Started = DateTime.UtcNow };
await _uow.PlayerMissions.AddAsync(pm);
}
pm.Completed = DateTime.UtcNow;
pm.ProgressPercent = 100;
await _uow.SaveChangesAsync();
var prevExp = player.Experience;
var prevMana = player.Mana;
// distribute rewards (XP/Mana/Skills/Items)
await _rewardService.DistributeMissionRewardsAsync(missionId, playerId);
await _uow.SaveChangesAsync();
// build skill progress
var afterSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync();
var skillProgress = new List<SkillProgress>();
foreach (var r in skillRewards)
{
var before = beforeSkills.FirstOrDefault(s => s.SkillId == r.SkillId)?.Score ?? 0;
var after = afterSkills.FirstOrDefault(s => s.SkillId == r.SkillId)?.Score ?? before;
if (after != before)
{
var skill = await _uow.Skills.GetByIdAsync(r.SkillId);
skillProgress.Add(new SkillProgress { SkillId = r.SkillId, SkillTitle = skill?.Title ?? string.Empty, PreviousLevel = before, NewLevel = after });
}
}
return new MissionCompletionResult
{
Success = true,
Message = "Mission completed",
ExperienceGained = player.Experience - prevExp,
ManaGained = player.Mana - prevMana,
SkillsProgress = skillProgress,
UnlockedMissions = (await _uow.Missions.Query(m => m.ParentMissionId == missionId).Select(m => m.Id).ToListAsync())
};
}
catch (Exception ex)
{
Log.Error(ex, "CompleteMissionAsync failed {MissionId} {PlayerId}", missionId, playerId);
throw;
}
}
}

View File

@@ -5,7 +5,6 @@ public class AnalyticsSummary
public int TotalUsers { get; set; }
public int TotalMissions { get; set; }
public int CompletedMissions { get; set; }
public int TotalArtifacts { get; set; }
public int TotalStoreItems { get; set; }
public long TotalExperience { get; set; }
public DateTime GeneratedAtUtc { get; set; } = DateTime.UtcNow;

View File

@@ -12,5 +12,4 @@ public class CreateMissionModel
public int ExperienceReward { get; set; }
public int ManaReward { get; set; }
public List<CompetencyRewardModel> CompetencyRewards { get; set; } = new();
public List<Guid> ArtifactRewardIds { get; set; } = new();
}

View File

@@ -2,6 +2,7 @@ using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Contracts;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
@@ -19,61 +20,49 @@ public class NotificationService : INotificationService
public async Task<Notification> CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Required", nameof(type));
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Required", nameof(title));
if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("Required", nameof(message));
var n = new Notification
try
{
UserId = userId,
Type = type.Trim(),
Title = title.Trim(),
Message = message.Trim(),
CreatedAt = DateTime.UtcNow,
IsRead = false
};
await _uow.Notifications.AddAsync(n, ct);
await _uow.SaveChangesAsync(ct);
return n;
if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Required", nameof(type));
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Required", nameof(title));
if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("Required", nameof(message));
var n = new Notification
{
UserId = userId,
Type = type.Trim(),
Title = title.Trim(),
Message = message.Trim(),
CreatedAt = DateTime.UtcNow,
IsRead = false
};
await _uow.Notifications.AddAsync(n, ct);
await _uow.SaveChangesAsync(ct);
return n;
}
catch (Exception ex) { Log.Error(ex, "Notification CreateAsync failed {UserId}", userId); throw; }
}
public async Task<IEnumerable<Notification>> GetUnreadAsync(Guid userId, CancellationToken ct = default)
{
var query = _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead, q => q.OrderByDescending(x => x.CreatedAt));
return await query.Take(100).ToListAsync(ct);
try { return await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead, q => q.OrderByDescending(x => x.CreatedAt)).Take(100).ToListAsync(ct); }
catch (Exception ex) { Log.Error(ex, "GetUnreadAsync failed {UserId}", userId); throw; }
}
public async Task<IEnumerable<Notification>> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default)
{
if (take <= 0) take = 1;
if (take > 500) take = 500;
var query = _uow.Notifications.Query(n => n.UserId == userId, q => q.OrderByDescending(x => x.CreatedAt));
return await query.Take(take).ToListAsync(ct);
try { if (take <= 0) take = 1; if (take > 500) take = 500; return await _uow.Notifications.Query(n => n.UserId == userId, q => q.OrderByDescending(x => x.CreatedAt)).Take(take).ToListAsync(ct); }
catch (Exception ex) { Log.Error(ex, "GetAllAsync failed {UserId}", userId); throw; }
}
public async Task MarkReadAsync(Guid userId, Guid notificationId, CancellationToken ct = default)
{
var notif = await _uow.Notifications.Query(n => n.Id == notificationId && n.UserId == userId).FirstOrDefaultAsync(ct)
?? throw new KeyNotFoundException("Notification not found");
if (!notif.IsRead)
{
notif.IsRead = true;
notif.ReadAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
try { var notif = await _uow.Notifications.Query(n => n.Id == notificationId && n.UserId == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Notification not found"); if (!notif.IsRead) { notif.IsRead = true; notif.ReadAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); } }
catch (Exception ex) { Log.Error(ex, "MarkReadAsync failed {NotificationId}", notificationId); throw; }
}
public async Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct = default)
{
var unread = await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead).ToListAsync(ct);
if (unread.Count == 0) return 0;
var now = DateTime.UtcNow;
foreach (var n in unread)
{
n.IsRead = true;
n.ReadAt = now;
}
await _uow.SaveChangesAsync(ct);
return unread.Count;
try { var unread = await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead).ToListAsync(ct); if (unread.Count == 0) return 0; var now = DateTime.UtcNow; foreach (var n in unread) { n.IsRead = true; n.ReadAt = now; } await _uow.SaveChangesAsync(ct); return unread.Count; }
catch (Exception ex) { Log.Error(ex, "MarkAllReadAsync failed {UserId}", userId); throw; }
}
}

View File

@@ -0,0 +1,153 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class PlayerService : IPlayerService
{
private readonly IUnitOfWork _uow;
public PlayerService(IUnitOfWork uow) => _uow = uow;
public async Task<Player?> GetPlayerByUserIdAsync(string userId)
{
try
{
if (!Guid.TryParse(userId, out var uid)) return null;
return await _uow.Players.Query(p => p.UserId == uid, null, p => p.Rank).FirstOrDefaultAsync();
}
catch (Exception ex)
{
Log.Error(ex, "GetPlayerByUserIdAsync failed {UserId}", userId);
throw;
}
}
public async Task<Player> CreatePlayerAsync(string userId, string username)
{
try
{
if (!Guid.TryParse(userId, out var uid)) throw new ArgumentException("Invalid user id", nameof(userId));
var existing = await GetPlayerByUserIdAsync(userId);
if (existing != null) return existing;
// pick lowest exp rank
var baseRank = await _uow.Ranks.Query().OrderBy(r => r.ExpNeeded).FirstAsync();
var player = new Player
{
Id = Guid.NewGuid(),
UserId = uid,
RankId = baseRank.Id,
Experience = 0,
Mana = 0
};
await _uow.Players.AddAsync(player);
await _uow.SaveChangesAsync();
Log.Information("Created player {PlayerId} for user {UserId}", player.Id, userId);
return player;
}
catch (Exception ex)
{
Log.Error(ex, "CreatePlayerAsync failed {UserId}", userId);
throw;
}
}
public async Task<Player> UpdatePlayerRankAsync(Guid playerId, Guid newRankId)
{
try
{
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
var rank = await _uow.Ranks.GetByIdAsync(newRankId) ?? throw new KeyNotFoundException("Rank not found");
player.RankId = rank.Id;
await _uow.SaveChangesAsync();
return player;
}
catch (Exception ex)
{
Log.Error(ex, "UpdatePlayerRankAsync failed {PlayerId} -> {RankId}", playerId, newRankId);
throw;
}
}
public async Task<Player> AddPlayerExperienceAsync(Guid playerId, int experience)
{
try
{
if (experience == 0) return await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException();
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
player.Experience += experience;
await _uow.SaveChangesAsync();
await AutoRankUpAsync(player);
return player;
}
catch (Exception ex)
{
Log.Error(ex, "AddPlayerExperienceAsync failed {PlayerId}", playerId);
throw;
}
}
private async Task AutoRankUpAsync(Player player)
{
// find highest rank whose ExpNeeded <= player's experience
var target = await _uow.Ranks.Query(r => r.ExpNeeded <= player.Experience)
.OrderByDescending(r => r.ExpNeeded)
.FirstOrDefaultAsync();
if (target != null && target.Id != player.RankId)
{
player.RankId = target.Id;
await _uow.SaveChangesAsync();
Log.Information("Player {Player} advanced to rank {Rank}", player.Id, target.Title);
}
}
public async Task<Player> AddPlayerManaAsync(Guid playerId, int mana)
{
try
{
if (mana == 0) return await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException();
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
player.Mana += mana;
await _uow.SaveChangesAsync();
return player;
}
catch (Exception ex)
{
Log.Error(ex, "AddPlayerManaAsync failed {PlayerId}", playerId);
throw;
}
}
public async Task<IEnumerable<Player>> GetTopPlayersAsync(int topCount, TimeSpan timeFrame)
{
try
{
// Simple ordering by experience (timeFrame ignored due to no timestamp on Player)
return await _uow.Players.Query()
.OrderByDescending(p => p.Experience)
.Take(topCount)
.ToListAsync();
}
catch (Exception ex)
{
Log.Error(ex, "GetTopPlayersAsync failed");
throw;
}
}
public async Task<Player> GetPlayerWithProgressAsync(Guid playerId)
{
try
{
return await _uow.Players.Query(p => p.Id == playerId, null, p => p.Rank)
.FirstOrDefaultAsync() ?? throw new KeyNotFoundException("Player not found");
}
catch (Exception ex)
{
Log.Error(ex, "GetPlayerWithProgressAsync failed {PlayerId}", playerId);
throw;
}
}
}

View File

@@ -0,0 +1,103 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Models.DTO;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class ProgressTrackingService : IProgressTrackingService
{
private readonly IUnitOfWork _uow;
private readonly IMissionService _missionService;
public ProgressTrackingService(IUnitOfWork uow, IMissionService missionService)
{
_uow = uow;
_missionService = missionService;
}
public async Task<PlayerMission> StartMissionAsync(Guid missionId, Guid playerId)
{
try
{
var existing = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId).FirstOrDefaultAsync();
if (existing != null) return existing;
var pm = new PlayerMission { Id = Guid.NewGuid(), PlayerId = playerId, MissionId = missionId, Started = DateTime.UtcNow, ProgressPercent = 0 };
await _uow.PlayerMissions.AddAsync(pm);
await _uow.SaveChangesAsync();
return pm;
}
catch (Exception ex) { Log.Error(ex, "StartMissionAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; }
}
public async Task<PlayerMission> UpdateMissionProgressAsync(Guid playerMissionId, int progressPercentage, object? proof = null)
{
try
{
if (progressPercentage is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(progressPercentage));
var pm = await _uow.PlayerMissions.GetByIdAsync(playerMissionId) ?? throw new KeyNotFoundException("PlayerMission not found");
if (pm.Completed != null) return pm;
pm.ProgressPercent = progressPercentage;
if (progressPercentage == 100 && pm.Completed == null)
{
// Complete mission through mission service to allocate rewards, etc.
await _missionService.CompleteMissionAsync(pm.MissionId, pm.PlayerId);
}
await _uow.SaveChangesAsync();
return pm;
}
catch (Exception ex) { Log.Error(ex, "UpdateMissionProgressAsync failed {PlayerMissionId}", playerMissionId); throw; }
}
public async Task<PlayerMission> CompleteMissionAsync(Guid playerMissionId, object? proof = null)
{
try
{
var pm = await _uow.PlayerMissions.GetByIdAsync(playerMissionId) ?? throw new KeyNotFoundException("PlayerMission not found");
if (pm.Completed != null) return pm;
await _missionService.CompleteMissionAsync(pm.MissionId, pm.PlayerId, proof);
await _uow.SaveChangesAsync();
return pm;
}
catch (Exception ex) { Log.Error(ex, "CompleteMissionAsync (progress) failed {PlayerMissionId}", playerMissionId); throw; }
}
public async Task<IEnumerable<PlayerMission>> GetPlayerMissionsAsync(Guid playerId)
{
try { return await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetPlayerMissionsAsync failed {PlayerId}", playerId); throw; }
}
public async Task<PlayerMission?> GetPlayerMissionAsync(Guid playerId, Guid missionId)
{
try { return await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId).FirstOrDefaultAsync(); }
catch (Exception ex) { Log.Error(ex, "GetPlayerMissionAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; }
}
public async Task<PlayerProgress> GetPlayerOverallProgressAsync(Guid playerId)
{
try
{
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
var missions = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId).ToListAsync();
var completed = missions.Count(m => m.Completed != null);
var totalMissions = await _uow.Missions.Query().CountAsync();
var skillLevels = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId, null, ps => ps.Skill)
.ToDictionaryAsync(ps => ps.Skill.Title, ps => ps.Score);
return new PlayerProgress
{
PlayerId = playerId,
PlayerName = playerId.ToString(),
CurrentRank = await _uow.Ranks.GetByIdAsync(player.RankId),
TotalExperience = player.Experience,
TotalMana = player.Mana,
CompletedMissions = completed,
TotalAvailableMissions = totalMissions,
SkillLevels = skillLevels
};
}
catch (Exception ex) { Log.Error(ex, "GetPlayerOverallProgressAsync failed {PlayerId}", playerId); throw; }
}
}

View File

@@ -0,0 +1,75 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class RankService : IRankService
{
private readonly IUnitOfWork _uow;
public RankService(IUnitOfWork uow) => _uow = uow;
public async Task<Rank?> GetRankByIdAsync(Guid rankId)
{
try { return await _uow.Ranks.GetByIdAsync(rankId); }
catch (Exception ex) { Log.Error(ex, "GetRankByIdAsync failed {RankId}", rankId); throw; }
}
public async Task<Rank?> GetRankByTitleAsync(string title)
{
try { return await _uow.Ranks.Query(r => r.Title == title).FirstOrDefaultAsync(); }
catch (Exception ex) { Log.Error(ex, "GetRankByTitleAsync failed {Title}", title); throw; }
}
public async Task<IEnumerable<Rank>> GetAllRanksAsync()
{
try { return await _uow.Ranks.Query().OrderBy(r => r.ExpNeeded).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetAllRanksAsync failed"); throw; }
}
public async Task<Rank> CreateRankAsync(Rank rank)
{
try { rank.Id = Guid.NewGuid(); await _uow.Ranks.AddAsync(rank); await _uow.SaveChangesAsync(); return rank; }
catch (Exception ex) { Log.Error(ex, "CreateRankAsync failed {Title}", rank.Title); throw; }
}
public async Task<Rank> UpdateRankAsync(Rank rank)
{
try { _uow.Ranks.Update(rank); await _uow.SaveChangesAsync(); return rank; }
catch (Exception ex) { Log.Error(ex, "UpdateRankAsync failed {RankId}", rank.Id); throw; }
}
public async Task<bool> DeleteRankAsync(Guid rankId)
{
try { var r = await _uow.Ranks.GetByIdAsync(rankId); if (r == null) return false; _uow.Ranks.Remove(r); await _uow.SaveChangesAsync(); return true; }
catch (Exception ex) { Log.Error(ex, "DeleteRankAsync failed {RankId}", rankId); throw; }
}
public async Task<bool> CanPlayerAdvanceToRankAsync(Guid playerId, Guid rankId)
{
try {
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
var rank = await _uow.Ranks.GetByIdAsync(rankId) ?? throw new KeyNotFoundException("Rank not found");
if (player.Experience < rank.ExpNeeded) return false;
var missionReqs = await _uow.RankMissionRules.Query(rmr => rmr.RankId == rankId).Select(r => r.MissionId).ToListAsync();
if (missionReqs.Count > 0)
{
var completed = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.Completed != null).Select(pm => pm.MissionId).ToListAsync();
if (missionReqs.Except(completed).Any()) return false;
}
var skillReqs = await _uow.RankSkillRules.Query(rsr => rsr.RankId == rankId).ToListAsync();
if (skillReqs.Count > 0)
{
var playerSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync();
foreach (var req in skillReqs)
{
var ps = playerSkills.FirstOrDefault(s => s.SkillId == req.SkillId);
if (ps == null || ps.Score < req.Min) return false;
}
}
return true; }
catch (Exception ex) { Log.Error(ex, "CanPlayerAdvanceToRankAsync failed {PlayerId}->{RankId}", playerId, rankId); throw; }
}
public async Task<Rank?> GetNextRankAsync(Guid currentRankId)
{
try {
var current = await _uow.Ranks.GetByIdAsync(currentRankId); if (current == null) return null; return await _uow.Ranks.Query(r => r.ExpNeeded > current.ExpNeeded).OrderBy(r => r.ExpNeeded).FirstOrDefaultAsync(); }
catch (Exception ex) { Log.Error(ex, "GetNextRankAsync failed {RankId}", currentRankId); throw; }
}
}

View File

@@ -0,0 +1,92 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class RewardService : IRewardService
{
private readonly IUnitOfWork _uow;
public RewardService(IUnitOfWork uow) => _uow = uow;
public async Task<IEnumerable<MissionSkillReward>> GetMissionSkillRewardsAsync(Guid missionId)
{
try { return await _uow.MissionSkillRewards.Query(r => r.MissionId == missionId, null, r => r.Skill).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetMissionSkillRewardsAsync failed {MissionId}", missionId); throw; }
}
public async Task<IEnumerable<MissionItemReward>> GetMissionItemRewardsAsync(Guid missionId)
{
try { return await _uow.MissionItemRewards.Query(r => r.MissionId == missionId).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetMissionItemRewardsAsync failed {MissionId}", missionId); throw; }
}
public async Task DistributeMissionRewardsAsync(Guid missionId, Guid playerId)
{
try
{
var mission = await _uow.Missions.GetByIdAsync(missionId) ?? throw new KeyNotFoundException("Mission not found");
var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found");
player.Experience += mission.ExpReward;
player.Mana += mission.ManaReward;
// Skill rewards
var skillRewards = await _uow.MissionSkillRewards.Query(r => r.MissionId == missionId).ToListAsync();
foreach (var sr in skillRewards)
{
var ps = await _uow.PlayerSkills.Query(x => x.PlayerId == playerId && x.SkillId == sr.SkillId).FirstOrDefaultAsync();
if (ps == null)
{
ps = new PlayerSkill { Id = Guid.NewGuid(), PlayerId = playerId, SkillId = sr.SkillId, Score = sr.Value };
await _uow.PlayerSkills.AddAsync(ps);
}
else
{
ps.Score += sr.Value;
_uow.PlayerSkills.Update(ps);
}
}
// Item rewards (store items) one each
var itemRewards = await _uow.MissionItemRewards.Query(r => r.MissionId == missionId).ToListAsync();
foreach (var ir in itemRewards)
{
var inv = await _uow.UserInventoryItems.FindAsync(player.UserId, ir.ItemId);
if (inv == null)
{
inv = new UserInventoryItem { UserId = player.UserId, StoreItemId = ir.ItemId, Quantity = 1, AcquiredAt = DateTime.UtcNow };
await _uow.UserInventoryItems.AddAsync(inv);
}
else inv.Quantity += 1;
}
// Mark redeemed
var pm = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId).FirstOrDefaultAsync();
if (pm != null && pm.RewardsRedeemed == null)
{
pm.RewardsRedeemed = DateTime.UtcNow;
_uow.PlayerMissions.Update(pm);
}
}
catch (Exception ex)
{
Log.Error(ex, "DistributeMissionRewardsAsync failed {MissionId} {PlayerId}", missionId, playerId);
throw;
}
}
public async Task<bool> CanClaimRewardAsync(Guid rewardId, Guid playerId)
{
try
{
// Interpret rewardId as missionId; claim if mission completed and rewards not yet redeemed
var pm = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == rewardId).FirstOrDefaultAsync();
if (pm == null || pm.Completed == null) return false;
return pm.RewardsRedeemed == null;
}
catch (Exception ex) { Log.Error(ex, "CanClaimRewardAsync failed {RewardId} {PlayerId}", rewardId, playerId); throw; }
}
}

View File

@@ -0,0 +1,64 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class RuleValidationService : IRuleValidationService
{
private readonly IUnitOfWork _uow;
public RuleValidationService(IUnitOfWork uow) => _uow = uow;
public async Task<bool> ValidateMissionRankRulesAsync(Guid missionId, Guid playerId)
{
try
{
var player = await _uow.Players.GetByIdAsync(playerId);
if (player == null) return false;
var rankRules = await _uow.MissionRankRules.Query(r => r.MissionId == missionId).Select(r => r.RankId).ToListAsync();
if (rankRules.Count == 0) return true; // no restriction
return rankRules.Contains(player.RankId);
}
catch (Exception ex) { Log.Error(ex, "ValidateMissionRankRulesAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; }
}
public async Task<bool> ValidateRankAdvancementRulesAsync(Guid playerId, Guid targetRankId)
{
try
{
var player = await _uow.Players.GetByIdAsync(playerId);
if (player == null) return false;
var rank = await _uow.Ranks.GetByIdAsync(targetRankId);
if (rank == null) return false;
if (player.Experience < rank.ExpNeeded) return false;
// required missions
var missionReqs = await _uow.RankMissionRules.Query(r => r.RankId == targetRankId).Select(r => r.MissionId).ToListAsync();
if (missionReqs.Count > 0)
{
var completed = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.Completed != null).Select(pm => pm.MissionId).ToListAsync();
if (missionReqs.Except(completed).Any()) return false;
}
// required skills
var skillReqs = await _uow.RankSkillRules.Query(r => r.RankId == targetRankId).ToListAsync();
if (skillReqs.Count > 0)
{
var playerSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync();
foreach (var req in skillReqs)
{
var ps = playerSkills.FirstOrDefault(s => s.SkillId == req.SkillId);
if (ps == null || ps.Score < req.Min) return false;
}
}
return true;
}
catch (Exception ex) { Log.Error(ex, "ValidateRankAdvancementRulesAsync failed {PlayerId}->{RankId}", playerId, targetRankId); throw; }
}
public async Task<IEnumerable<MissionRankRule>> GetApplicableRankRulesAsync(Guid missionId)
{
try { return await _uow.MissionRankRules.Query(r => r.MissionId == missionId, null, r => r.Rank).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetApplicableRankRulesAsync failed {MissionId}", missionId); throw; }
}
}

View File

@@ -0,0 +1,59 @@
using LctMonolith.Database.UnitOfWork;
using LctMonolith.Models.Database;
using LctMonolith.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
public class SkillService : ISkillService
{
private readonly IUnitOfWork _uow;
public SkillService(IUnitOfWork uow) => _uow = uow;
public async Task<Skill?> GetSkillByIdAsync(Guid skillId)
{
try { return await _uow.Skills.GetByIdAsync(skillId); }
catch (Exception ex) { Log.Error(ex, "GetSkillByIdAsync failed {SkillId}", skillId); throw; }
}
public async Task<Skill?> GetSkillByTitleAsync(string title)
{
try { return await _uow.Skills.Query(s => s.Title == title).FirstOrDefaultAsync(); }
catch (Exception ex) { Log.Error(ex, "GetSkillByTitleAsync failed {Title}", title); throw; }
}
public async Task<IEnumerable<Skill>> GetAllSkillsAsync()
{
try { return await _uow.Skills.Query().OrderBy(s => s.Title).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetAllSkillsAsync failed"); throw; }
}
public async Task<Skill> CreateSkillAsync(Skill skill)
{
try { skill.Id = Guid.NewGuid(); await _uow.Skills.AddAsync(skill); await _uow.SaveChangesAsync(); return skill; }
catch (Exception ex) { Log.Error(ex, "CreateSkillAsync failed {Title}", skill.Title); throw; }
}
public async Task<Skill> UpdateSkillAsync(Skill skill)
{
try { _uow.Skills.Update(skill); await _uow.SaveChangesAsync(); return skill; }
catch (Exception ex) { Log.Error(ex, "UpdateSkillAsync failed {SkillId}", skill.Id); throw; }
}
public async Task<bool> DeleteSkillAsync(Guid skillId)
{
try { var skill = await _uow.Skills.GetByIdAsync(skillId); if (skill == null) return false; _uow.Skills.Remove(skill); await _uow.SaveChangesAsync(); return true; }
catch (Exception ex) { Log.Error(ex, "DeleteSkillAsync failed {SkillId}", skillId); throw; }
}
public async Task<PlayerSkill> UpdatePlayerSkillAsync(Guid playerId, Guid skillId, int level)
{
try { var ps = await _uow.PlayerSkills.Query(x => x.PlayerId == playerId && x.SkillId == skillId).FirstOrDefaultAsync(); if (ps == null) { ps = new PlayerSkill { Id = Guid.NewGuid(), PlayerId = playerId, SkillId = skillId, Score = level }; await _uow.PlayerSkills.AddAsync(ps); } else { ps.Score = level; _uow.PlayerSkills.Update(ps); } await _uow.SaveChangesAsync(); return ps; }
catch (Exception ex) { Log.Error(ex, "UpdatePlayerSkillAsync failed {PlayerId} {SkillId}", playerId, skillId); throw; }
}
public async Task<IEnumerable<PlayerSkill>> GetPlayerSkillsAsync(Guid playerId)
{
try { return await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId, null, ps => ps.Skill).ToListAsync(); }
catch (Exception ex) { Log.Error(ex, "GetPlayerSkillsAsync failed {PlayerId}", playerId); throw; }
}
public async Task<int> GetPlayerSkillLevelAsync(Guid playerId, Guid skillId)
{
try { var ps = await _uow.PlayerSkills.Query(x => x.PlayerId == playerId && x.SkillId == skillId).FirstOrDefaultAsync(); return ps?.Score ?? 0; }
catch (Exception ex) { Log.Error(ex, "GetPlayerSkillLevelAsync failed {PlayerId} {SkillId}", playerId, skillId); throw; }
}
}

View File

@@ -21,55 +21,42 @@ public class StoreService : IStoreService
public async Task<IEnumerable<StoreItem>> GetActiveItemsAsync(CancellationToken ct = default)
{
return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct);
try { return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct); }
catch (Exception ex) { Log.Error(ex, "GetActiveItemsAsync failed"); throw; }
}
public async Task<UserInventoryItem> PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
var user = await _uow.Users.Query(u => u.Id == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
var item = await _uow.StoreItems.Query(i => i.Id == itemId && i.IsActive).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Item not found or inactive");
var totalPrice = item.Price * quantity;
if (user.Mana < totalPrice) throw new InvalidOperationException("Insufficient mana");
if (item.Stock.HasValue && item.Stock.Value < quantity) throw new InvalidOperationException("Insufficient stock");
user.Mana -= totalPrice;
if (item.Stock.HasValue) item.Stock -= quantity;
var inv = await _uow.UserInventoryItems.FindAsync(userId, itemId);
if (inv == null)
try
{
inv = new UserInventoryItem
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
var player = await _uow.Players.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Player not found for user");
var item = await _uow.StoreItems.Query(i => i.Id == itemId && i.IsActive).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Item not found or inactive");
var totalPrice = item.Price * quantity;
if (player.Mana < totalPrice) throw new InvalidOperationException("Insufficient mana");
if (item.Stock.HasValue && item.Stock.Value < quantity) throw new InvalidOperationException("Insufficient stock");
player.Mana -= totalPrice;
if (item.Stock.HasValue) item.Stock -= quantity;
var inv = await _uow.UserInventoryItems.FindAsync(userId, itemId);
if (inv == null)
{
UserId = userId,
StoreItemId = itemId,
Quantity = quantity,
AcquiredAt = DateTime.UtcNow
};
await _uow.UserInventoryItems.AddAsync(inv, ct);
inv = new UserInventoryItem { UserId = userId, StoreItemId = itemId, Quantity = quantity, AcquiredAt = DateTime.UtcNow };
await _uow.UserInventoryItems.AddAsync(inv, ct);
}
else inv.Quantity += quantity;
await _uow.Transactions.AddAsync(new Transaction { UserId = userId, StoreItemId = itemId, Type = TransactionType.Purchase, ManaAmount = -totalPrice }, ct);
await _uow.EventLogs.AddAsync(new EventLog { Type = EventType.ItemPurchased, UserId = userId, Data = JsonSerializer.Serialize(new { itemId, quantity, totalPrice }) }, ct);
await _uow.SaveChangesAsync(ct);
Log.Information("User {User} purchased {Qty} of {Item}", userId, quantity, itemId);
return inv;
}
else
catch (Exception ex)
{
inv.Quantity += quantity;
Log.Error(ex, "PurchaseAsync failed {UserId} {ItemId}", userId, itemId);
throw;
}
await _uow.Transactions.AddAsync(new Transaction
{
UserId = userId,
StoreItemId = itemId,
Type = TransactionType.Purchase,
ManaAmount = -totalPrice
}, ct);
await _uow.EventLogs.AddAsync(new EventLog
{
Type = EventType.ItemPurchased,
UserId = userId,
Data = JsonSerializer.Serialize(new { itemId, quantity, totalPrice })
}, ct);
await _uow.SaveChangesAsync(ct);
Log.Information("User {User} purchased {Qty} of {Item}", userId, quantity, itemId);
return inv;
}
}

View File

@@ -10,6 +10,7 @@ using LctMonolith.Services.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Serilog;
namespace LctMonolith.Services;
@@ -34,54 +35,78 @@ public class TokenService : ITokenService
public async Task<TokenPair> IssueAsync(AppUser user, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var accessExp = now.AddMinutes(_options.AccessTokenMinutes);
var refreshExp = now.AddDays(_options.RefreshTokenDays);
var claims = new List<Claim>
try
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var jwt = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now,
expires: accessExp,
signingCredentials: _creds);
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
var now = DateTime.UtcNow;
var accessExp = now.AddMinutes(_options.AccessTokenMinutes);
var refreshExp = now.AddDays(_options.RefreshTokenDays);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var jwt = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now,
expires: accessExp,
signingCredentials: _creds);
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
var refreshToken = GenerateSecureToken();
var rt = new RefreshToken
var refreshToken = GenerateSecureToken();
var rt = new RefreshToken
{
Token = refreshToken,
UserId = user.Id,
ExpiresAt = refreshExp
};
await _uow.RefreshTokens.AddAsync(rt, ct);
await _uow.SaveChangesAsync(ct);
return new TokenPair(accessToken, accessExp, refreshToken, refreshExp);
}
catch (Exception ex)
{
Token = refreshToken,
UserId = user.Id,
ExpiresAt = refreshExp
};
await _uow.RefreshTokens.AddAsync(rt, ct);
await _uow.SaveChangesAsync(ct);
return new TokenPair(accessToken, accessExp, refreshToken, refreshExp);
Log.Error(ex, "IssueAsync failed for user {UserId}", user.Id);
throw;
}
}
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default)
{
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow)
throw new SecurityTokenException("Invalid refresh token");
var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found");
token.IsRevoked = true; // rotate
await _uow.SaveChangesAsync(ct);
return await IssueAsync(user, ct);
try
{
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow)
throw new SecurityTokenException("Invalid refresh token");
var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found");
token.IsRevoked = true; // rotate
await _uow.SaveChangesAsync(ct);
return await IssueAsync(user, ct);
}
catch (Exception ex)
{
Log.Error(ex, "RefreshAsync failed");
throw;
}
}
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
if (token == null) return; // idempotent
token.IsRevoked = true;
await _uow.SaveChangesAsync(ct);
try
{
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
if (token == null) return; // idempotent
token.IsRevoked = true;
await _uow.SaveChangesAsync(ct);
}
catch (Exception ex)
{
Log.Error(ex, "RevokeAsync failed");
throw;
}
}
private static string GenerateSecureToken()