feat random bullshit GO!
This commit is contained in:
34
LctMonolith/Services/AnalyticsService.cs
Normal file
34
LctMonolith/Services/AnalyticsService.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using LctMonolith.Infrastructure.UnitOfWork;
|
||||
using LctMonolith.Services.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides aggregated analytics metrics for dashboards.
|
||||
/// </summary>
|
||||
public class AnalyticsService : IAnalyticsService
|
||||
{
|
||||
private readonly IUnitOfWork _uow;
|
||||
public AnalyticsService(IUnitOfWork uow) => _uow = uow;
|
||||
|
||||
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 completedMissions = await _uow.UserMissions.Query(um => um.Status == Domain.Entities.MissionStatus.Completed).CountAsync(ct);
|
||||
var totalArtifacts = await _uow.Artifacts.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
|
||||
{
|
||||
TotalUsers = totalUsers,
|
||||
TotalMissions = totalMissions,
|
||||
CompletedMissions = completedMissions,
|
||||
TotalArtifacts = totalArtifacts,
|
||||
TotalStoreItems = totalStoreItems,
|
||||
TotalExperience = totalExperience
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
161
LctMonolith/Services/GamificationService.cs
Normal file
161
LctMonolith/Services/GamificationService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Linq;
|
||||
using LctMonolith.Domain.Entities;
|
||||
using LctMonolith.Infrastructure.UnitOfWork;
|
||||
using LctMonolith.Services.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles progression logic: mission completion rewards and rank advancement evaluation.
|
||||
/// </summary>
|
||||
public class GamificationService : IGamificationService
|
||||
{
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly INotificationService _notifications;
|
||||
|
||||
public GamificationService(IUnitOfWork uow, INotificationService notifications)
|
||||
{
|
||||
_uow = uow;
|
||||
_notifications = notifications;
|
||||
}
|
||||
|
||||
public async Task<ProgressSnapshot> GetProgressAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _uow.Users
|
||||
.Query(u => u.Id == userId, null, u => u.Rank, u => u.Competencies)
|
||||
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
var ranks = await _uow.Ranks
|
||||
.Query(null, q => q.OrderBy(r => r.Order), r => r.RequiredMissions, r => r.RequiredCompetencies)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var currentOrder = user.Rank?.Order ?? -1;
|
||||
var nextRank = ranks.FirstOrDefault(r => r.Order == currentOrder + 1);
|
||||
var snapshot = new ProgressSnapshot
|
||||
{
|
||||
Experience = user.Experience,
|
||||
Mana = user.Mana,
|
||||
CurrentRankId = user.RankId,
|
||||
CurrentRankName = user.Rank?.Name,
|
||||
NextRankId = nextRank?.Id,
|
||||
NextRankName = nextRank?.Name,
|
||||
RequiredExperienceForNextRank = nextRank?.RequiredExperience
|
||||
};
|
||||
if (nextRank != null)
|
||||
{
|
||||
// Outstanding missions
|
||||
var userMissionIds = await _uow.UserMissions.Query(um => um.UserId == userId).Select(um => um.MissionId).ToListAsync(ct);
|
||||
foreach (var rm in nextRank.RequiredMissions)
|
||||
{
|
||||
if (!userMissionIds.Contains(rm.MissionId))
|
||||
snapshot.OutstandingMissionIds.Add(rm.MissionId);
|
||||
}
|
||||
// Outstanding competencies
|
||||
foreach (var rc in nextRank.RequiredCompetencies)
|
||||
{
|
||||
var userComp = user.Competencies.FirstOrDefault(c => c.CompetencyId == rc.CompetencyId);
|
||||
var level = userComp?.Level ?? 0;
|
||||
if (level < rc.MinLevel)
|
||||
{
|
||||
snapshot.OutstandingCompetencies.Add(new OutstandingCompetency
|
||||
{
|
||||
CompetencyId = rc.CompetencyId,
|
||||
CompetencyName = rc.Competency?.Name,
|
||||
RequiredLevel = rc.MinLevel,
|
||||
CurrentLevel = level
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async Task ApplyMissionCompletionAsync(Guid userId, Mission mission, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _uow.Users
|
||||
.Query(u => u.Id == userId, null, u => u.Competencies, u => u.Rank)
|
||||
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
user.Experience += mission.ExperienceReward;
|
||||
user.Mana += mission.ManaReward;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Competency rewards
|
||||
var compRewards = await _uow.MissionCompetencyRewards.Query(m => m.MissionId == mission.Id).ToListAsync(ct);
|
||||
foreach (var reward in compRewards)
|
||||
{
|
||||
var uc = user.Competencies.FirstOrDefault(c => c.CompetencyId == reward.CompetencyId);
|
||||
if (uc == null)
|
||||
{
|
||||
uc = new UserCompetency
|
||||
{
|
||||
UserId = userId,
|
||||
CompetencyId = reward.CompetencyId,
|
||||
Level = reward.LevelDelta,
|
||||
ProgressPoints = reward.ProgressPointsDelta
|
||||
};
|
||||
await _uow.UserCompetencies.AddAsync(uc, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
uc.Level += reward.LevelDelta;
|
||||
uc.ProgressPoints += reward.ProgressPointsDelta;
|
||||
}
|
||||
}
|
||||
|
||||
// Artifacts
|
||||
var artRewards = await _uow.MissionArtifactRewards.Query(m => m.MissionId == mission.Id).ToListAsync(ct);
|
||||
foreach (var ar in artRewards)
|
||||
{
|
||||
var existing = await _uow.UserArtifacts.FindAsync(userId, ar.ArtifactId);
|
||||
if (existing == null)
|
||||
{
|
||||
await _uow.UserArtifacts.AddAsync(new UserArtifact
|
||||
{
|
||||
UserId = userId,
|
||||
ArtifactId = ar.ArtifactId,
|
||||
ObtainedAt = DateTime.UtcNow
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
await EvaluateRankUpgradeAsync(userId, ct);
|
||||
}
|
||||
|
||||
public async Task EvaluateRankUpgradeAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _uow.Users
|
||||
.Query(u => u.Id == userId, null, u => u.Rank, u => u.Competencies)
|
||||
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
var ranks = await _uow.Ranks
|
||||
.Query(null, q => q.OrderBy(r => r.Order), r => r.RequiredMissions, r => r.RequiredCompetencies)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var currentOrder = user.Rank?.Order ?? -1;
|
||||
var nextRank = ranks.FirstOrDefault(r => r.Order == currentOrder + 1);
|
||||
if (nextRank == null) return;
|
||||
|
||||
if (user.Experience < nextRank.RequiredExperience) return;
|
||||
var completedMissionIds = await _uow.UserMissions
|
||||
.Query(um => um.UserId == userId && um.Status == MissionStatus.Completed)
|
||||
.Select(x => x.MissionId)
|
||||
.ToListAsync(ct);
|
||||
if (nextRank.RequiredMissions.Any(rm => !completedMissionIds.Contains(rm.MissionId))) return;
|
||||
foreach (var rc in nextRank.RequiredCompetencies)
|
||||
{
|
||||
var uc = user.Competencies.FirstOrDefault(c => c.CompetencyId == rc.CompetencyId);
|
||||
if (uc == null || uc.Level < rc.MinLevel)
|
||||
return;
|
||||
}
|
||||
user.RankId = nextRank.Id;
|
||||
user.Rank = nextRank;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
Log.Information("User {UserId} promoted to rank {Rank}", userId, nextRank.Name);
|
||||
await _notifications.CreateAsync(userId, "rank", "Повышение ранга", $"Вы получили ранг '{nextRank.Name}'", ct);
|
||||
}
|
||||
}
|
||||
3
LctMonolith/Services/IWeatherService.cs
Normal file
3
LctMonolith/Services/IWeatherService.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Removed legacy Weather service placeholder.
|
||||
// Intentionally left blank.
|
||||
|
||||
58
LctMonolith/Services/Interfaces.cs
Normal file
58
LctMonolith/Services/Interfaces.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using LctMonolith.Domain.Entities;
|
||||
using LctMonolith.Services.Models;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>Service for issuing JWT and refresh tokens.</summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
Task<TokenPair> IssueAsync(AppUser user, CancellationToken ct = default);
|
||||
Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default);
|
||||
Task RevokeAsync(string refreshToken, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Gamification progression logic (awards, rank upgrade).</summary>
|
||||
public interface IGamificationService
|
||||
{
|
||||
Task<ProgressSnapshot> GetProgressAsync(Guid userId, CancellationToken ct = default);
|
||||
Task ApplyMissionCompletionAsync(Guid userId, Mission mission, CancellationToken ct = default);
|
||||
Task EvaluateRankUpgradeAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Mission management and user mission state transitions.</summary>
|
||||
public interface IMissionService
|
||||
{
|
||||
Task<Mission> CreateMissionAsync(CreateMissionModel model, CancellationToken ct = default);
|
||||
Task<IEnumerable<Mission>> GetAvailableMissionsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<UserMission> UpdateStatusAsync(Guid userId, Guid missionId, MissionStatus status, string? submissionData, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Store and inventory operations.</summary>
|
||||
public interface IStoreService
|
||||
{
|
||||
Task<IEnumerable<StoreItem>> GetActiveItemsAsync(CancellationToken ct = default);
|
||||
Task<UserInventoryItem> PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>User notifications (in-app) management.</summary>
|
||||
public interface INotificationService
|
||||
{
|
||||
Task<Notification> CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default);
|
||||
Task<IEnumerable<Notification>> GetUnreadAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IEnumerable<Notification>> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default);
|
||||
Task MarkReadAsync(Guid userId, Guid notificationId, CancellationToken ct = default);
|
||||
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Inventory querying (owned artifacts, store items).</summary>
|
||||
public interface IInventoryService
|
||||
{
|
||||
Task<IEnumerable<UserInventoryItem>> GetStoreInventoryAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IEnumerable<UserArtifact>> GetArtifactsAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Basic analytics / aggregated metrics.</summary>
|
||||
public interface IAnalyticsService
|
||||
{
|
||||
Task<AnalyticsSummary> GetSummaryAsync(CancellationToken ct = default);
|
||||
}
|
||||
35
LctMonolith/Services/InventoryService.cs
Normal file
35
LctMonolith/Services/InventoryService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using LctMonolith.Domain.Entities;
|
||||
using LctMonolith.Infrastructure.UnitOfWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides read-only access to user-owned inventory (store items and artifacts).
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
return await _uow.UserInventoryItems
|
||||
.Query(ii => ii.UserId == userId, null, ii => ii.StoreItem)
|
||||
.OrderByDescending(i => i.AcquiredAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UserArtifact>> GetArtifactsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
return await _uow.UserArtifacts
|
||||
.Query(a => a.UserId == userId, null, a => a.Artifact)
|
||||
.OrderByDescending(a => a.ObtainedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
112
LctMonolith/Services/MissionService.cs
Normal file
112
LctMonolith/Services/MissionService.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using LctMonolith.Domain.Entities;
|
||||
using LctMonolith.Infrastructure.UnitOfWork;
|
||||
using LctMonolith.Services.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mission management and user mission state transitions.
|
||||
/// </summary>
|
||||
public class MissionService : IMissionService
|
||||
{
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGamificationService _gamification;
|
||||
|
||||
public MissionService(IUnitOfWork uow, IGamificationService gamification)
|
||||
{
|
||||
_uow = uow;
|
||||
_gamification = gamification;
|
||||
}
|
||||
|
||||
public async Task<Mission> CreateMissionAsync(CreateMissionModel model, CancellationToken ct = default)
|
||||
{
|
||||
var mission = new Mission
|
||||
{
|
||||
Title = model.Title,
|
||||
Description = model.Description,
|
||||
Branch = model.Branch,
|
||||
Category = model.Category,
|
||||
MinRankId = model.MinRankId,
|
||||
ExperienceReward = model.ExperienceReward,
|
||||
ManaReward = model.ManaReward,
|
||||
IsActive = true
|
||||
};
|
||||
await _uow.Missions.AddAsync(mission, ct);
|
||||
foreach (var cr in model.CompetencyRewards)
|
||||
{
|
||||
await _uow.MissionCompetencyRewards.AddAsync(new MissionCompetencyReward
|
||||
{
|
||||
MissionId = mission.Id,
|
||||
CompetencyId = cr.CompetencyId,
|
||||
LevelDelta = cr.LevelDelta,
|
||||
ProgressPointsDelta = cr.ProgressPointsDelta
|
||||
}, ct);
|
||||
}
|
||||
foreach (var artId in model.ArtifactRewardIds.Distinct())
|
||||
{
|
||||
await _uow.MissionArtifactRewards.AddAsync(new MissionArtifactReward
|
||||
{
|
||||
MissionId = mission.Id,
|
||||
ArtifactId = artId
|
||||
}, ct);
|
||||
}
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
await LogEventAsync(EventType.MissionStatusChanged, null, new { action = "created", missionId = mission.Id }, ct);
|
||||
return mission;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Mission>> GetAvailableMissionsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _uow.Users.Query(u => u.Id == userId, null, u => u.Rank).FirstOrDefaultAsync(ct)
|
||||
?? throw new KeyNotFoundException("User not found");
|
||||
var missions = await _uow.Missions.Query(m => m.IsActive, null, m => m.MinRank).ToListAsync(ct);
|
||||
var userOrder = user.Rank?.Order ?? int.MinValue;
|
||||
return missions.Where(m => m.MinRank == null || m.MinRank.Order <= userOrder + 1);
|
||||
}
|
||||
|
||||
public async Task<UserMission> UpdateStatusAsync(Guid userId, Guid missionId, MissionStatus status, string? submissionData, CancellationToken ct = default)
|
||||
{
|
||||
var mission = await _uow.Missions.Query(m => m.Id == missionId).FirstOrDefaultAsync(ct)
|
||||
?? throw new KeyNotFoundException("Mission not found");
|
||||
var userMission = await _uow.UserMissions.FindAsync(userId, missionId);
|
||||
if (userMission == null)
|
||||
{
|
||||
userMission = new UserMission
|
||||
{
|
||||
UserId = userId,
|
||||
MissionId = missionId,
|
||||
Status = MissionStatus.Available
|
||||
};
|
||||
await _uow.UserMissions.AddAsync(userMission, ct);
|
||||
}
|
||||
userMission.Status = status;
|
||||
userMission.SubmissionData = submissionData;
|
||||
userMission.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
await LogEventAsync(EventType.MissionStatusChanged, userId, new { missionId, status }, ct);
|
||||
if (status == MissionStatus.Completed)
|
||||
{
|
||||
await _gamification.ApplyMissionCompletionAsync(userId, mission, ct);
|
||||
await LogEventAsync(EventType.RewardGranted, userId, new { missionId, mission.ExperienceReward, mission.ManaReward }, ct);
|
||||
}
|
||||
return userMission;
|
||||
}
|
||||
|
||||
private async Task LogEventAsync(EventType type, Guid? userId, object data, CancellationToken ct)
|
||||
{
|
||||
if (userId == null && type != EventType.MissionStatusChanged) return;
|
||||
var evt = new EventLog
|
||||
{
|
||||
Type = type,
|
||||
UserId = userId ?? Guid.Empty,
|
||||
Data = JsonSerializer.Serialize(data)
|
||||
};
|
||||
await _uow.EventLogs.AddAsync(evt, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
Log.Debug("Event {Type} logged {Data}", type, evt.Data);
|
||||
}
|
||||
}
|
||||
99
LctMonolith/Services/Models/Models.cs
Normal file
99
LctMonolith/Services/Models/Models.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using LctMonolith.Domain.Entities;
|
||||
|
||||
namespace LctMonolith.Services.Models;
|
||||
|
||||
/// <summary>Returned access+refresh token pair with expiry metadata.</summary>
|
||||
public record TokenPair(string AccessToken, DateTime AccessTokenExpiresAt, string RefreshToken, DateTime RefreshTokenExpiresAt);
|
||||
|
||||
/// <summary>Mission creation request model.</summary>
|
||||
public class CreateMissionModel
|
||||
{
|
||||
public string Title { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
public string? Branch { get; set; }
|
||||
public MissionCategory Category { get; set; }
|
||||
public Guid? MinRankId { get; set; }
|
||||
public int ExperienceReward { get; set; }
|
||||
public int ManaReward { get; set; }
|
||||
public List<CompetencyRewardModel> CompetencyRewards { get; set; } = new();
|
||||
public List<Guid> ArtifactRewardIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Competency reward definition for mission creation.</summary>
|
||||
public class CompetencyRewardModel
|
||||
{
|
||||
public Guid CompetencyId { get; set; }
|
||||
public int LevelDelta { get; set; }
|
||||
public int ProgressPointsDelta { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Progress snapshot for UI: rank, xp, remaining requirements.</summary>
|
||||
public class ProgressSnapshot
|
||||
{
|
||||
public int Experience { get; set; }
|
||||
public int Mana { get; set; }
|
||||
public Guid? CurrentRankId { get; set; }
|
||||
public string? CurrentRankName { get; set; }
|
||||
public Guid? NextRankId { get; set; }
|
||||
public string? NextRankName { get; set; }
|
||||
public int? RequiredExperienceForNextRank { get; set; }
|
||||
public int? ExperienceRemaining => RequiredExperienceForNextRank.HasValue ? Math.Max(0, RequiredExperienceForNextRank.Value - Experience) : null;
|
||||
public List<Guid> OutstandingMissionIds { get; set; } = new();
|
||||
public List<OutstandingCompetency> OutstandingCompetencies { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Competency requirement still unmet for next rank.</summary>
|
||||
public class OutstandingCompetency
|
||||
{
|
||||
public Guid CompetencyId { get; set; }
|
||||
public string? CompetencyName { get; set; }
|
||||
public int RequiredLevel { get; set; }
|
||||
public int CurrentLevel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Request to update mission status with optional submission data.</summary>
|
||||
public class UpdateMissionStatusRequest
|
||||
{
|
||||
public MissionStatus Status { get; set; }
|
||||
public string? SubmissionData { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Store purchase request.</summary>
|
||||
public class PurchaseRequest
|
||||
{
|
||||
public Guid ItemId { get; set; }
|
||||
public int Quantity { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>Authentication request (login/register) simple mock.</summary>
|
||||
public class AuthRequest
|
||||
{
|
||||
public string Email { get; set; } = null!;
|
||||
public string Password { get; set; } = null!;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Refresh token request.</summary>
|
||||
public class RefreshRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>Revoke refresh token request.</summary>
|
||||
public class RevokeRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>Analytics summary for admin dashboard.</summary>
|
||||
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;
|
||||
}
|
||||
1
LctMonolith/Services/NotificationService.cs
Normal file
1
LctMonolith/Services/NotificationService.cs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
75
LctMonolith/Services/StoreService.cs
Normal file
75
LctMonolith/Services/StoreService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using LctMonolith.Domain.Entities;
|
||||
using LctMonolith.Infrastructure.UnitOfWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Store purchase operations and inventory management.
|
||||
/// </summary>
|
||||
public class StoreService : IStoreService
|
||||
{
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public StoreService(IUnitOfWork uow)
|
||||
{
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<StoreItem>> GetActiveItemsAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
98
LctMonolith/Services/TokenService.cs
Normal file
98
LctMonolith/Services/TokenService.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using LctMonolith.Application.Options;
|
||||
using LctMonolith.Domain.Entities;
|
||||
using LctMonolith.Infrastructure.UnitOfWork;
|
||||
using LctMonolith.Services.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Issues and refreshes JWT + refresh tokens.
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly JwtOptions _options;
|
||||
private readonly SigningCredentials _creds;
|
||||
|
||||
public TokenService(IUnitOfWork uow, UserManager<AppUser> userManager, IOptions<JwtOptions> options)
|
||||
{
|
||||
_uow = uow;
|
||||
_userManager = userManager;
|
||||
_options = options.Value;
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
|
||||
_creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
}
|
||||
|
||||
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>
|
||||
{
|
||||
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
|
||||
{
|
||||
Token = refreshToken,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = refreshExp
|
||||
};
|
||||
await _uow.RefreshTokens.AddAsync(rt, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new TokenPair(accessToken, accessExp, refreshToken, refreshExp);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[64];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EfAsyncExtensions
|
||||
{
|
||||
public static Task<T?> FirstOrDefaultAsync<T>(this IQueryable<T> query, CancellationToken ct = default) => Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(query, ct);
|
||||
}
|
||||
Reference in New Issue
Block a user