feat: Thêm các tính năng quản lý admin cho Membership và Storage services, cùng với chức năng trao đổi tiền tệ và cập nhật cấu trúc ví trong Wallet service.
This commit is contained in:
@@ -4,6 +4,7 @@ using Hellang.Middleware.ProblemDetails;
|
||||
using IamService.API.Application.Behaviors;
|
||||
using IamService.API.Swagger;
|
||||
using IamService.Infrastructure;
|
||||
using IamService.Infrastructure.Authorization;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -21,6 +22,11 @@ builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
// VI: Thêm Infrastructure services (Identity, Duende IdentityServer, Repositories)
|
||||
builder.Services.AddInfrastructure(builder.Configuration, builder.Environment.EnvironmentName);
|
||||
|
||||
// EN: Add Authorization Policies for Backoffice APIs
|
||||
// VI: Thêm Authorization Policies cho các API Backoffice
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddAuthorizationPolicies();
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IamService.Infrastructure.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Extension methods for configuring Authorization Policies.
|
||||
/// VI: Các extension methods để cấu hình Authorization Policies.
|
||||
/// </summary>
|
||||
public static class AuthorizationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add authorization policies for IAM Service.
|
||||
/// VI: Thêm authorization policies cho IAM Service.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection / Service collection</param>
|
||||
/// <returns>Service collection with authorization policies / Service collection với authorization policies</returns>
|
||||
public static IServiceCollection AddAuthorizationPolicies(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorizationBuilder()
|
||||
// EN: SuperAdmin - Full system access
|
||||
// VI: SuperAdmin - Quyền truy cập toàn hệ thống
|
||||
.AddPolicy("RequireSuperAdmin", policy =>
|
||||
policy.RequireRole("SuperAdmin"))
|
||||
|
||||
// EN: Admin - Platform management (SuperAdmin or Admin)
|
||||
// VI: Admin - Quản lý platform (SuperAdmin hoặc Admin)
|
||||
.AddPolicy("RequireAdmin", policy =>
|
||||
policy.RequireRole("SuperAdmin", "Admin"))
|
||||
|
||||
// EN: Auditor - Read-only audit access
|
||||
// VI: Auditor - Quyền xem audit logs
|
||||
.AddPolicy("RequireAuditor", policy =>
|
||||
policy.RequireRole("SuperAdmin", "Admin", "Auditor"));
|
||||
|
||||
// EN: Register custom authorization handler for OwnerOrAdmin policy
|
||||
// VI: Đăng ký custom authorization handler cho policy OwnerOrAdmin
|
||||
services.AddScoped<IAuthorizationHandler, OwnerOrAdminHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace IamService.Infrastructure.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Authorization requirement for OwnerOrAdmin policy.
|
||||
/// VI: Yêu cầu authorization cho policy OwnerOrAdmin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This requirement allows access if:
|
||||
/// - User has Admin or SuperAdmin role, OR
|
||||
/// - User is accessing their own resource (userId in route matches current user)
|
||||
/// VI: Yêu cầu này cho phép truy cập nếu:
|
||||
/// - User có role Admin hoặc SuperAdmin, HOẶC
|
||||
/// - User đang truy cập resource của chính mình (userId trong route khớp với user hiện tại)
|
||||
/// </remarks>
|
||||
public class OwnerOrAdminRequirement : IAuthorizationRequirement
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Authorization handler for OwnerOrAdmin requirement.
|
||||
/// VI: Authorization handler cho yêu cầu OwnerOrAdmin.
|
||||
/// </summary>
|
||||
public class OwnerOrAdminHandler : AuthorizationHandler<OwnerOrAdminRequirement>
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Initializes a new instance of OwnerOrAdminHandler.
|
||||
/// VI: Khởi tạo instance mới của OwnerOrAdminHandler.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">HTTP context accessor / HTTP context accessor</param>
|
||||
public OwnerOrAdminHandler(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handle the authorization requirement.
|
||||
/// VI: Xử lý yêu cầu authorization.
|
||||
/// </summary>
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
OwnerOrAdminRequirement requirement)
|
||||
{
|
||||
// EN: Admin or SuperAdmin always allowed
|
||||
// VI: Admin hoặc SuperAdmin luôn được phép
|
||||
if (context.User.IsInRole("Admin") || context.User.IsInRole("SuperAdmin"))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// EN: Get current user ID from claims
|
||||
// VI: Lấy user ID hiện tại từ claims
|
||||
var currentUserId = context.User.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrEmpty(currentUserId))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// EN: Get the resource user ID from route
|
||||
// VI: Lấy resource user ID từ route
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// EN: Check if route has 'id' parameter
|
||||
// VI: Kiểm tra route có parameter 'id' không
|
||||
var routeUserId = httpContext.GetRouteValue("id")?.ToString();
|
||||
if (string.IsNullOrEmpty(routeUserId))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// EN: User accessing their own resource
|
||||
// VI: User đang truy cập resource của chính mình
|
||||
if (currentUserId.Equals(routeUserId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new level definition.
|
||||
/// VI: Command để tạo level definition mới.
|
||||
/// </summary>
|
||||
public class CreateLevelDefinitionCommand : IRequest<CreateLevelDefinitionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Level number (1, 2, 3...).
|
||||
/// VI: Số thứ tự level (1, 2, 3...).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(1, 100)]
|
||||
public int LevelNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level name (Bronze, Silver, Gold...).
|
||||
/// VI: Tên level (Bronze, Silver, Gold...).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 1)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Required EXP to reach this level.
|
||||
/// VI: EXP cần thiết để đạt level này.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(0, int.MaxValue)]
|
||||
public int RequiredExp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level description.
|
||||
/// VI: Mô tả level.
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Icon URL for the level badge.
|
||||
/// VI: URL icon cho badge level.
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Badge color in hex format (#CD7F32, #FFD700...).
|
||||
/// VI: Màu badge dạng hex (#CD7F32, #FFD700...).
|
||||
/// </summary>
|
||||
[StringLength(20)]
|
||||
[RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", ErrorMessage = "Invalid hex color format")]
|
||||
public string? BadgeColor { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of creating a level definition.
|
||||
/// VI: Kết quả tạo level definition.
|
||||
/// </summary>
|
||||
public class CreateLevelDefinitionResult
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public int LevelNumber { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int RequiredExp { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for creating a new level definition.
|
||||
/// VI: Handler để tạo level definition mới.
|
||||
/// </summary>
|
||||
public class CreateLevelDefinitionCommandHandler : IRequestHandler<CreateLevelDefinitionCommand, CreateLevelDefinitionResult>
|
||||
{
|
||||
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
|
||||
private readonly ILogger<CreateLevelDefinitionCommandHandler> _logger;
|
||||
|
||||
public CreateLevelDefinitionCommandHandler(
|
||||
ILevelDefinitionRepository levelDefinitionRepository,
|
||||
ILogger<CreateLevelDefinitionCommandHandler> logger)
|
||||
{
|
||||
_levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateLevelDefinitionResult> Handle(CreateLevelDefinitionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Check if level number already exists
|
||||
// VI: Kiểm tra xem level number đã tồn tại chưa
|
||||
var exists = await _levelDefinitionRepository.ExistsByLevelNumberAsync(request.LevelNumber);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException($"Level number {request.LevelNumber} already exists");
|
||||
}
|
||||
|
||||
// EN: Create new level definition
|
||||
// VI: Tạo level definition mới
|
||||
var levelDefinition = new LevelDefinition(
|
||||
request.LevelNumber,
|
||||
request.Name,
|
||||
request.RequiredExp,
|
||||
request.Description,
|
||||
request.IconUrl,
|
||||
request.BadgeColor);
|
||||
|
||||
_levelDefinitionRepository.Add(levelDefinition);
|
||||
await _levelDefinitionRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created level definition {LevelId} with number {LevelNumber} and name {LevelName}",
|
||||
levelDefinition.Id, levelDefinition.LevelNumber, levelDefinition.Name);
|
||||
|
||||
return new CreateLevelDefinitionResult
|
||||
{
|
||||
Id = levelDefinition.Id,
|
||||
LevelNumber = levelDefinition.LevelNumber,
|
||||
Name = levelDefinition.Name,
|
||||
RequiredExp = levelDefinition.RequiredExp,
|
||||
CreatedAt = levelDefinition.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to deactivate a level definition (soft delete).
|
||||
/// VI: Command để vô hiệu hóa level definition (xóa mềm).
|
||||
/// </summary>
|
||||
public class DeactivateLevelDefinitionCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Level definition ID.
|
||||
/// VI: ID của level definition.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public DeactivateLevelDefinitionCommand() { }
|
||||
|
||||
public DeactivateLevelDefinitionCommand(Guid id)
|
||||
{
|
||||
Id = id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for deactivating a level definition (soft delete).
|
||||
/// VI: Handler để vô hiệu hóa level definition (xóa mềm).
|
||||
/// </summary>
|
||||
public class DeactivateLevelDefinitionCommandHandler : IRequestHandler<DeactivateLevelDefinitionCommand, bool>
|
||||
{
|
||||
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
|
||||
private readonly ILogger<DeactivateLevelDefinitionCommandHandler> _logger;
|
||||
|
||||
public DeactivateLevelDefinitionCommandHandler(
|
||||
ILevelDefinitionRepository levelDefinitionRepository,
|
||||
ILogger<DeactivateLevelDefinitionCommandHandler> logger)
|
||||
{
|
||||
_levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(DeactivateLevelDefinitionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get level definition from repository
|
||||
// VI: Lấy level definition từ repository
|
||||
var levelDefinition = await _levelDefinitionRepository.GetAsync(request.Id);
|
||||
if (levelDefinition == null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Level definition {request.Id} not found");
|
||||
}
|
||||
|
||||
// EN: Soft delete by deactivating
|
||||
// VI: Xóa mềm bằng cách deactivate
|
||||
levelDefinition.Deactivate();
|
||||
|
||||
_levelDefinitionRepository.Update(levelDefinition);
|
||||
await _levelDefinitionRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deactivated level definition {LevelId} with number {LevelNumber}",
|
||||
levelDefinition.Id, levelDefinition.LevelNumber);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing level definition.
|
||||
/// VI: Command để cập nhật level definition.
|
||||
/// </summary>
|
||||
public class UpdateLevelDefinitionCommand : IRequest<UpdateLevelDefinitionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Level definition ID.
|
||||
/// VI: ID của level definition.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level name (optional, only updates if provided).
|
||||
/// VI: Tên level (optional, chỉ cập nhật nếu có).
|
||||
/// </summary>
|
||||
[StringLength(100, MinimumLength = 1)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Required EXP (optional, only updates if provided).
|
||||
/// VI: EXP yêu cầu (optional, chỉ cập nhật nếu có).
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? RequiredExp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level description.
|
||||
/// VI: Mô tả level.
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Icon URL for the level badge.
|
||||
/// VI: URL icon cho badge level.
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Badge color in hex format.
|
||||
/// VI: Màu badge dạng hex.
|
||||
/// </summary>
|
||||
[StringLength(20)]
|
||||
[RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", ErrorMessage = "Invalid hex color format")]
|
||||
public string? BadgeColor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether to clear description.
|
||||
/// VI: Có xóa description không.
|
||||
/// </summary>
|
||||
public bool ClearDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether to clear icon URL.
|
||||
/// VI: Có xóa icon URL không.
|
||||
/// </summary>
|
||||
public bool ClearIconUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether to clear badge color.
|
||||
/// VI: Có xóa badge color không.
|
||||
/// </summary>
|
||||
public bool ClearBadgeColor { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of updating a level definition.
|
||||
/// VI: Kết quả cập nhật level definition.
|
||||
/// </summary>
|
||||
public class UpdateLevelDefinitionResult
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public int LevelNumber { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int RequiredExp { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? IconUrl { get; set; }
|
||||
public string? BadgeColor { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for updating an existing level definition.
|
||||
/// VI: Handler để cập nhật level definition.
|
||||
/// </summary>
|
||||
public class UpdateLevelDefinitionCommandHandler : IRequestHandler<UpdateLevelDefinitionCommand, UpdateLevelDefinitionResult>
|
||||
{
|
||||
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
|
||||
private readonly ILogger<UpdateLevelDefinitionCommandHandler> _logger;
|
||||
|
||||
public UpdateLevelDefinitionCommandHandler(
|
||||
ILevelDefinitionRepository levelDefinitionRepository,
|
||||
ILogger<UpdateLevelDefinitionCommandHandler> logger)
|
||||
{
|
||||
_levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<UpdateLevelDefinitionResult> Handle(UpdateLevelDefinitionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get level definition from repository
|
||||
// VI: Lấy level definition từ repository
|
||||
var levelDefinition = await _levelDefinitionRepository.GetAsync(request.Id);
|
||||
if (levelDefinition == null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Level definition {request.Id} not found");
|
||||
}
|
||||
|
||||
// EN: Apply partial updates using domain methods
|
||||
// VI: Áp dụng partial updates qua các domain methods
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
{
|
||||
levelDefinition.UpdateName(request.Name);
|
||||
}
|
||||
|
||||
if (request.RequiredExp.HasValue)
|
||||
{
|
||||
levelDefinition.UpdateRequiredExp(request.RequiredExp.Value);
|
||||
}
|
||||
|
||||
if (request.ClearDescription)
|
||||
{
|
||||
levelDefinition.UpdateDescription(null);
|
||||
}
|
||||
else if (request.Description != null)
|
||||
{
|
||||
levelDefinition.UpdateDescription(request.Description);
|
||||
}
|
||||
|
||||
if (request.ClearIconUrl)
|
||||
{
|
||||
levelDefinition.UpdateIconUrl(null);
|
||||
}
|
||||
else if (request.IconUrl != null)
|
||||
{
|
||||
levelDefinition.UpdateIconUrl(request.IconUrl);
|
||||
}
|
||||
|
||||
if (request.ClearBadgeColor)
|
||||
{
|
||||
levelDefinition.UpdateBadgeColor(null);
|
||||
}
|
||||
else if (request.BadgeColor != null)
|
||||
{
|
||||
levelDefinition.UpdateBadgeColor(request.BadgeColor);
|
||||
}
|
||||
|
||||
_levelDefinitionRepository.Update(levelDefinition);
|
||||
await _levelDefinitionRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated level definition {LevelId} with number {LevelNumber}",
|
||||
levelDefinition.Id, levelDefinition.LevelNumber);
|
||||
|
||||
return new UpdateLevelDefinitionResult
|
||||
{
|
||||
Id = levelDefinition.Id,
|
||||
LevelNumber = levelDefinition.LevelNumber,
|
||||
Name = levelDefinition.Name,
|
||||
RequiredExp = levelDefinition.RequiredExp,
|
||||
Description = levelDefinition.Description,
|
||||
IconUrl = levelDefinition.IconUrl,
|
||||
BadgeColor = levelDefinition.BadgeColor,
|
||||
IsActive = levelDefinition.IsActive,
|
||||
UpdatedAt = levelDefinition.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MembershipService.API.Application.Commands;
|
||||
using MembershipService.API.Application.Queries;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
@@ -43,8 +44,83 @@ public class LevelsController : ControllerBase
|
||||
return Ok(levels);
|
||||
}
|
||||
|
||||
// TODO: Add admin endpoints in Phase 6
|
||||
// POST /api/v1/levels - Create level definition (Admin)
|
||||
// PUT /api/v1/levels/{id} - Update level definition (Admin)
|
||||
// DELETE /api/v1/levels/{id} - Deactivate level (Admin)
|
||||
/// <summary>
|
||||
/// EN: Create a new level definition (Admin only).
|
||||
/// VI: Tạo level definition mới (chỉ Admin).
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[SwaggerOperation(Summary = "Create level definition", Description = "Creates a new level definition (Admin only)")]
|
||||
[SwaggerResponse(201, "Level definition created", typeof(CreateLevelDefinitionResult))]
|
||||
[SwaggerResponse(400, "Invalid request")]
|
||||
[SwaggerResponse(401, "Unauthorized")]
|
||||
[SwaggerResponse(403, "Forbidden - Admin role required")]
|
||||
[SwaggerResponse(409, "Level number already exists")]
|
||||
public async Task<ActionResult<CreateLevelDefinitionResult>> Create(
|
||||
[FromBody] CreateLevelDefinitionCommand command)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetAll), new { }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists"))
|
||||
{
|
||||
return Conflict(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing level definition (Admin only).
|
||||
/// VI: Cập nhật level definition (chỉ Admin).
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[SwaggerOperation(Summary = "Update level definition", Description = "Updates an existing level definition (Admin only)")]
|
||||
[SwaggerResponse(200, "Level definition updated", typeof(UpdateLevelDefinitionResult))]
|
||||
[SwaggerResponse(400, "Invalid request")]
|
||||
[SwaggerResponse(401, "Unauthorized")]
|
||||
[SwaggerResponse(403, "Forbidden - Admin role required")]
|
||||
[SwaggerResponse(404, "Level definition not found")]
|
||||
public async Task<ActionResult<UpdateLevelDefinitionResult>> Update(
|
||||
Guid id,
|
||||
[FromBody] UpdateLevelDefinitionCommand command)
|
||||
{
|
||||
command.Id = id;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(command);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate a level definition (Admin only, soft delete).
|
||||
/// VI: Vô hiệu hóa level definition (chỉ Admin, xóa mềm).
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[SwaggerOperation(Summary = "Deactivate level definition", Description = "Deactivates a level definition - soft delete (Admin only)")]
|
||||
[SwaggerResponse(204, "Level definition deactivated")]
|
||||
[SwaggerResponse(401, "Unauthorized")]
|
||||
[SwaggerResponse(403, "Forbidden - Admin role required")]
|
||||
[SwaggerResponse(404, "Level definition not found")]
|
||||
public async Task<IActionResult> Deactivate(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mediator.Send(new DeactivateLevelDefinitionCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command for Admin to delete a file (bypasses ownership check).
|
||||
/// VI: Command cho Admin xóa file (bỏ qua kiểm tra ownership).
|
||||
/// </summary>
|
||||
public record AdminDeleteFileCommand(
|
||||
Guid FileId,
|
||||
string AdminUserId,
|
||||
string Reason
|
||||
) : IRequest<AdminDeleteFileResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of admin file deletion.
|
||||
/// VI: Kết quả xóa file bởi admin.
|
||||
/// </summary>
|
||||
public record AdminDeleteFileResult(
|
||||
bool Success,
|
||||
string? DeletedFileName,
|
||||
string? FileOwnerUserId,
|
||||
string? Error);
|
||||
@@ -0,0 +1,99 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
using StorageService.Domain.AggregatesModel.QuotaAggregate;
|
||||
using StorageService.Infrastructure.Caching;
|
||||
using StorageService.Infrastructure.Storage;
|
||||
|
||||
namespace StorageService.API.Application.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for AdminDeleteFileCommand.
|
||||
/// VI: Handler cho AdminDeleteFileCommand.
|
||||
/// </summary>
|
||||
public class AdminDeleteFileCommandHandler : IRequestHandler<AdminDeleteFileCommand, AdminDeleteFileResult>
|
||||
{
|
||||
private readonly IFileRepository _fileRepository;
|
||||
private readonly IQuotaRepository _quotaRepository;
|
||||
private readonly IStorageProviderFactory _storageProviderFactory;
|
||||
private readonly IRedisCacheService _cache;
|
||||
private readonly ILogger<AdminDeleteFileCommandHandler> _logger;
|
||||
|
||||
public AdminDeleteFileCommandHandler(
|
||||
IFileRepository fileRepository,
|
||||
IQuotaRepository quotaRepository,
|
||||
IStorageProviderFactory storageProviderFactory,
|
||||
IRedisCacheService cache,
|
||||
ILogger<AdminDeleteFileCommandHandler> logger)
|
||||
{
|
||||
_fileRepository = fileRepository;
|
||||
_quotaRepository = quotaRepository;
|
||||
_storageProviderFactory = storageProviderFactory;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdminDeleteFileResult> Handle(AdminDeleteFileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// EN: Get file metadata / VI: Lấy metadata file
|
||||
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
|
||||
|
||||
if (file == null)
|
||||
{
|
||||
return new AdminDeleteFileResult(false, null, null, "File not found.");
|
||||
}
|
||||
|
||||
var fileName = file.FileName;
|
||||
var fileOwner = file.UserId;
|
||||
var fileSize = file.FileSizeBytes;
|
||||
|
||||
// EN: Log admin action / VI: Log hành động admin
|
||||
_logger.LogWarning(
|
||||
"Admin {AdminId} deleting file {FileId} (owner: {OwnerId}) for reason: {Reason}",
|
||||
request.AdminUserId, request.FileId, fileOwner, request.Reason);
|
||||
|
||||
// EN: Delete from storage provider / VI: Xóa khỏi storage provider
|
||||
var provider = _storageProviderFactory.GetProvider(file.Provider);
|
||||
var deleted = await provider.DeleteAsync(file.BucketName, file.ObjectKey, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
_logger.LogWarning("Failed to delete file from storage: {FileId}", request.FileId);
|
||||
}
|
||||
|
||||
// EN: Soft delete file record / VI: Soft delete record file
|
||||
file.Delete();
|
||||
_fileRepository.Update(file);
|
||||
|
||||
// EN: Update quota / VI: Cập nhật quota
|
||||
var quota = await _quotaRepository.GetByUserIdAsync(fileOwner, cancellationToken);
|
||||
if (quota != null)
|
||||
{
|
||||
quota.RemoveUsage(fileSize);
|
||||
_quotaRepository.Update(quota);
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
// EN: Invalidate caches / VI: Invalidate caches
|
||||
await _cache.DeleteAsync(CacheKeys.FileMetadata(request.FileId), cancellationToken);
|
||||
if (Guid.TryParse(fileOwner, out var ownerId))
|
||||
{
|
||||
await _cache.DeleteAsync(CacheKeys.UserQuota(ownerId), cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"File {FileId} deleted by admin {AdminId}. Reason: {Reason}",
|
||||
request.FileId, request.AdminUserId, request.Reason);
|
||||
|
||||
return new AdminDeleteFileResult(true, fileName, fileOwner, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file {FileId} by admin", request.FileId);
|
||||
return new AdminDeleteFileResult(false, null, null, "An error occurred while deleting the file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command for Admin to revoke a file share.
|
||||
/// VI: Command cho Admin revoke chia sẻ file.
|
||||
/// </summary>
|
||||
public record AdminRevokeShareCommand(
|
||||
Guid ShareId,
|
||||
string AdminUserId,
|
||||
string Reason
|
||||
) : IRequest<AdminRevokeShareResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of admin share revocation.
|
||||
/// VI: Kết quả revoke share bởi admin.
|
||||
/// </summary>
|
||||
public record AdminRevokeShareResult(
|
||||
bool Success,
|
||||
Guid? FileId,
|
||||
string? ShareOwnerUserId,
|
||||
string? Error);
|
||||
@@ -0,0 +1,62 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.FileShareAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for AdminRevokeShareCommand.
|
||||
/// VI: Handler cho AdminRevokeShareCommand.
|
||||
/// </summary>
|
||||
public class AdminRevokeShareCommandHandler : IRequestHandler<AdminRevokeShareCommand, AdminRevokeShareResult>
|
||||
{
|
||||
private readonly IFileShareRepository _shareRepository;
|
||||
private readonly ILogger<AdminRevokeShareCommandHandler> _logger;
|
||||
|
||||
public AdminRevokeShareCommandHandler(
|
||||
IFileShareRepository shareRepository,
|
||||
ILogger<AdminRevokeShareCommandHandler> logger)
|
||||
{
|
||||
_shareRepository = shareRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdminRevokeShareResult> Handle(AdminRevokeShareCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// EN: Get share / VI: Lấy share
|
||||
var share = await _shareRepository.GetByIdAsync(request.ShareId, cancellationToken);
|
||||
|
||||
if (share == null)
|
||||
{
|
||||
return new AdminRevokeShareResult(false, null, null, "Share not found.");
|
||||
}
|
||||
|
||||
var fileId = share.FileId;
|
||||
var shareOwner = share.SharedBy;
|
||||
|
||||
// EN: Log admin action / VI: Log hành động admin
|
||||
_logger.LogWarning(
|
||||
"Admin {AdminId} revoking share {ShareId} (owner: {OwnerId}, file: {FileId}) for reason: {Reason}",
|
||||
request.AdminUserId, request.ShareId, shareOwner, fileId, request.Reason);
|
||||
|
||||
// EN: Revoke share / VI: Thu hồi share
|
||||
share.Revoke();
|
||||
_shareRepository.Update(share);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _shareRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Share {ShareId} revoked by admin {AdminId}. Reason: {Reason}",
|
||||
request.ShareId, request.AdminUserId, request.Reason);
|
||||
|
||||
return new AdminRevokeShareResult(true, fileId, shareOwner, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error revoking share {ShareId} by admin", request.ShareId);
|
||||
return new AdminRevokeShareResult(false, null, null, "An error occurred while revoking the share.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update a user's storage quota (Admin only).
|
||||
/// VI: Command để cập nhật quota storage của user (chỉ Admin).
|
||||
/// </summary>
|
||||
public record UpdateUserQuotaCommand(
|
||||
string TargetUserId,
|
||||
long MaxStorageBytes,
|
||||
int MaxFileCount,
|
||||
string? QuotaTier = null
|
||||
) : IRequest<UpdateUserQuotaResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of quota update operation.
|
||||
/// VI: Kết quả thao tác cập nhật quota.
|
||||
/// </summary>
|
||||
public record UpdateUserQuotaResult(
|
||||
bool Success,
|
||||
QuotaUpdatedDto? Data,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for updated quota information.
|
||||
/// VI: DTO cho thông tin quota đã cập nhật.
|
||||
/// </summary>
|
||||
public record QuotaUpdatedDto(
|
||||
string UserId,
|
||||
long MaxStorageBytes,
|
||||
long UsedStorageBytes,
|
||||
int MaxFileCount,
|
||||
int CurrentFileCount,
|
||||
string? QuotaTier,
|
||||
DateTime UpdatedAt);
|
||||
@@ -0,0 +1,91 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.QuotaAggregate;
|
||||
using StorageService.Infrastructure.Caching;
|
||||
|
||||
namespace StorageService.API.Application.Commands.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateUserQuotaCommand.
|
||||
/// VI: Handler cho UpdateUserQuotaCommand.
|
||||
/// </summary>
|
||||
public class UpdateUserQuotaCommandHandler : IRequestHandler<UpdateUserQuotaCommand, UpdateUserQuotaResult>
|
||||
{
|
||||
private readonly IQuotaRepository _quotaRepository;
|
||||
private readonly IRedisCacheService _cache;
|
||||
private readonly ILogger<UpdateUserQuotaCommandHandler> _logger;
|
||||
|
||||
public UpdateUserQuotaCommandHandler(
|
||||
IQuotaRepository quotaRepository,
|
||||
IRedisCacheService cache,
|
||||
ILogger<UpdateUserQuotaCommandHandler> logger)
|
||||
{
|
||||
_quotaRepository = quotaRepository;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UpdateUserQuotaResult> Handle(UpdateUserQuotaCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// EN: Get user quota / VI: Lấy quota của user
|
||||
var quota = await _quotaRepository.GetByUserIdAsync(request.TargetUserId, cancellationToken);
|
||||
|
||||
if (quota == null)
|
||||
{
|
||||
// EN: Create new quota if not exists / VI: Tạo quota mới nếu chưa có
|
||||
quota = new UserStorageQuota(
|
||||
request.TargetUserId,
|
||||
request.MaxStorageBytes,
|
||||
request.MaxFileCount,
|
||||
request.QuotaTier);
|
||||
await _quotaRepository.AddAsync(quota, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new quota for user {UserId}: MaxStorage={MaxStorageBytes}, MaxFiles={MaxFileCount}, Tier={QuotaTier}",
|
||||
request.TargetUserId, request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EN: Update existing quota / VI: Cập nhật quota hiện có
|
||||
quota.UpdateLimits(request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier);
|
||||
_quotaRepository.Update(quota);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated quota for user {UserId}: MaxStorage={MaxStorageBytes}, MaxFiles={MaxFileCount}, Tier={QuotaTier}",
|
||||
request.TargetUserId, request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier);
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _quotaRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
// EN: Invalidate cache / VI: Invalidate cache
|
||||
if (Guid.TryParse(request.TargetUserId, out var userId))
|
||||
{
|
||||
await _cache.DeleteAsync(CacheKeys.UserQuota(userId), cancellationToken);
|
||||
}
|
||||
|
||||
var result = new QuotaUpdatedDto(
|
||||
quota.UserId,
|
||||
quota.MaxStorageBytes,
|
||||
quota.UsedStorageBytes,
|
||||
quota.MaxFileCount,
|
||||
quota.CurrentFileCount,
|
||||
quota.QuotaTier,
|
||||
quota.LastUpdatedAt);
|
||||
|
||||
return new UpdateUserQuotaResult(true, result, null);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// EN: Business rule violation (e.g., max < current usage) / VI: Vi phạm business rule
|
||||
_logger.LogWarning(ex, "Failed to update quota for user {UserId}: {Error}", request.TargetUserId, ex.Message);
|
||||
return new UpdateUserQuotaResult(false, null, ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating quota for user {UserId}", request.TargetUserId);
|
||||
return new UpdateUserQuotaResult(false, null, "An error occurred while updating the quota.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Queries.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all users' quotas with pagination (Admin only).
|
||||
/// VI: Query lấy quota của tất cả users với phân trang (chỉ Admin).
|
||||
/// </summary>
|
||||
public record GetAllUsersQuotaQuery(
|
||||
int PageNumber = 1,
|
||||
int PageSize = 20,
|
||||
string? QuotaTier = null,
|
||||
double? MinUsagePercentage = null,
|
||||
string? SortBy = null,
|
||||
bool Descending = false
|
||||
) : IRequest<AllUsersQuotaResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for all users quota query.
|
||||
/// VI: Kết quả query quota tất cả users.
|
||||
/// </summary>
|
||||
public record AllUsersQuotaResult(
|
||||
IReadOnlyList<AdminQuotaDto> Items,
|
||||
int TotalCount,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int TotalPages);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for quota with extended admin info.
|
||||
/// VI: DTO quota với thông tin admin mở rộng.
|
||||
/// </summary>
|
||||
public record AdminQuotaDto(
|
||||
Guid Id,
|
||||
string UserId,
|
||||
long MaxStorageBytes,
|
||||
long UsedStorageBytes,
|
||||
long RemainingStorageBytes,
|
||||
int MaxFileCount,
|
||||
int CurrentFileCount,
|
||||
int RemainingFileCount,
|
||||
double UsagePercentage,
|
||||
string? QuotaTier,
|
||||
DateTime LastUpdatedAt,
|
||||
DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get storage statistics (Admin only).
|
||||
/// VI: Query lấy thống kê storage (chỉ Admin).
|
||||
/// </summary>
|
||||
public record GetStorageStatisticsQuery() : IRequest<StorageStatisticsDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for storage statistics.
|
||||
/// VI: DTO cho thống kê storage.
|
||||
/// </summary>
|
||||
public record StorageStatisticsDto(
|
||||
long TotalUsers,
|
||||
long TotalStorageUsedBytes,
|
||||
long TotalStorageAllocatedBytes,
|
||||
long TotalFileCount,
|
||||
double AverageUsagePercentage,
|
||||
Dictionary<string, int> UsersByTier,
|
||||
int UsersNearLimit,
|
||||
int UsersOverLimit);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all files (Admin only).
|
||||
/// VI: Query lấy tất cả files (chỉ Admin).
|
||||
/// </summary>
|
||||
public record AdminGetFilesQuery(
|
||||
int PageNumber = 1,
|
||||
int PageSize = 20,
|
||||
string? UserId = null,
|
||||
string? AccessLevel = null,
|
||||
string? ContentType = null,
|
||||
DateTime? UploadedAfter = null,
|
||||
DateTime? UploadedBefore = null,
|
||||
string? SortBy = null,
|
||||
bool Descending = false
|
||||
) : IRequest<AdminFilesResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for admin files query.
|
||||
/// VI: Kết quả query files cho admin.
|
||||
/// </summary>
|
||||
public record AdminFilesResult(
|
||||
IReadOnlyList<AdminFileDto> Items,
|
||||
int TotalCount,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int TotalPages);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for file with admin info.
|
||||
/// VI: DTO file với thông tin cho admin.
|
||||
/// </summary>
|
||||
public record AdminFileDto(
|
||||
Guid Id,
|
||||
string UserId,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long FileSizeBytes,
|
||||
string Provider,
|
||||
string AccessLevel,
|
||||
string? FolderId,
|
||||
DateTime UploadedAt,
|
||||
DateTime? LastAccessedAt,
|
||||
bool IsDeleted);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all file shares (Admin only).
|
||||
/// VI: Query lấy tất cả file shares (chỉ Admin).
|
||||
/// </summary>
|
||||
public record AdminGetSharesQuery(
|
||||
int PageNumber = 1,
|
||||
int PageSize = 20,
|
||||
string? Status = null,
|
||||
string? SharedBy = null
|
||||
) : IRequest<AdminSharesResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for admin shares query.
|
||||
/// VI: Kết quả query shares cho admin.
|
||||
/// </summary>
|
||||
public record AdminSharesResult(
|
||||
IReadOnlyList<AdminShareDto> Items,
|
||||
int TotalCount,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int TotalPages);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for share with admin info.
|
||||
/// VI: DTO share với thông tin cho admin.
|
||||
/// </summary>
|
||||
public record AdminShareDto(
|
||||
Guid Id,
|
||||
Guid FileId,
|
||||
string SharedBy,
|
||||
string? SharedWith,
|
||||
string Permission,
|
||||
string Status,
|
||||
int DownloadCount,
|
||||
int? MaxDownloads,
|
||||
DateTime? ExpiresAt,
|
||||
DateTime CreatedAt,
|
||||
DateTime? RevokedAt);
|
||||
@@ -5,8 +5,8 @@ using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateWalletCommand
|
||||
/// VI: Handler cho CreateWalletCommand
|
||||
/// EN: Handler for CreateWalletCommand.
|
||||
/// VI: Handler cho CreateWalletCommand.
|
||||
/// </summary>
|
||||
public class CreateWalletCommandHandler : IRequestHandler<CreateWalletCommand, CreateWalletResult>
|
||||
{
|
||||
@@ -33,22 +33,40 @@ public class CreateWalletCommandHandler : IRequestHandler<CreateWalletCommand, C
|
||||
throw new WalletDomainException($"User {request.UserId} already has a wallet");
|
||||
}
|
||||
|
||||
// EN: Create new wallet
|
||||
// VI: Tạo ví mới
|
||||
var wallet = new Wallet(request.UserId, request.Currency);
|
||||
// EN: Parse currency string to CurrencyType
|
||||
// VI: Parse chuỗi tiền tệ sang CurrencyType
|
||||
CurrencyType currencyType;
|
||||
try
|
||||
{
|
||||
currencyType = CurrencyType.FromDisplayName<CurrencyType>(request.Currency.ToUpperInvariant());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// EN: Default to VND if currency not found
|
||||
// VI: Mặc định là VND nếu không tìm thấy tiền tệ
|
||||
currencyType = CurrencyType.VND;
|
||||
}
|
||||
|
||||
// EN: Create new wallet with CurrencyType
|
||||
// VI: Tạo ví mới với CurrencyType
|
||||
var wallet = new Wallet(request.UserId, currencyType);
|
||||
|
||||
_walletRepository.Add(wallet);
|
||||
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created wallet {WalletId} for user {UserId}",
|
||||
wallet.Id, request.UserId);
|
||||
"Created wallet {WalletId} for user {UserId} with currency {Currency}",
|
||||
wallet.Id, request.UserId, currencyType.Name);
|
||||
|
||||
// EN: Get default currency balance
|
||||
// VI: Lấy số dư tiền tệ mặc định
|
||||
var defaultBalance = wallet.GetBalance(currencyType);
|
||||
|
||||
return new CreateWalletResult(
|
||||
wallet.Id,
|
||||
wallet.UserId,
|
||||
wallet.Balance.Amount,
|
||||
wallet.Balance.Currency,
|
||||
defaultBalance,
|
||||
currencyType.Name,
|
||||
wallet.Status.Name,
|
||||
wallet.CreatedAt
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DepositCommand
|
||||
/// VI: Handler cho DepositCommand
|
||||
/// EN: Handler for DepositCommand.
|
||||
/// VI: Handler cho DepositCommand.
|
||||
/// </summary>
|
||||
public class DepositCommandHandler : IRequestHandler<DepositCommand, TransactionResult>
|
||||
{
|
||||
@@ -30,13 +30,13 @@ public class DepositCommandHandler : IRequestHandler<DepositCommand, Transaction
|
||||
var wallet = await _walletRepository.GetByUserIdAsync(request.UserId)
|
||||
?? throw new WalletDomainException($"Wallet not found for user {request.UserId}");
|
||||
|
||||
// EN: Create money value object
|
||||
// VI: Tạo value object Money
|
||||
var amount = new Money(request.Amount, wallet.Balance.Currency);
|
||||
// EN: Use default currency type
|
||||
// VI: Sử dụng loại tiền tệ mặc định
|
||||
var currencyType = wallet.DefaultCurrencyType ?? CurrencyType.VND;
|
||||
|
||||
// EN: Perform deposit
|
||||
// VI: Thực hiện nạp tiền
|
||||
wallet.Deposit(amount, request.Description, request.ReferenceId);
|
||||
// EN: Perform deposit with CurrencyType
|
||||
// VI: Thực hiện nạp tiền với CurrencyType
|
||||
wallet.Deposit(request.Amount, currencyType, request.Description, request.ReferenceId);
|
||||
|
||||
_walletRepository.Update(wallet);
|
||||
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
@@ -47,7 +47,7 @@ public class DepositCommandHandler : IRequestHandler<DepositCommand, Transaction
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deposited {Amount} {Currency} to wallet {WalletId}",
|
||||
request.Amount, wallet.Balance.Currency, wallet.Id);
|
||||
request.Amount, currencyType.Name, wallet.Id);
|
||||
|
||||
return new TransactionResult(
|
||||
transaction.Id,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace WalletService.API.Application.Commands;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to exchange currency within a wallet.
|
||||
/// VI: Command để quy đổi tiền tệ trong ví.
|
||||
/// </summary>
|
||||
public record ExchangeCommand(
|
||||
Guid UserId,
|
||||
decimal FromAmount,
|
||||
int FromCurrencyTypeId,
|
||||
int ToCurrencyTypeId,
|
||||
decimal? CustomRate = null
|
||||
) : IRequest<ExchangeResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of exchange operation.
|
||||
/// VI: Kết quả của thao tác quy đổi.
|
||||
/// </summary>
|
||||
public record ExchangeResult(
|
||||
Guid WalletId,
|
||||
decimal FromAmount,
|
||||
string FromCurrency,
|
||||
decimal ToAmount,
|
||||
string ToCurrency,
|
||||
decimal ExchangeRate,
|
||||
DateTime ExchangedAt
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace WalletService.API.Application.Commands;
|
||||
|
||||
using MediatR;
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ExchangeCommand.
|
||||
/// VI: Handler cho ExchangeCommand.
|
||||
/// </summary>
|
||||
public class ExchangeCommandHandler : IRequestHandler<ExchangeCommand, ExchangeResult>
|
||||
{
|
||||
private readonly IWalletRepository _walletRepository;
|
||||
private readonly ILogger<ExchangeCommandHandler> _logger;
|
||||
|
||||
public ExchangeCommandHandler(
|
||||
IWalletRepository walletRepository,
|
||||
ILogger<ExchangeCommandHandler> logger)
|
||||
{
|
||||
_walletRepository = walletRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ExchangeResult> Handle(
|
||||
ExchangeCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get wallet by user ID
|
||||
// VI: Lấy ví theo user ID
|
||||
var wallet = await _walletRepository.GetByUserIdAsync(request.UserId)
|
||||
?? throw new WalletDomainException($"Wallet not found for user {request.UserId}");
|
||||
|
||||
// EN: Parse currency types from IDs
|
||||
// VI: Parse loại tiền tệ từ IDs
|
||||
var fromCurrency = Enumeration.FromValue<CurrencyType>(request.FromCurrencyTypeId);
|
||||
var toCurrency = Enumeration.FromValue<CurrencyType>(request.ToCurrencyTypeId);
|
||||
|
||||
// EN: Calculate actual rate
|
||||
// VI: Tính tỷ giá thực tế
|
||||
var rate = request.CustomRate ?? fromCurrency.GetExchangeRateTo(toCurrency);
|
||||
|
||||
// EN: Perform exchange (atomic operation within aggregate)
|
||||
// VI: Thực hiện quy đổi (thao tác atomic trong aggregate)
|
||||
var receivedAmount = wallet.Exchange(request.FromAmount, fromCurrency, toCurrency, request.CustomRate);
|
||||
|
||||
_walletRepository.Update(wallet);
|
||||
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exchanged {FromAmount} {FromCurrency} to {ToAmount} {ToCurrency} in wallet {WalletId}",
|
||||
request.FromAmount, fromCurrency.Name, receivedAmount, toCurrency.Name, wallet.Id);
|
||||
|
||||
return new ExchangeResult(
|
||||
wallet.Id,
|
||||
request.FromAmount,
|
||||
fromCurrency.Name,
|
||||
receivedAmount,
|
||||
toCurrency.Name,
|
||||
rate,
|
||||
DateTime.UtcNow
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for WithdrawCommand
|
||||
/// VI: Handler cho WithdrawCommand
|
||||
/// EN: Handler for WithdrawCommand.
|
||||
/// VI: Handler cho WithdrawCommand.
|
||||
/// </summary>
|
||||
public class WithdrawCommandHandler : IRequestHandler<WithdrawCommand, TransactionResult>
|
||||
{
|
||||
@@ -28,11 +28,13 @@ public class WithdrawCommandHandler : IRequestHandler<WithdrawCommand, Transacti
|
||||
var wallet = await _walletRepository.GetByUserIdAsync(request.UserId)
|
||||
?? throw new WalletDomainException($"Wallet not found for user {request.UserId}");
|
||||
|
||||
var amount = new Money(request.Amount, wallet.Balance.Currency);
|
||||
// EN: Use default currency type
|
||||
// VI: Sử dụng loại tiền tệ mặc định
|
||||
var currencyType = wallet.DefaultCurrencyType ?? CurrencyType.VND;
|
||||
|
||||
// EN: Perform withdrawal (may throw InsufficientBalanceException)
|
||||
// VI: Thực hiện rút tiền (có thể throw InsufficientBalanceException)
|
||||
wallet.Withdraw(amount, request.Description, request.ReferenceId);
|
||||
// EN: Perform withdrawal with CurrencyType (may throw InsufficientBalanceException)
|
||||
// VI: Thực hiện rút tiền với CurrencyType (có thể throw InsufficientBalanceException)
|
||||
wallet.Withdraw(request.Amount, currencyType, request.Description, request.ReferenceId);
|
||||
|
||||
_walletRepository.Update(wallet);
|
||||
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
@@ -41,7 +43,7 @@ public class WithdrawCommandHandler : IRequestHandler<WithdrawCommand, Transacti
|
||||
|
||||
_logger.LogInformation(
|
||||
"Withdrew {Amount} {Currency} from wallet {WalletId}",
|
||||
request.Amount, wallet.Balance.Currency, wallet.Id);
|
||||
request.Amount, currencyType.Name, wallet.Id);
|
||||
|
||||
return new TransactionResult(
|
||||
transaction.Id,
|
||||
|
||||
@@ -4,8 +4,8 @@ using MediatR;
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetWalletQuery
|
||||
/// VI: Handler cho GetWalletQuery
|
||||
/// EN: Handler for GetWalletQuery.
|
||||
/// VI: Handler cho GetWalletQuery.
|
||||
/// </summary>
|
||||
public class GetWalletQueryHandler : IRequestHandler<GetWalletQuery, WalletDto?>
|
||||
{
|
||||
@@ -23,11 +23,16 @@ public class GetWalletQueryHandler : IRequestHandler<GetWalletQuery, WalletDto?>
|
||||
if (wallet == null)
|
||||
return null;
|
||||
|
||||
// EN: Get balance for default currency
|
||||
// VI: Lấy số dư cho tiền tệ mặc định
|
||||
var currencyType = wallet.DefaultCurrencyType ?? CurrencyType.VND;
|
||||
var balance = wallet.GetBalance(currencyType);
|
||||
|
||||
return new WalletDto(
|
||||
wallet.Id,
|
||||
wallet.UserId,
|
||||
wallet.Balance.Amount,
|
||||
wallet.Balance.Currency,
|
||||
balance,
|
||||
currencyType.Name,
|
||||
wallet.Status.Name,
|
||||
wallet.CreatedAt,
|
||||
wallet.UpdatedAt
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency type enumeration with exchange rate capabilities.
|
||||
/// VI: Enumeration loại tiền tệ với khả năng quy đổi tỷ giá.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: PPoint is treated as a currency type to enable seamless exchange with other currencies.
|
||||
/// VI: PPoint được coi như một loại tiền tệ để cho phép quy đổi liền mạch với các tiền tệ khác.
|
||||
/// </remarks>
|
||||
public class CurrencyType : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Vietnamese Dong - base currency.
|
||||
/// VI: Đồng Việt Nam - tiền tệ cơ sở.
|
||||
/// </summary>
|
||||
public static CurrencyType VND = new(1, nameof(VND), "Vietnamese Dong", 1m);
|
||||
|
||||
/// <summary>
|
||||
/// EN: US Dollar.
|
||||
/// VI: Đô la Mỹ.
|
||||
/// </summary>
|
||||
public static CurrencyType USD = new(2, nameof(USD), "US Dollar", 25000m);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Loyalty points - exchangeable with currency.
|
||||
/// VI: Điểm thưởng - có thể quy đổi với tiền tệ.
|
||||
/// </summary>
|
||||
public static CurrencyType PPoint = new(3, nameof(PPoint), "Loyalty Points", 1000m);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Display name of the currency.
|
||||
/// VI: Tên hiển thị của loại tiền tệ.
|
||||
/// </summary>
|
||||
public string DisplayName { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base exchange rate to VND (1 unit of this currency = X VND).
|
||||
/// VI: Tỷ giá cơ sở quy đổi sang VND (1 đơn vị tiền này = X VND).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: VND = 1, USD = 25000 (1 USD = 25000 VND), PPoint = 1000 (1 PPoint = 1000 VND).
|
||||
/// VI: VND = 1, USD = 25000 (1 USD = 25000 VND), PPoint = 1000 (1 PPoint = 1000 VND).
|
||||
/// </remarks>
|
||||
public decimal BaseExchangeRate { get; private set; }
|
||||
|
||||
protected CurrencyType() : base(0, "Unknown")
|
||||
{
|
||||
DisplayName = "Unknown";
|
||||
BaseExchangeRate = 0;
|
||||
}
|
||||
|
||||
public CurrencyType(int id, string name, string displayName, decimal baseExchangeRate)
|
||||
: base(id, name)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
BaseExchangeRate = baseExchangeRate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Calculate exchange rate from this currency to another.
|
||||
/// VI: Tính tỷ giá quy đổi từ loại tiền này sang loại khác.
|
||||
/// </summary>
|
||||
/// <param name="toCurrency">Target currency / Tiền tệ đích</param>
|
||||
/// <returns>Exchange rate / Tỷ giá quy đổi</returns>
|
||||
public decimal GetExchangeRateTo(CurrencyType toCurrency)
|
||||
{
|
||||
if (toCurrency.BaseExchangeRate == 0)
|
||||
throw new InvalidOperationException("Cannot exchange to currency with zero base rate");
|
||||
|
||||
return BaseExchangeRate / toCurrency.BaseExchangeRate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Convert amount from this currency to another.
|
||||
/// VI: Quy đổi số tiền từ loại tiền này sang loại khác.
|
||||
/// </summary>
|
||||
public decimal ConvertTo(decimal amount, CurrencyType toCurrency)
|
||||
{
|
||||
var rate = GetExchangeRateTo(toCurrency);
|
||||
return amount * rate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if this is a points-based currency.
|
||||
/// VI: Kiểm tra xem đây có phải là loại tiền dựa trên điểm không.
|
||||
/// </summary>
|
||||
public bool IsPointsBased => Id == PPoint.Id;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if this is a fiat currency.
|
||||
/// VI: Kiểm tra xem đây có phải là tiền pháp định không.
|
||||
/// </summary>
|
||||
public bool IsFiatCurrency => Id == VND.Id || Id == USD.Id;
|
||||
}
|
||||
@@ -5,134 +5,190 @@ using WalletService.Domain.Exceptions;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Wallet aggregate root representing a user's digital wallet
|
||||
/// VI: Aggregate root Ví đại diện cho ví điện tử của người dùng
|
||||
/// EN: Wallet aggregate root representing a user's digital wallet with multi-currency support.
|
||||
/// VI: Aggregate root Ví đại diện cho ví điện tử của người dùng với hỗ trợ đa tiền tệ.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Wallet manages multiple balances (one per currency type) and supports currency exchange.
|
||||
/// VI: Wallet quản lý nhiều số dư (một cho mỗi loại tiền tệ) và hỗ trợ quy đổi tiền tệ.
|
||||
/// </remarks>
|
||||
public class Wallet : Entity, IAggregateRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: User ID from IAM Service
|
||||
/// VI: ID người dùng từ IAM Service
|
||||
/// EN: User ID from IAM Service.
|
||||
/// VI: ID người dùng từ IAM Service.
|
||||
/// </summary>
|
||||
public Guid UserId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current wallet balance
|
||||
/// VI: Số dư ví hiện tại
|
||||
/// EN: Default currency type ID.
|
||||
/// VI: ID loại tiền tệ mặc định.
|
||||
/// </summary>
|
||||
public Money Balance { get; private set; } = null!;
|
||||
public int DefaultCurrencyTypeId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Wallet status
|
||||
/// VI: Trạng thái ví
|
||||
/// EN: Default currency type.
|
||||
/// VI: Loại tiền tệ mặc định.
|
||||
/// </summary>
|
||||
public CurrencyType DefaultCurrencyType { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Legacy balance property - for backward compatibility. Returns default currency balance.
|
||||
/// VI: Thuộc tính số dư kế thừa - để tương thích ngược. Trả về số dư tiền tệ mặc định.
|
||||
/// </summary>
|
||||
[Obsolete("Use GetBalance(CurrencyType) instead for multi-currency support")]
|
||||
public Money Balance => GetLegacyBalance();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Wallet status.
|
||||
/// VI: Trạng thái ví.
|
||||
/// </summary>
|
||||
public WalletStatus Status { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core
|
||||
/// VI: ID trạng thái cho EF Core
|
||||
/// EN: Status ID for EF Core.
|
||||
/// VI: ID trạng thái cho EF Core.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Wallet creation timestamp
|
||||
/// VI: Thời điểm tạo ví
|
||||
/// EN: Wallet creation timestamp.
|
||||
/// VI: Thời điểm tạo ví.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp
|
||||
/// VI: Thời điểm cập nhật cuối
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời điểm cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; private set; }
|
||||
|
||||
private readonly List<WalletItem> _balances = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Multi-currency balances.
|
||||
/// VI: Số dư đa tiền tệ.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<WalletItem> Balances => _balances.AsReadOnly();
|
||||
|
||||
private readonly List<WalletTransaction> _transactions = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Wallet transactions
|
||||
/// VI: Các giao dịch của ví
|
||||
/// EN: Wallet transactions.
|
||||
/// VI: Các giao dịch của ví.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<WalletTransaction> Transactions => _transactions.AsReadOnly();
|
||||
|
||||
protected Wallet() { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new wallet for a user
|
||||
/// VI: Tạo ví mới cho người dùng
|
||||
/// EN: Create a new wallet for a user.
|
||||
/// VI: Tạo ví mới cho người dùng.
|
||||
/// </summary>
|
||||
public Wallet(Guid userId, string currency = "VND")
|
||||
/// <param name="userId">User ID / ID người dùng</param>
|
||||
/// <param name="defaultCurrencyType">Default currency type (default: VND) / Loại tiền tệ mặc định (mặc định: VND)</param>
|
||||
public Wallet(Guid userId, CurrencyType? defaultCurrencyType = null)
|
||||
{
|
||||
var currency = defaultCurrencyType ?? CurrencyType.VND;
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
UserId = userId;
|
||||
Balance = new Money(0, currency);
|
||||
DefaultCurrencyType = currency;
|
||||
DefaultCurrencyTypeId = currency.Id;
|
||||
Status = WalletStatus.Active;
|
||||
StatusId = Status.Id;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Initialize default currency with zero balance
|
||||
_balances.Add(new WalletItem(Id, currency, 0));
|
||||
|
||||
AddDomainEvent(new WalletCreatedDomainEvent(Id, userId));
|
||||
}
|
||||
|
||||
#region Multi-Currency Methods
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deposit money into the wallet
|
||||
/// VI: Nạp tiền vào ví
|
||||
/// EN: Get balance for a specific currency type.
|
||||
/// VI: Lấy số dư cho một loại tiền tệ cụ thể.
|
||||
/// </summary>
|
||||
public void Deposit(Money amount, string description, string? referenceId = null)
|
||||
public decimal GetBalance(CurrencyType currencyType)
|
||||
{
|
||||
var item = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id);
|
||||
return item?.Balance ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get or create a wallet item for a currency type.
|
||||
/// VI: Lấy hoặc tạo wallet item cho một loại tiền tệ.
|
||||
/// </summary>
|
||||
private WalletItem GetOrCreateBalance(CurrencyType currencyType)
|
||||
{
|
||||
var item = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id);
|
||||
if (item == null)
|
||||
{
|
||||
item = new WalletItem(Id, currencyType, 0);
|
||||
_balances.Add(item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deposit amount into wallet for a specific currency.
|
||||
/// VI: Nạp tiền vào ví cho một loại tiền tệ cụ thể.
|
||||
/// </summary>
|
||||
public void Deposit(decimal amount, CurrencyType currencyType, string description, string? referenceId = null)
|
||||
{
|
||||
EnsureWalletIsActive();
|
||||
|
||||
if (amount.Amount <= 0)
|
||||
if (amount <= 0)
|
||||
throw new WalletDomainException("Deposit amount must be greater than zero");
|
||||
|
||||
if (amount.Currency != Balance.Currency)
|
||||
throw new WalletDomainException($"Currency mismatch. Wallet uses {Balance.Currency}");
|
||||
|
||||
Balance = Balance.Add(amount);
|
||||
var balance = GetOrCreateBalance(currencyType);
|
||||
balance.Add(amount);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var transaction = new WalletTransaction(
|
||||
Id,
|
||||
amount,
|
||||
new Money(amount, currencyType.Name),
|
||||
TransactionType.Credit,
|
||||
Balance.Amount,
|
||||
balance.Balance,
|
||||
description,
|
||||
referenceId);
|
||||
|
||||
_transactions.Add(transaction);
|
||||
|
||||
AddDomainEvent(new WalletBalanceChangedDomainEvent(
|
||||
Id,
|
||||
UserId,
|
||||
Id,
|
||||
UserId,
|
||||
TransactionType.Credit.Name,
|
||||
amount.Amount,
|
||||
Balance.Amount));
|
||||
amount,
|
||||
balance.Balance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Withdraw money from the wallet
|
||||
/// VI: Rút tiền khỏi ví
|
||||
/// EN: Withdraw amount from wallet for a specific currency.
|
||||
/// VI: Rút tiền từ ví cho một loại tiền tệ cụ thể.
|
||||
/// </summary>
|
||||
public void Withdraw(Money amount, string description, string? referenceId = null)
|
||||
public void Withdraw(decimal amount, CurrencyType currencyType, string description, string? referenceId = null)
|
||||
{
|
||||
EnsureWalletIsActive();
|
||||
|
||||
if (amount.Amount <= 0)
|
||||
if (amount <= 0)
|
||||
throw new WalletDomainException("Withdrawal amount must be greater than zero");
|
||||
|
||||
if (amount.Currency != Balance.Currency)
|
||||
throw new WalletDomainException($"Currency mismatch. Wallet uses {Balance.Currency}");
|
||||
var balance = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id);
|
||||
if (balance == null || !balance.HasSufficientBalance(amount))
|
||||
throw new InsufficientBalanceException(balance?.Balance ?? 0, amount);
|
||||
|
||||
if (!Balance.IsGreaterThanOrEqual(amount))
|
||||
throw new InsufficientBalanceException(Balance.Amount, amount.Amount);
|
||||
|
||||
Balance = Balance.Subtract(amount);
|
||||
balance.Subtract(amount);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var transaction = new WalletTransaction(
|
||||
Id,
|
||||
amount,
|
||||
new Money(amount, currencyType.Name),
|
||||
TransactionType.Debit,
|
||||
Balance.Amount,
|
||||
balance.Balance,
|
||||
description,
|
||||
referenceId);
|
||||
|
||||
@@ -142,32 +198,118 @@ public class Wallet : Entity, IAggregateRoot
|
||||
Id,
|
||||
UserId,
|
||||
TransactionType.Debit.Name,
|
||||
amount.Amount,
|
||||
Balance.Amount));
|
||||
amount,
|
||||
balance.Balance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Transfer money to another wallet
|
||||
/// VI: Chuyển tiền đến ví khác
|
||||
/// EN: Exchange currency within the wallet (atomic operation).
|
||||
/// VI: Quy đổi tiền tệ trong ví (thao tác nguyên tử).
|
||||
/// </summary>
|
||||
/// <param name="fromAmount">Amount to exchange / Số tiền quy đổi</param>
|
||||
/// <param name="fromCurrency">Source currency / Tiền tệ nguồn</param>
|
||||
/// <param name="toCurrency">Target currency / Tiền tệ đích</param>
|
||||
/// <param name="customRate">Optional custom exchange rate / Tỷ giá tùy chỉnh (tuỳ chọn)</param>
|
||||
/// <returns>Amount received in target currency / Số tiền nhận được bằng tiền tệ đích</returns>
|
||||
public decimal Exchange(decimal fromAmount, CurrencyType fromCurrency, CurrencyType toCurrency, decimal? customRate = null)
|
||||
{
|
||||
EnsureWalletIsActive();
|
||||
|
||||
if (fromAmount <= 0)
|
||||
throw new WalletDomainException("Exchange amount must be greater than zero");
|
||||
|
||||
if (fromCurrency.Id == toCurrency.Id)
|
||||
throw new WalletDomainException("Cannot exchange to the same currency");
|
||||
|
||||
// Calculate exchange rate
|
||||
var rate = customRate ?? fromCurrency.GetExchangeRateTo(toCurrency);
|
||||
if (rate <= 0)
|
||||
throw new WalletDomainException("Exchange rate must be greater than zero");
|
||||
|
||||
// Calculate target amount
|
||||
var toAmount = Math.Round(fromAmount * rate, 2);
|
||||
|
||||
// Withdraw from source (validates sufficient balance)
|
||||
Withdraw(fromAmount, fromCurrency, $"Exchange to {toCurrency.Name}", null);
|
||||
|
||||
// Deposit to target
|
||||
Deposit(toAmount, toCurrency, $"Exchange from {fromCurrency.Name}", null);
|
||||
|
||||
// Raise exchange domain event
|
||||
AddDomainEvent(new WalletExchangedDomainEvent(
|
||||
Id,
|
||||
UserId,
|
||||
fromCurrency.Id,
|
||||
toCurrency.Id,
|
||||
fromAmount,
|
||||
toAmount,
|
||||
rate));
|
||||
|
||||
return toAmount;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Legacy Methods (Backward Compatibility)
|
||||
|
||||
private Money GetLegacyBalance()
|
||||
{
|
||||
var defaultBalance = _balances.FirstOrDefault(b => b.CurrencyTypeId == DefaultCurrencyTypeId);
|
||||
return new Money(defaultBalance?.Balance ?? 0, DefaultCurrencyType?.Name ?? "VND");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deposit money into the wallet (legacy method - uses default currency).
|
||||
/// VI: Nạp tiền vào ví (phương thức kế thừa - sử dụng tiền tệ mặc định).
|
||||
/// </summary>
|
||||
[Obsolete("Use Deposit(decimal, CurrencyType, string, string?) instead")]
|
||||
public void Deposit(Money amount, string description, string? referenceId = null)
|
||||
{
|
||||
var currencyType = CurrencyType.FromDisplayName<CurrencyType>(amount.Currency)
|
||||
?? throw new WalletDomainException($"Unknown currency: {amount.Currency}");
|
||||
|
||||
Deposit(amount.Amount, currencyType, description, referenceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Withdraw money from the wallet (legacy method - uses default currency).
|
||||
/// VI: Rút tiền khỏi ví (phương thức kế thừa - sử dụng tiền tệ mặc định).
|
||||
/// </summary>
|
||||
[Obsolete("Use Withdraw(decimal, CurrencyType, string, string?) instead")]
|
||||
public void Withdraw(Money amount, string description, string? referenceId = null)
|
||||
{
|
||||
var currencyType = CurrencyType.FromDisplayName<CurrencyType>(amount.Currency)
|
||||
?? throw new WalletDomainException($"Unknown currency: {amount.Currency}");
|
||||
|
||||
Withdraw(amount.Amount, currencyType, description, referenceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Transfer money to another wallet.
|
||||
/// VI: Chuyển tiền đến ví khác.
|
||||
/// </summary>
|
||||
public void TransferOut(Money amount, Guid toWalletId, string description)
|
||||
{
|
||||
EnsureWalletIsActive();
|
||||
|
||||
var currencyType = CurrencyType.FromDisplayName<CurrencyType>(amount.Currency)
|
||||
?? throw new WalletDomainException($"Unknown currency: {amount.Currency}");
|
||||
|
||||
if (amount.Amount <= 0)
|
||||
throw new WalletDomainException("Transfer amount must be greater than zero");
|
||||
|
||||
if (!Balance.IsGreaterThanOrEqual(amount))
|
||||
throw new InsufficientBalanceException(Balance.Amount, amount.Amount);
|
||||
var balance = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id);
|
||||
if (balance == null || !balance.HasSufficientBalance(amount.Amount))
|
||||
throw new InsufficientBalanceException(balance?.Balance ?? 0, amount.Amount);
|
||||
|
||||
Balance = Balance.Subtract(amount);
|
||||
balance.Subtract(amount.Amount);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var transaction = new WalletTransaction(
|
||||
Id,
|
||||
amount,
|
||||
TransactionType.TransferOut,
|
||||
Balance.Amount,
|
||||
balance.Balance,
|
||||
description,
|
||||
toWalletId.ToString());
|
||||
|
||||
@@ -178,25 +320,29 @@ public class Wallet : Entity, IAggregateRoot
|
||||
UserId,
|
||||
TransactionType.TransferOut.Name,
|
||||
amount.Amount,
|
||||
Balance.Amount));
|
||||
balance.Balance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Receive transfer from another wallet
|
||||
/// VI: Nhận chuyển khoản từ ví khác
|
||||
/// EN: Receive transfer from another wallet.
|
||||
/// VI: Nhận chuyển khoản từ ví khác.
|
||||
/// </summary>
|
||||
public void TransferIn(Money amount, Guid fromWalletId, string description)
|
||||
{
|
||||
EnsureWalletIsActive();
|
||||
|
||||
Balance = Balance.Add(amount);
|
||||
var currencyType = CurrencyType.FromDisplayName<CurrencyType>(amount.Currency)
|
||||
?? throw new WalletDomainException($"Unknown currency: {amount.Currency}");
|
||||
|
||||
var balance = GetOrCreateBalance(currencyType);
|
||||
balance.Add(amount.Amount);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var transaction = new WalletTransaction(
|
||||
Id,
|
||||
amount,
|
||||
TransactionType.TransferIn,
|
||||
Balance.Amount,
|
||||
balance.Balance,
|
||||
description,
|
||||
fromWalletId.ToString());
|
||||
|
||||
@@ -207,12 +353,16 @@ public class Wallet : Entity, IAggregateRoot
|
||||
UserId,
|
||||
TransactionType.TransferIn.Name,
|
||||
amount.Amount,
|
||||
Balance.Amount));
|
||||
balance.Balance));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Wallet Status Management
|
||||
|
||||
/// <summary>
|
||||
/// EN: Freeze the wallet (no transactions allowed)
|
||||
/// VI: Đóng băng ví (không cho phép giao dịch)
|
||||
/// EN: Freeze the wallet (no transactions allowed).
|
||||
/// VI: Đóng băng ví (không cho phép giao dịch).
|
||||
/// </summary>
|
||||
public void Freeze()
|
||||
{
|
||||
@@ -225,8 +375,8 @@ public class Wallet : Entity, IAggregateRoot
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unfreeze the wallet
|
||||
/// VI: Mở băng ví
|
||||
/// EN: Unfreeze the wallet.
|
||||
/// VI: Mở băng ví.
|
||||
/// </summary>
|
||||
public void Unfreeze()
|
||||
{
|
||||
@@ -239,13 +389,14 @@ public class Wallet : Entity, IAggregateRoot
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close the wallet permanently
|
||||
/// VI: Đóng ví vĩnh viễn
|
||||
/// EN: Close the wallet permanently.
|
||||
/// VI: Đóng ví vĩnh viễn.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
if (Balance.Amount > 0)
|
||||
throw new WalletDomainException("Cannot close wallet with positive balance");
|
||||
// Check all balances are zero
|
||||
if (_balances.Any(b => b.Balance > 0))
|
||||
throw new WalletDomainException("Cannot close wallet with positive balance in any currency");
|
||||
|
||||
Status = WalletStatus.Closed;
|
||||
StatusId = Status.Id;
|
||||
@@ -257,4 +408,6 @@ public class Wallet : Entity, IAggregateRoot
|
||||
if (Status != WalletStatus.Active)
|
||||
throw new WalletDomainException($"Wallet is {Status.Name}. Only active wallets can perform transactions");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
using WalletService.Domain.Exceptions;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents a balance for a specific currency type within a wallet.
|
||||
/// VI: Đại diện cho số dư của một loại tiền tệ cụ thể trong ví.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: WalletItem is a child entity of Wallet aggregate, not an aggregate root.
|
||||
/// Each wallet can have multiple WalletItems, one for each currency type.
|
||||
/// VI: WalletItem là entity con của Wallet aggregate, không phải aggregate root.
|
||||
/// Mỗi ví có thể có nhiều WalletItem, một cho mỗi loại tiền tệ.
|
||||
/// </remarks>
|
||||
public class WalletItem : Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Parent wallet ID.
|
||||
/// VI: ID của ví cha.
|
||||
/// </summary>
|
||||
public Guid WalletId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency type ID for EF Core.
|
||||
/// VI: ID loại tiền tệ cho EF Core.
|
||||
/// </summary>
|
||||
public int CurrencyTypeId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency type.
|
||||
/// VI: Loại tiền tệ.
|
||||
/// </summary>
|
||||
public CurrencyType CurrencyType { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current balance amount.
|
||||
/// VI: Số dư hiện tại.
|
||||
/// </summary>
|
||||
public decimal Balance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời điểm tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời điểm cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; private set; }
|
||||
|
||||
protected WalletItem() { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new wallet item for a specific currency.
|
||||
/// VI: Tạo wallet item mới cho một loại tiền tệ cụ thể.
|
||||
/// </summary>
|
||||
public WalletItem(Guid walletId, CurrencyType currencyType, decimal initialBalance = 0)
|
||||
{
|
||||
if (initialBalance < 0)
|
||||
throw new WalletDomainException("Initial balance cannot be negative");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
WalletId = walletId;
|
||||
CurrencyType = currencyType;
|
||||
CurrencyTypeId = currencyType.Id;
|
||||
Balance = initialBalance;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add amount to balance.
|
||||
/// VI: Cộng số tiền vào số dư.
|
||||
/// </summary>
|
||||
public void Add(decimal amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new WalletDomainException("Amount to add must be greater than zero");
|
||||
|
||||
Balance += amount;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subtract amount from balance.
|
||||
/// VI: Trừ số tiền từ số dư.
|
||||
/// </summary>
|
||||
public void Subtract(decimal amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new WalletDomainException("Amount to subtract must be greater than zero");
|
||||
|
||||
if (Balance < amount)
|
||||
throw new InsufficientBalanceException(Balance, amount);
|
||||
|
||||
Balance -= amount;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if balance is sufficient for an amount.
|
||||
/// VI: Kiểm tra số dư có đủ cho một số tiền không.
|
||||
/// </summary>
|
||||
public bool HasSufficientBalance(decimal amount) => Balance >= amount;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get formatted balance string.
|
||||
/// VI: Lấy chuỗi số dư đã được format.
|
||||
/// </summary>
|
||||
public override string ToString() => $"{Balance:N2} {CurrencyType.Name}";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when currency exchange occurs in a wallet.
|
||||
/// VI: Domain event được raise khi xảy ra quy đổi tiền tệ trong ví.
|
||||
/// </summary>
|
||||
public record WalletExchangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Wallet ID.
|
||||
/// VI: ID ví.
|
||||
/// </summary>
|
||||
public Guid WalletId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: User ID who owns the wallet.
|
||||
/// VI: ID người dùng sở hữu ví.
|
||||
/// </summary>
|
||||
public Guid UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Source currency type ID.
|
||||
/// VI: ID loại tiền tệ nguồn.
|
||||
/// </summary>
|
||||
public int FromCurrencyTypeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Target currency type ID.
|
||||
/// VI: ID loại tiền tệ đích.
|
||||
/// </summary>
|
||||
public int ToCurrencyTypeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Amount exchanged from source currency.
|
||||
/// VI: Số tiền quy đổi từ tiền tệ nguồn.
|
||||
/// </summary>
|
||||
public decimal FromAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Amount received in target currency.
|
||||
/// VI: Số tiền nhận được bằng tiền tệ đích.
|
||||
/// </summary>
|
||||
public decimal ToAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exchange rate applied.
|
||||
/// VI: Tỷ giá áp dụng.
|
||||
/// </summary>
|
||||
public decimal ExchangeRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Timestamp of the exchange.
|
||||
/// VI: Thời điểm quy đổi.
|
||||
/// </summary>
|
||||
public DateTime ExchangedAt { get; init; } = DateTime.UtcNow;
|
||||
|
||||
public WalletExchangedDomainEvent(
|
||||
Guid walletId,
|
||||
Guid userId,
|
||||
int fromCurrencyTypeId,
|
||||
int toCurrencyTypeId,
|
||||
decimal fromAmount,
|
||||
decimal toAmount,
|
||||
decimal exchangeRate)
|
||||
{
|
||||
WalletId = walletId;
|
||||
UserId = userId;
|
||||
FromCurrencyTypeId = fromCurrencyTypeId;
|
||||
ToCurrencyTypeId = toCurrencyTypeId;
|
||||
FromAmount = fromAmount;
|
||||
ToAmount = toAmount;
|
||||
ExchangeRate = exchangeRate;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Wallet aggregate
|
||||
/// VI: Cấu hình EF Core cho aggregate Wallet
|
||||
/// EN: EF Core configuration for Wallet aggregate with multi-currency support.
|
||||
/// VI: Cấu hình EF Core cho aggregate Wallet với hỗ trợ đa tiền tệ.
|
||||
/// </summary>
|
||||
public class WalletEntityTypeConfiguration : IEntityTypeConfiguration<Wallet>
|
||||
{
|
||||
@@ -28,20 +28,22 @@ public class WalletEntityTypeConfiguration : IEntityTypeConfiguration<Wallet>
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wallets_user_id");
|
||||
|
||||
// EN: Configure Money value object
|
||||
// VI: Cấu hình value object Money
|
||||
builder.OwnsOne(w => w.Balance, balance =>
|
||||
{
|
||||
balance.Property(m => m.Amount)
|
||||
.HasColumnName("balance")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
// EN: Default currency type ID
|
||||
// VI: ID loại tiền tệ mặc định
|
||||
builder.Property(w => w.DefaultCurrencyTypeId)
|
||||
.HasColumnName("default_currency_type_id")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(1); // VND
|
||||
|
||||
balance.Property(m => m.Currency)
|
||||
.HasColumnName("currency")
|
||||
.HasMaxLength(3)
|
||||
.IsRequired();
|
||||
});
|
||||
// EN: Ignore CurrencyType navigation - it's an enumeration
|
||||
// VI: Bỏ qua navigation CurrencyType - đây là enumeration
|
||||
builder.Ignore(w => w.DefaultCurrencyType);
|
||||
|
||||
// EN: Ignore legacy Balance property (computed from Balances)
|
||||
// VI: Bỏ qua property Balance kế thừa (tính từ Balances)
|
||||
#pragma warning disable CS0618 // Obsolete warning suppressed for EF Core configuration
|
||||
builder.Ignore(w => w.Balance);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
builder.Property(w => w.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
@@ -57,8 +59,21 @@ public class WalletEntityTypeConfiguration : IEntityTypeConfiguration<Wallet>
|
||||
.HasColumnName("updated_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Configure navigation to wallet items (multi-balance)
|
||||
// VI: Cấu hình navigation đến wallet items (đa số dư)
|
||||
var balancesNavigation = builder.Metadata.FindNavigation(nameof(Wallet.Balances));
|
||||
balancesNavigation?.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.HasMany(w => w.Balances)
|
||||
.WithOne()
|
||||
.HasForeignKey(wi => wi.WalletId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: Configure navigation to transactions
|
||||
// VI: Cấu hình navigation đến transactions
|
||||
var transactionsNavigation = builder.Metadata.FindNavigation(nameof(Wallet.Transactions));
|
||||
transactionsNavigation?.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.HasMany(w => w.Transactions)
|
||||
.WithOne()
|
||||
.HasForeignKey(t => t.WalletId)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace WalletService.Infrastructure.EntityConfigurations;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for WalletItem entity.
|
||||
/// VI: Cấu hình EF Core cho entity WalletItem.
|
||||
/// </summary>
|
||||
public class WalletItemEntityTypeConfiguration : IEntityTypeConfiguration<WalletItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WalletItem> builder)
|
||||
{
|
||||
builder.ToTable("wallet_items");
|
||||
|
||||
builder.HasKey(wi => wi.Id);
|
||||
|
||||
builder.Property(wi => wi.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(wi => wi.WalletId)
|
||||
.HasColumnName("wallet_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(wi => wi.CurrencyTypeId)
|
||||
.HasColumnName("currency_type_id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Ignore navigation property - CurrencyType is an enumeration
|
||||
// VI: Bỏ qua navigation property - CurrencyType là enumeration
|
||||
builder.Ignore(wi => wi.CurrencyType);
|
||||
|
||||
builder.Property(wi => wi.Balance)
|
||||
.HasColumnName("balance")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(wi => wi.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(wi => wi.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Unique constraint: one balance per currency per wallet
|
||||
// VI: Ràng buộc unique: một số dư cho mỗi loại tiền tệ trong mỗi ví
|
||||
builder.HasIndex(wi => new { wi.WalletId, wi.CurrencyTypeId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wallet_items_wallet_currency");
|
||||
|
||||
builder.HasIndex(wi => wi.WalletId)
|
||||
.HasDatabaseName("ix_wallet_items_wallet_id");
|
||||
|
||||
builder.Ignore(wi => wi.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class WalletServiceContext : DbContext, IUnitOfWork
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
public DbSet<Wallet> Wallets { get; set; } = null!;
|
||||
public DbSet<WalletItem> WalletItems { get; set; } = null!;
|
||||
public DbSet<WalletTransaction> WalletTransactions { get; set; } = null!;
|
||||
public DbSet<PointAccount> PointAccounts { get; set; } = null!;
|
||||
public DbSet<PointTransaction> PointTransactions { get; set; } = null!;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// EN: Unit tests for Wallet aggregate
|
||||
// VI: Unit tests cho Wallet aggregate
|
||||
// EN: Unit tests for Wallet aggregate with multi-currency support
|
||||
// VI: Unit tests cho Wallet aggregate với hỗ trợ đa tiền tệ
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
@@ -9,19 +9,18 @@ namespace WalletService.UnitTests.Domain;
|
||||
public class WalletTests
|
||||
{
|
||||
private static readonly Guid TestUserId = Guid.NewGuid();
|
||||
private const string TestCurrency = "VND";
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldCreateWallet_WithZeroBalance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(wallet);
|
||||
Assert.Equal(TestUserId, wallet.UserId);
|
||||
Assert.Equal(TestCurrency, wallet.Balance.Currency);
|
||||
Assert.Equal(0, wallet.Balance.Amount);
|
||||
Assert.Equal(CurrencyType.VND.Id, wallet.DefaultCurrencyTypeId);
|
||||
Assert.Equal(0, wallet.GetBalance(CurrencyType.VND));
|
||||
Assert.Equal(WalletStatus.Active, wallet.Status);
|
||||
}
|
||||
|
||||
@@ -32,21 +31,20 @@ public class WalletTests
|
||||
var wallet = new Wallet(TestUserId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("VND", wallet.Balance.Currency);
|
||||
Assert.Equal(CurrencyType.VND.Id, wallet.DefaultCurrencyTypeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deposit_ShouldIncreaseBalance()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
var depositAmount = new Money(100000m, TestCurrency);
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
|
||||
// Act
|
||||
wallet.Deposit(depositAmount, "Test deposit", "REF001");
|
||||
wallet.Deposit(100000m, CurrencyType.VND, "Test deposit", "REF001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100000m, wallet.Balance.Amount);
|
||||
Assert.Equal(100000m, wallet.GetBalance(CurrencyType.VND));
|
||||
Assert.Single(wallet.Transactions);
|
||||
Assert.Equal(TransactionType.Credit, wallet.Transactions.First().Type);
|
||||
}
|
||||
@@ -58,21 +56,21 @@ public class WalletTests
|
||||
// EN: Money constructor throws ArgumentException for negative amounts
|
||||
// VI: Money constructor ném ArgumentException cho số tiền âm
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new Money(-100, TestCurrency));
|
||||
new Money(-100, "VND"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Withdraw_ShouldDecreaseBalance()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
wallet.Deposit(new Money(100000m, TestCurrency), "Initial deposit", "REF001");
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Deposit(100000m, CurrencyType.VND, "Initial deposit", "REF001");
|
||||
|
||||
// Act
|
||||
wallet.Withdraw(new Money(50000m, TestCurrency), "Test withdrawal", "REF002");
|
||||
wallet.Withdraw(50000m, CurrencyType.VND, "Test withdrawal", "REF002");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(50000m, wallet.Balance.Amount);
|
||||
Assert.Equal(50000m, wallet.GetBalance(CurrencyType.VND));
|
||||
Assert.Equal(2, wallet.Transactions.Count);
|
||||
}
|
||||
|
||||
@@ -80,19 +78,19 @@ public class WalletTests
|
||||
public void Withdraw_ShouldThrow_WhenInsufficientBalance()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
wallet.Deposit(new Money(50000m, TestCurrency), "Initial deposit", "REF001");
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Deposit(50000m, CurrencyType.VND, "Initial deposit", "REF001");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InsufficientBalanceException>(() =>
|
||||
wallet.Withdraw(new Money(100000m, TestCurrency), "Invalid withdrawal"));
|
||||
wallet.Withdraw(100000m, CurrencyType.VND, "Invalid withdrawal"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Freeze_ShouldSetStatusToFrozen()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
|
||||
// Act
|
||||
wallet.Freeze();
|
||||
@@ -105,7 +103,7 @@ public class WalletTests
|
||||
public void Unfreeze_ShouldSetStatusToActive()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Freeze();
|
||||
|
||||
// Act
|
||||
@@ -119,7 +117,7 @@ public class WalletTests
|
||||
public void Close_ShouldSetStatusToClosed()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
|
||||
// Act
|
||||
wallet.Close();
|
||||
@@ -132,10 +130,71 @@ public class WalletTests
|
||||
public void Close_ShouldThrow_WhenBalanceIsPositive()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, TestCurrency);
|
||||
wallet.Deposit(new Money(100000m, TestCurrency), "Deposit", "REF001");
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Deposit(100000m, CurrencyType.VND, "Deposit", "REF001");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<WalletDomainException>(() => wallet.Close());
|
||||
}
|
||||
|
||||
#region Multi-Currency Tests / Tests đa tiền tệ
|
||||
|
||||
[Fact]
|
||||
public void Deposit_ShouldSupportMultipleCurrencies()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
|
||||
// Act
|
||||
wallet.Deposit(100000m, CurrencyType.VND, "VND deposit");
|
||||
wallet.Deposit(100m, CurrencyType.USD, "USD deposit");
|
||||
wallet.Deposit(1000, CurrencyType.PPoint, "PPoint deposit");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100000m, wallet.GetBalance(CurrencyType.VND));
|
||||
Assert.Equal(100m, wallet.GetBalance(CurrencyType.USD));
|
||||
Assert.Equal(1000, wallet.GetBalance(CurrencyType.PPoint));
|
||||
Assert.Equal(3, wallet.Balances.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exchange_ShouldConvertBetweenCurrencies()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Deposit(100000m, CurrencyType.VND, "Initial VND deposit");
|
||||
|
||||
// Act - Exchange 100000 VND to PPoint (rate: 1 VND = 0.001 PPoint)
|
||||
var receivedPoints = wallet.Exchange(100000m, CurrencyType.VND, CurrencyType.PPoint);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wallet.GetBalance(CurrencyType.VND));
|
||||
Assert.Equal(100, wallet.GetBalance(CurrencyType.PPoint)); // 100000 / 1000 = 100
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exchange_ShouldThrow_WhenSameCurrency()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Deposit(100000m, CurrencyType.VND, "Deposit");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<WalletDomainException>(() =>
|
||||
wallet.Exchange(50000m, CurrencyType.VND, CurrencyType.VND));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exchange_ShouldThrow_WhenInsufficientBalance()
|
||||
{
|
||||
// Arrange
|
||||
var wallet = new Wallet(TestUserId, CurrencyType.VND);
|
||||
wallet.Deposit(50000m, CurrencyType.VND, "Deposit");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InsufficientBalanceException>(() =>
|
||||
wallet.Exchange(100000m, CurrencyType.VND, CurrencyType.PPoint));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user