feat: Bổ sung các chức năng quản trị viên để quản lý ví và tài khoản điểm, bao gồm các lệnh điều chỉnh và truy vấn.

This commit is contained in:
Ho Ngoc Hai
2026-01-15 22:17:55 +07:00
parent 055c6c4075
commit bcadf2b8e4
9 changed files with 663 additions and 1 deletions

View File

@@ -301,7 +301,7 @@ services:
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.wallet-service.rule=PathPrefix(`/api/v1/wallets`) || PathPrefix(`/api/v1/points`)"
- "traefik.http.routers.wallet-service.rule=PathPrefix(`/api/v1/wallets`) || PathPrefix(`/api/v1/points`) || PathPrefix(`/api/v1/admin/wallets`) || PathPrefix(`/api/v1/admin/points`)"
- "traefik.http.routers.wallet-service.entrypoints=web"
- "traefik.http.services.wallet-service.loadbalancer.server.port=8080"
- "traefik.http.services.wallet-service.loadbalancer.healthcheck.path=/health/live"

View File

@@ -0,0 +1,99 @@
namespace WalletService.API.Application.Commands;
using MediatR;
using WalletService.Domain.AggregatesModel.PointAccountAggregate;
using WalletService.Domain.Exceptions;
/// <summary>
/// EN: Handler for AdminAdjustPointsCommand.
/// VI: Handler cho AdminAdjustPointsCommand.
/// </summary>
public class AdminAdjustPointsCommandHandler : IRequestHandler<AdminAdjustPointsCommand, AdminAdjustPointsResult>
{
private readonly IPointAccountRepository _pointAccountRepository;
private readonly ILogger<AdminAdjustPointsCommandHandler> _logger;
public AdminAdjustPointsCommandHandler(
IPointAccountRepository pointAccountRepository,
ILogger<AdminAdjustPointsCommandHandler> logger)
{
_pointAccountRepository = pointAccountRepository;
_logger = logger;
}
public async Task<AdminAdjustPointsResult> Handle(
AdminAdjustPointsCommand request,
CancellationToken cancellationToken)
{
var account = await _pointAccountRepository.GetByIdAsync(request.AccountId)
?? throw new WalletDomainException($"Point account {request.AccountId} not found");
var previousPoints = account.AvailablePoints;
// EN: Adjust points directly using domain method
// VI: Điều chỉnh điểm trực tiếp bằng phương thức domain
account.AdjustPoints(request.Points, $"Admin Adjustment: {request.Reason}");
_pointAccountRepository.Update(account);
await _pointAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Point account {AccountId} adjusted by Admin {AdminId}. Points: {Points}. Reason: {Reason}",
request.AccountId, request.AdminId, request.Points, request.Reason);
return new AdminAdjustPointsResult(
account.Id,
previousPoints,
request.Points,
account.AvailablePoints,
request.Reason,
DateTime.UtcNow);
}
}
/// <summary>
/// EN: Handler for AdminGrantBonusCommand.
/// VI: Handler cho AdminGrantBonusCommand.
/// </summary>
public class AdminGrantBonusCommandHandler : IRequestHandler<AdminGrantBonusCommand, AdminGrantBonusResult>
{
private readonly IPointAccountRepository _pointAccountRepository;
private readonly ILogger<AdminGrantBonusCommandHandler> _logger;
public AdminGrantBonusCommandHandler(
IPointAccountRepository pointAccountRepository,
ILogger<AdminGrantBonusCommandHandler> logger)
{
_pointAccountRepository = pointAccountRepository;
_logger = logger;
}
public async Task<AdminGrantBonusResult> Handle(
AdminGrantBonusCommand request,
CancellationToken cancellationToken)
{
var account = await _pointAccountRepository.GetByIdAsync(request.AccountId)
?? throw new WalletDomainException($"Point account {request.AccountId} not found");
var expiryMonths = request.ExpiryMonths ?? 12;
var expiresAt = DateTime.UtcNow.AddMonths(expiryMonths);
// EN: Earn bonus points with expiry
// VI: Tích điểm thưởng với thời hạn
account.AddBonusPoints(request.Points, "AdminBonus", $"Admin Bonus: {request.Reason}", expiresAt);
_pointAccountRepository.Update(account);
await _pointAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Point account {AccountId} granted {Points} bonus points by Admin {AdminId}. Reason: {Reason}",
request.AccountId, request.Points, request.AdminId, request.Reason);
return new AdminGrantBonusResult(
account.Id,
request.Points,
account.AvailablePoints,
expiresAt,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,145 @@
namespace WalletService.API.Application.Commands;
using MediatR;
using WalletService.Domain.AggregatesModel.WalletAggregate;
using WalletService.Domain.Exceptions;
/// <summary>
/// EN: Handler for AdminFreezeWalletCommand.
/// VI: Handler cho AdminFreezeWalletCommand.
/// </summary>
public class AdminFreezeWalletCommandHandler : IRequestHandler<AdminFreezeWalletCommand, AdminWalletActionResult>
{
private readonly IWalletRepository _walletRepository;
private readonly ILogger<AdminFreezeWalletCommandHandler> _logger;
public AdminFreezeWalletCommandHandler(
IWalletRepository walletRepository,
ILogger<AdminFreezeWalletCommandHandler> logger)
{
_walletRepository = walletRepository;
_logger = logger;
}
public async Task<AdminWalletActionResult> Handle(
AdminFreezeWalletCommand request,
CancellationToken cancellationToken)
{
var wallet = await _walletRepository.GetByIdAsync(request.WalletId)
?? throw new WalletDomainException($"Wallet {request.WalletId} not found");
wallet.Freeze();
_walletRepository.Update(wallet);
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Wallet {WalletId} frozen by Admin {AdminId}. Reason: {Reason}",
request.WalletId, request.AdminId, request.Reason);
return new AdminWalletActionResult(
wallet.Id,
wallet.Status.Name,
request.AdminId.ToString(),
DateTime.UtcNow);
}
}
/// <summary>
/// EN: Handler for AdminUnfreezeWalletCommand.
/// VI: Handler cho AdminUnfreezeWalletCommand.
/// </summary>
public class AdminUnfreezeWalletCommandHandler : IRequestHandler<AdminUnfreezeWalletCommand, AdminWalletActionResult>
{
private readonly IWalletRepository _walletRepository;
private readonly ILogger<AdminUnfreezeWalletCommandHandler> _logger;
public AdminUnfreezeWalletCommandHandler(
IWalletRepository walletRepository,
ILogger<AdminUnfreezeWalletCommandHandler> logger)
{
_walletRepository = walletRepository;
_logger = logger;
}
public async Task<AdminWalletActionResult> Handle(
AdminUnfreezeWalletCommand request,
CancellationToken cancellationToken)
{
var wallet = await _walletRepository.GetByIdAsync(request.WalletId)
?? throw new WalletDomainException($"Wallet {request.WalletId} not found");
wallet.Unfreeze();
_walletRepository.Update(wallet);
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Wallet {WalletId} unfrozen by Admin {AdminId}. Reason: {Reason}",
request.WalletId, request.AdminId, request.Reason);
return new AdminWalletActionResult(
wallet.Id,
wallet.Status.Name,
request.AdminId.ToString(),
DateTime.UtcNow);
}
}
/// <summary>
/// EN: Handler for AdminAdjustBalanceCommand.
/// VI: Handler cho AdminAdjustBalanceCommand.
/// </summary>
public class AdminAdjustBalanceCommandHandler : IRequestHandler<AdminAdjustBalanceCommand, AdminAdjustBalanceResult>
{
private readonly IWalletRepository _walletRepository;
private readonly ILogger<AdminAdjustBalanceCommandHandler> _logger;
public AdminAdjustBalanceCommandHandler(
IWalletRepository walletRepository,
ILogger<AdminAdjustBalanceCommandHandler> logger)
{
_walletRepository = walletRepository;
_logger = logger;
}
public async Task<AdminAdjustBalanceResult> Handle(
AdminAdjustBalanceCommand request,
CancellationToken cancellationToken)
{
var wallet = await _walletRepository.GetByIdAsync(request.WalletId)
?? throw new WalletDomainException($"Wallet {request.WalletId} not found");
var currencyType = Domain.SeedWork.Enumeration.FromValue<CurrencyType>(request.CurrencyTypeId);
var previousBalance = wallet.GetBalance(currencyType);
// EN: Positive amount = credit, negative = debit
// VI: Số dương = cộng, số âm = trừ
if (request.Amount > 0)
{
wallet.Deposit(request.Amount, currencyType, $"Admin Adjustment: {request.Reason}");
}
else if (request.Amount < 0)
{
wallet.Withdraw(Math.Abs(request.Amount), currencyType, $"Admin Adjustment: {request.Reason}");
}
_walletRepository.Update(wallet);
await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
var newBalance = wallet.GetBalance(currencyType);
_logger.LogInformation(
"Wallet {WalletId} balance adjusted by Admin {AdminId}. Amount: {Amount} {Currency}. Reason: {Reason}",
request.WalletId, request.AdminId, request.Amount, currencyType.Name, request.Reason);
return new AdminAdjustBalanceResult(
wallet.Id,
previousBalance,
request.Amount,
newBalance,
currencyType.Name,
request.Reason,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,181 @@
namespace WalletService.API.Application.Queries;
using MediatR;
using Microsoft.EntityFrameworkCore;
using WalletService.Domain.AggregatesModel.PointAccountAggregate;
using WalletService.Infrastructure;
/// <summary>
/// EN: Handler for GetAllPointAccountsQuery (Admin).
/// VI: Handler cho GetAllPointAccountsQuery (Admin).
/// </summary>
public class GetAllPointAccountsQueryHandler : IRequestHandler<GetAllPointAccountsQuery, AdminPointAccountsListDto>
{
private readonly WalletServiceContext _context;
public GetAllPointAccountsQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<AdminPointAccountsListDto> Handle(
GetAllPointAccountsQuery request,
CancellationToken cancellationToken)
{
var query = _context.PointAccounts.AsQueryable();
// EN: Filter by points range if provided
// VI: Lọc theo khoảng điểm nếu có
if (request.MinPoints.HasValue)
query = query.Where(p => p.AvailablePoints >= request.MinPoints.Value);
if (request.MaxPoints.HasValue)
query = query.Where(p => p.AvailablePoints <= request.MaxPoints.Value);
var totalCount = await query.CountAsync(cancellationToken);
var accounts = await query
.OrderByDescending(p => p.TotalPoints)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(p => new AdminPointAccountSummaryDto(
p.Id,
p.UserId,
p.TotalPoints,
p.AvailablePoints,
p.CreatedAt))
.ToListAsync(cancellationToken);
return new AdminPointAccountsListDto(accounts, totalCount, request.Page, request.PageSize);
}
}
/// <summary>
/// EN: Handler for GetPointAccountByIdQuery (Admin).
/// VI: Handler cho GetPointAccountByIdQuery (Admin).
/// </summary>
public class GetPointAccountByIdQueryHandler : IRequestHandler<GetPointAccountByIdQuery, AdminPointAccountDetailDto?>
{
private readonly WalletServiceContext _context;
public GetPointAccountByIdQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<AdminPointAccountDetailDto?> Handle(
GetPointAccountByIdQuery request,
CancellationToken cancellationToken)
{
var account = await _context.PointAccounts
.Include(p => p.Transactions)
.FirstOrDefaultAsync(p => p.Id == request.AccountId, cancellationToken);
if (account == null) return null;
return new AdminPointAccountDetailDto(
account.Id,
account.UserId,
account.TotalPoints,
account.AvailablePoints,
account.Transactions.Count,
account.CreatedAt,
account.UpdatedAt);
}
}
/// <summary>
/// EN: Handler for SearchPointAccountsQuery (Admin).
/// VI: Handler cho SearchPointAccountsQuery (Admin).
/// </summary>
public class SearchPointAccountsQueryHandler : IRequestHandler<SearchPointAccountsQuery, List<AdminPointAccountDetailDto>>
{
private readonly WalletServiceContext _context;
public SearchPointAccountsQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<List<AdminPointAccountDetailDto>> Handle(
SearchPointAccountsQuery request,
CancellationToken cancellationToken)
{
var query = _context.PointAccounts
.Include(p => p.Transactions)
.AsQueryable();
if (request.UserId.HasValue)
query = query.Where(p => p.UserId == request.UserId.Value);
var accounts = await query
.Take(50)
.Select(p => new AdminPointAccountDetailDto(
p.Id,
p.UserId,
p.TotalPoints,
p.AvailablePoints,
p.Transactions.Count,
p.CreatedAt,
p.UpdatedAt))
.ToListAsync(cancellationToken);
return accounts;
}
}
/// <summary>
/// EN: Handler for GetPointsStatisticsQuery (Admin).
/// VI: Handler cho GetPointsStatisticsQuery (Admin).
/// </summary>
public class GetPointsStatisticsQueryHandler : IRequestHandler<GetPointsStatisticsQuery, PointsStatisticsDto>
{
private readonly WalletServiceContext _context;
public GetPointsStatisticsQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<PointsStatisticsDto> Handle(
GetPointsStatisticsQuery request,
CancellationToken cancellationToken)
{
var today = DateTime.UtcNow.Date;
var totalAccounts = await _context.PointAccounts.CountAsync(cancellationToken);
var totalPointsIssued = await _context.PointAccounts
.SumAsync(p => p.TotalPoints, cancellationToken);
var totalPointsAvailable = await _context.PointAccounts
.SumAsync(p => p.AvailablePoints, cancellationToken);
// EN: Calculate spent and expired from transactions
// VI: Tính điểm đã tiêu và hết hạn từ giao dịch
var totalPointsSpent = await _context.PointTransactions
.Where(t => t.TypeId == PointTransactionType.Spend.Id)
.SumAsync(t => t.Points, cancellationToken);
var totalPointsExpired = await _context.PointTransactions
.Where(t => t.TypeId == PointTransactionType.Expire.Id)
.SumAsync(t => t.Points, cancellationToken);
var pointsEarnedToday = await _context.PointTransactions
.Where(t => t.CreatedAt >= today && t.TypeId == PointTransactionType.Earn.Id)
.SumAsync(t => t.Points, cancellationToken);
var pointsSpentToday = await _context.PointTransactions
.Where(t => t.CreatedAt >= today && t.TypeId == PointTransactionType.Spend.Id)
.SumAsync(t => t.Points, cancellationToken);
return new PointsStatisticsDto(
totalAccounts,
totalPointsIssued,
totalPointsAvailable,
totalPointsSpent,
totalPointsExpired,
pointsEarnedToday,
pointsSpentToday);
}
}

View File

@@ -0,0 +1,211 @@
namespace WalletService.API.Application.Queries;
using MediatR;
using Microsoft.EntityFrameworkCore;
using WalletService.Domain.AggregatesModel.WalletAggregate;
using WalletService.Infrastructure;
/// <summary>
/// EN: Handler for GetAllWalletsQuery (Admin).
/// VI: Handler cho GetAllWalletsQuery (Admin).
/// </summary>
public class GetAllWalletsQueryHandler : IRequestHandler<GetAllWalletsQuery, AdminWalletsListDto>
{
private readonly WalletServiceContext _context;
public GetAllWalletsQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<AdminWalletsListDto> Handle(
GetAllWalletsQuery request,
CancellationToken cancellationToken)
{
var query = _context.Wallets
.Include(w => w.Balances)
.AsQueryable();
// EN: Filter by status if provided
// VI: Lọc theo trạng thái nếu có
if (!string.IsNullOrEmpty(request.Status))
{
var status = WalletStatus.FromDisplayName<WalletStatus>(request.Status);
if (status != null)
query = query.Where(w => w.StatusId == status.Id);
}
var totalCount = await query.CountAsync(cancellationToken);
var wallets = await query
.OrderByDescending(w => w.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(w => new AdminWalletSummaryDto(
w.Id,
w.UserId,
WalletStatus.FromValue<WalletStatus>(w.StatusId).Name,
w.Balances.Select(b => new BalanceItemDto(
CurrencyType.FromValue<CurrencyType>(b.CurrencyTypeId).Name,
b.Balance)).ToList(),
w.CreatedAt,
w.UpdatedAt))
.ToListAsync(cancellationToken);
return new AdminWalletsListDto(wallets, totalCount, request.Page, request.PageSize);
}
}
/// <summary>
/// EN: Handler for GetWalletByIdQuery (Admin).
/// VI: Handler cho GetWalletByIdQuery (Admin).
/// </summary>
public class GetWalletByIdQueryHandler : IRequestHandler<GetWalletByIdQuery, AdminWalletDetailDto?>
{
private readonly WalletServiceContext _context;
public GetWalletByIdQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<AdminWalletDetailDto?> Handle(
GetWalletByIdQuery request,
CancellationToken cancellationToken)
{
var wallet = await _context.Wallets
.Include(w => w.Balances)
.Include(w => w.Transactions)
.FirstOrDefaultAsync(w => w.Id == request.WalletId, cancellationToken);
if (wallet == null) return null;
return new AdminWalletDetailDto(
wallet.Id,
wallet.UserId,
WalletStatus.FromValue<WalletStatus>(wallet.StatusId).Name,
wallet.Balances.Select(b => new BalanceItemDto(
CurrencyType.FromValue<CurrencyType>(b.CurrencyTypeId).Name,
b.Balance)).ToList(),
wallet.Transactions.Count,
wallet.CreatedAt,
wallet.UpdatedAt);
}
}
/// <summary>
/// EN: Handler for SearchWalletsQuery (Admin).
/// VI: Handler cho SearchWalletsQuery (Admin).
/// </summary>
public class SearchWalletsQueryHandler : IRequestHandler<SearchWalletsQuery, List<AdminWalletDetailDto>>
{
private readonly WalletServiceContext _context;
public SearchWalletsQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<List<AdminWalletDetailDto>> Handle(
SearchWalletsQuery request,
CancellationToken cancellationToken)
{
var query = _context.Wallets
.Include(w => w.Balances)
.Include(w => w.Transactions)
.AsQueryable();
if (request.UserId.HasValue)
query = query.Where(w => w.UserId == request.UserId.Value);
if (request.WalletId.HasValue)
query = query.Where(w => w.Id == request.WalletId.Value);
if (!string.IsNullOrEmpty(request.Status))
{
var status = WalletStatus.FromDisplayName<WalletStatus>(request.Status);
if (status != null)
query = query.Where(w => w.StatusId == status.Id);
}
var wallets = await query
.Take(50) // Limit results
.Select(w => new AdminWalletDetailDto(
w.Id,
w.UserId,
WalletStatus.FromValue<WalletStatus>(w.StatusId).Name,
w.Balances.Select(b => new BalanceItemDto(
CurrencyType.FromValue<CurrencyType>(b.CurrencyTypeId).Name,
b.Balance)).ToList(),
w.Transactions.Count,
w.CreatedAt,
w.UpdatedAt))
.ToListAsync(cancellationToken);
return wallets;
}
}
/// <summary>
/// EN: Handler for GetWalletStatisticsQuery (Admin).
/// VI: Handler cho GetWalletStatisticsQuery (Admin).
/// </summary>
public class GetWalletStatisticsQueryHandler : IRequestHandler<GetWalletStatisticsQuery, WalletStatisticsDto>
{
private readonly WalletServiceContext _context;
public GetWalletStatisticsQueryHandler(WalletServiceContext context)
{
_context = context;
}
public async Task<WalletStatisticsDto> Handle(
GetWalletStatisticsQuery request,
CancellationToken cancellationToken)
{
var today = DateTime.UtcNow.Date;
var totalWallets = await _context.Wallets.CountAsync(cancellationToken);
var activeWallets = await _context.Wallets
.CountAsync(w => w.StatusId == WalletStatus.Active.Id, cancellationToken);
var frozenWallets = await _context.Wallets
.CountAsync(w => w.StatusId == WalletStatus.Frozen.Id, cancellationToken);
var closedWallets = await _context.Wallets
.CountAsync(w => w.StatusId == WalletStatus.Closed.Id, cancellationToken);
// EN: Total balance by currency
// VI: Tổng số dư theo loại tiền tệ
var balancesByCurrency = await _context.WalletItems
.GroupBy(wi => wi.CurrencyTypeId)
.Select(g => new { CurrencyId = g.Key, Total = g.Sum(wi => wi.Balance) })
.ToListAsync(cancellationToken);
var totalBalanceByCurrency = balancesByCurrency.ToDictionary(
b => CurrencyType.FromValue<CurrencyType>(b.CurrencyId).Name,
b => b.Total);
// EN: Today's transactions
// VI: Giao dịch hôm nay
var todayTransactions = await _context.WalletTransactions
.Where(t => t.CreatedAt >= today)
.SumAsync(t => t.Amount.Amount, cancellationToken);
var todayDeposits = await _context.WalletTransactions
.Where(t => t.CreatedAt >= today && t.TypeId == TransactionType.Credit.Id)
.SumAsync(t => t.Amount.Amount, cancellationToken);
var todayWithdrawals = await _context.WalletTransactions
.Where(t => t.CreatedAt >= today && t.TypeId == TransactionType.Debit.Id)
.SumAsync(t => t.Amount.Amount, cancellationToken);
return new WalletStatisticsDto(
totalWallets,
activeWallets,
frozenWallets,
closedWallets,
totalBalanceByCurrency,
todayTransactions,
todayDeposits,
todayWithdrawals);
}
}

View File

@@ -8,6 +8,12 @@ using WalletService.Domain.SeedWork;
/// </summary>
public interface IPointAccountRepository : IRepository<PointAccount>
{
/// <summary>
/// EN: Get point account by ID
/// VI: Lấy tài khoản điểm theo ID
/// </summary>
Task<PointAccount?> GetByIdAsync(Guid accountId);
/// <summary>
/// EN: Get point account by user ID
/// VI: Lấy tài khoản điểm theo ID người dùng

View File

@@ -8,6 +8,12 @@ using WalletService.Domain.SeedWork;
/// </summary>
public interface IWalletRepository : IRepository<Wallet>
{
/// <summary>
/// EN: Get wallet by ID
/// VI: Lấy ví theo ID
/// </summary>
Task<Wallet?> GetByIdAsync(Guid walletId);
/// <summary>
/// EN: Get wallet by user ID
/// VI: Lấy ví theo ID người dùng

View File

@@ -19,6 +19,12 @@ public class PointAccountRepository : IPointAccountRepository
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<PointAccount?> GetByIdAsync(Guid accountId)
{
return await _context.PointAccounts
.FirstOrDefaultAsync(p => p.Id == accountId);
}
public async Task<PointAccount?> GetByUserIdAsync(Guid userId)
{
return await _context.PointAccounts

View File

@@ -19,9 +19,17 @@ public class WalletRepository : IWalletRepository
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Wallet?> GetByIdAsync(Guid walletId)
{
return await _context.Wallets
.Include(w => w.Balances)
.FirstOrDefaultAsync(w => w.Id == walletId);
}
public async Task<Wallet?> GetByUserIdAsync(Guid userId)
{
return await _context.Wallets
.Include(w => w.Balances)
.FirstOrDefaultAsync(w => w.UserId == userId);
}