feat: Thêm dịch vụ MerchantService mới và cập nhật các tệp điều khiển thành viên trong MembershipService.
This commit is contained in:
@@ -152,7 +152,7 @@ services:
|
||||
start_period: 40s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.membership-service.rule=PathPrefix(`/api/v1/memberships`) || PathPrefix(`/api/v1/subscriptions`)"
|
||||
- "traefik.http.routers.membership-service.rule=PathPrefix(`/api/v1/members`) || PathPrefix(`/api/v1/levels`) || PathPrefix(`/api/v1/memberships`) || PathPrefix(`/api/v1/subscriptions`)"
|
||||
- "traefik.http.routers.membership-service.entrypoints=web"
|
||||
- "traefik.http.services.membership-service.loadbalancer.server.port=8080"
|
||||
- "traefik.http.services.membership-service.loadbalancer.healthcheck.path=/health/live"
|
||||
|
||||
@@ -52,6 +52,18 @@ http:
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# EN: Membership Service - Member & Level Management
|
||||
# VI: Membership Service - Quản lý Member & Level
|
||||
membership-service-router:
|
||||
rule: "PathPrefix(`/api/v1/members`) || PathPrefix(`/api/v1/levels`)"
|
||||
service: membership-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
- cors
|
||||
- secure-headers
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
services:
|
||||
iam-service:
|
||||
loadBalancer:
|
||||
@@ -73,4 +85,11 @@ http:
|
||||
storage-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://storage-service:8080"
|
||||
- url: "http://storage-service:8080"
|
||||
|
||||
# EN: Membership Service
|
||||
# VI: Membership Service
|
||||
membership-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://membership-service-net:8080"
|
||||
@@ -247,6 +247,11 @@ var app = builder.Build();
|
||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogInformation("Starting IAM Service API / Khởi động IAM Service API");
|
||||
|
||||
// EN: Seed system roles on startup
|
||||
// VI: Seed system roles khi khởi động
|
||||
await IamService.Infrastructure.Data.DataSeeder.SeedRolesAsync(app.Services);
|
||||
|
||||
|
||||
// EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseProblemDetails();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// EN: Database seeder for initial data.
|
||||
// VI: Database seeder cho dữ liệu khởi tạo.
|
||||
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using IamService.Domain.AggregatesModel.RoleAggregate;
|
||||
|
||||
namespace IamService.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Database seeder for system roles and initial data.
|
||||
/// VI: Database seeder cho system roles và dữ liệu khởi tạo.
|
||||
/// </summary>
|
||||
public static class DataSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Seed system roles into the database.
|
||||
/// VI: Seed system roles vào database.
|
||||
/// </summary>
|
||||
public static async Task SeedRolesAsync(IServiceProvider serviceProvider)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<RoleManager<ApplicationRole>>>();
|
||||
|
||||
// EN: Define system roles
|
||||
// VI: Định nghĩa system roles
|
||||
var systemRoles = new (string Name, string Description)[]
|
||||
{
|
||||
// Customer roles
|
||||
("User", "Regular user with basic access"),
|
||||
("PremiumUser", "Premium subscriber with enhanced access"),
|
||||
|
||||
// Merchant roles
|
||||
("Merchant", "Shop owner with full merchant access"),
|
||||
("MerchantStaff", "Shop staff with limited access"),
|
||||
("MerchantAdmin", "Merchant administrator with management access"),
|
||||
|
||||
// Platform roles
|
||||
("Admin", "Platform administrator"),
|
||||
("SuperAdmin", "Super administrator with full system access"),
|
||||
("Support", "Customer support agent"),
|
||||
};
|
||||
|
||||
foreach (var (name, description) in systemRoles)
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(name))
|
||||
{
|
||||
var role = new ApplicationRole(name, description, isSystemRole: true);
|
||||
var result = await roleManager.CreateAsync(role);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"EN: Created system role '{RoleName}' / VI: Đã tạo system role '{RoleName}'",
|
||||
name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
|
||||
logger.LogWarning(
|
||||
"EN: Failed to create role '{RoleName}': {Errors} / VI: Không thể tạo role '{RoleName}': {Errors}",
|
||||
name, errors);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug(
|
||||
"EN: Role '{RoleName}' already exists / VI: Role '{RoleName}' đã tồn tại",
|
||||
name);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"EN: System roles seeding completed / VI: Seeding system roles hoàn thành");
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy project files for layer caching
|
||||
# VI: Sao chép các file project để tận dụng layer caching
|
||||
COPY ["src/MyService.API/MyService.API.csproj", "src/MyService.API/"]
|
||||
COPY ["src/MyService.Domain/MyService.Domain.csproj", "src/MyService.Domain/"]
|
||||
COPY ["src/MyService.Infrastructure/MyService.Infrastructure.csproj", "src/MyService.Infrastructure/"]
|
||||
# EN: Copy solution and project files for layer caching
|
||||
# VI: Sao chép solution và các file project để tận dụng layer caching
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
COPY ["src/MembershipService.API/MembershipService.API.csproj", "src/MembershipService.API/"]
|
||||
COPY ["src/MembershipService.Domain/MembershipService.Domain.csproj", "src/MembershipService.Domain/"]
|
||||
COPY ["src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj", "src/MembershipService.Infrastructure/"]
|
||||
|
||||
# EN: Restore dependencies
|
||||
# VI: Khôi phục dependencies
|
||||
RUN dotnet restore "src/MyService.API/MyService.API.csproj"
|
||||
RUN dotnet restore "src/MembershipService.API/MembershipService.API.csproj"
|
||||
|
||||
# EN: Copy all source code
|
||||
# VI: Sao chép toàn bộ source code
|
||||
@@ -19,17 +19,21 @@ COPY src/ ./src/
|
||||
|
||||
# EN: Build the application
|
||||
# VI: Build ứng dụng
|
||||
WORKDIR "/src/src/MyService.API"
|
||||
RUN dotnet build "MyService.API.csproj" -c Release -o /app/build --no-restore
|
||||
WORKDIR "/src/src/MembershipService.API"
|
||||
RUN dotnet build "MembershipService.API.csproj" -c Release -o /app/build
|
||||
|
||||
# Publish stage / Giai đoạn publish
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "MyService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||
RUN dotnet publish "MembershipService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Runtime stage / Giai đoạn runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# EN: Install curl for health checks
|
||||
# VI: Cài đặt curl cho health checks
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# EN: Create non-root user for security
|
||||
# VI: Tạo user non-root cho bảo mật
|
||||
RUN groupadd -g 1001 dotnetuser && \
|
||||
@@ -63,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
|
||||
# EN: Start the application
|
||||
# VI: Khởi động ứng dụng
|
||||
ENTRYPOINT ["dotnet", "MyService.API.dll"]
|
||||
ENTRYPOINT ["dotnet", "MembershipService.API.dll"]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to add experience points to a member.
|
||||
/// VI: Command để thêm điểm kinh nghiệm cho member.
|
||||
/// </summary>
|
||||
public class AddExperienceCommand : IRequest<AddExperienceResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Member ID.
|
||||
/// VI: ID của member.
|
||||
/// </summary>
|
||||
public Guid MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Amount of EXP points to add.
|
||||
/// VI: Số điểm EXP cộng thêm.
|
||||
/// </summary>
|
||||
public int Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Source of experience (1=Purchase, 2=Referral, 3=Activity, 4=Promotion, 5=Review, 6=CheckIn, 7=Admin).
|
||||
/// VI: Nguồn EXP (1=Mua hàng, 2=Giới thiệu, 3=Hoạt động, 4=Khuyến mãi, 5=Đánh giá, 6=Check-in, 7=Admin).
|
||||
/// </summary>
|
||||
public int SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference ID (Order ID, Referral Code, etc.).
|
||||
/// VI: ID tham chiếu (Order ID, Referral Code, etc.).
|
||||
/// </summary>
|
||||
public string? ReferenceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Additional metadata as JSON string.
|
||||
/// VI: Metadata bổ sung dạng chuỗi JSON.
|
||||
/// </summary>
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of add experience command.
|
||||
/// VI: Kết quả của add experience command.
|
||||
/// </summary>
|
||||
public class AddExperienceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Member ID.
|
||||
/// VI: ID của member.
|
||||
/// </summary>
|
||||
public Guid MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Points added.
|
||||
/// VI: Điểm đã thêm.
|
||||
/// </summary>
|
||||
public int PointsAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New current EXP.
|
||||
/// VI: EXP hiện tại mới.
|
||||
/// </summary>
|
||||
public int CurrentExp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total EXP earned.
|
||||
/// VI: Tổng EXP đã kiếm được.
|
||||
/// </summary>
|
||||
public int TotalExpEarned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous level before adding EXP.
|
||||
/// VI: Level trước khi thêm EXP.
|
||||
/// </summary>
|
||||
public int PreviousLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current level after adding EXP.
|
||||
/// VI: Level hiện tại sau khi thêm EXP.
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether the member leveled up.
|
||||
/// VI: Member có lên level không.
|
||||
/// </summary>
|
||||
public bool LeveledUp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience transaction ID.
|
||||
/// VI: ID của experience transaction.
|
||||
/// </summary>
|
||||
public Guid TransactionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for adding experience points to a member.
|
||||
/// VI: Handler để thêm điểm kinh nghiệm cho member.
|
||||
/// </summary>
|
||||
public class AddExperienceCommandHandler : IRequestHandler<AddExperienceCommand, AddExperienceResult>
|
||||
{
|
||||
private readonly IMemberRepository _memberRepository;
|
||||
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
|
||||
private readonly IExperienceTransactionRepository _experienceTransactionRepository;
|
||||
private readonly ILogger<AddExperienceCommandHandler> _logger;
|
||||
|
||||
public AddExperienceCommandHandler(
|
||||
IMemberRepository memberRepository,
|
||||
ILevelDefinitionRepository levelDefinitionRepository,
|
||||
IExperienceTransactionRepository experienceTransactionRepository,
|
||||
ILogger<AddExperienceCommandHandler> logger)
|
||||
{
|
||||
_memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository));
|
||||
_levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
|
||||
_experienceTransactionRepository = experienceTransactionRepository ?? throw new ArgumentNullException(nameof(experienceTransactionRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AddExperienceResult> Handle(AddExperienceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get member
|
||||
// VI: Lấy member
|
||||
var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken);
|
||||
if (member == null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Member {request.MemberId} not found");
|
||||
}
|
||||
|
||||
// EN: Get active level rules
|
||||
// VI: Lấy level rules đang active
|
||||
var levelRules = await _levelDefinitionRepository.GetAllActiveAsync();
|
||||
if (!levelRules.Any())
|
||||
{
|
||||
throw new InvalidOperationException("No active level definitions found");
|
||||
}
|
||||
|
||||
// EN: Get experience source
|
||||
// VI: Lấy experience source
|
||||
var source = ExperienceSource.FromValue<ExperienceSource>(request.SourceId);
|
||||
|
||||
// EN: Store previous level
|
||||
// VI: Lưu level trước đó
|
||||
var previousLevel = member.CurrentLevel;
|
||||
|
||||
// EN: Add experience and get transaction
|
||||
// VI: Thêm experience và lấy transaction
|
||||
var transaction = member.AddExperience(
|
||||
request.Points,
|
||||
source,
|
||||
levelRules,
|
||||
request.ReferenceId,
|
||||
request.Metadata);
|
||||
|
||||
// EN: Save transaction
|
||||
// VI: Lưu transaction
|
||||
_experienceTransactionRepository.Add(transaction);
|
||||
|
||||
// EN: Update member
|
||||
// VI: Cập nhật member
|
||||
_memberRepository.Update(member);
|
||||
|
||||
// EN: Save all changes
|
||||
// VI: Lưu tất cả thay đổi
|
||||
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added {Points} EXP to member {MemberId} from {Source}. Level: {PrevLevel} -> {NewLevel}",
|
||||
request.Points, request.MemberId, source.Name, previousLevel, member.CurrentLevel);
|
||||
|
||||
return new AddExperienceResult
|
||||
{
|
||||
MemberId = member.Id,
|
||||
PointsAdded = request.Points,
|
||||
CurrentExp = member.CurrentExp,
|
||||
TotalExpEarned = member.TotalExpEarned,
|
||||
PreviousLevel = previousLevel,
|
||||
CurrentLevel = member.CurrentLevel,
|
||||
LeveledUp = member.CurrentLevel > previousLevel,
|
||||
TransactionId = transaction.Id
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change membership level.
|
||||
/// VI: Command để thay đổi cấp thành viên.
|
||||
/// </summary>
|
||||
public class ChangeMembershipLevelCommand : IRequest<ChangeMembershipLevelResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Member ID.
|
||||
/// VI: ID member.
|
||||
/// </summary>
|
||||
public Guid MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New membership level ID.
|
||||
/// VI: ID cấp thành viên mới.
|
||||
/// </summary>
|
||||
public int NewLevelId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of change membership level command.
|
||||
/// VI: Kết quả của change membership level command.
|
||||
/// </summary>
|
||||
public class ChangeMembershipLevelResult
|
||||
{
|
||||
public Guid MemberId { get; set; }
|
||||
public string OldLevel { get; set; } = null!;
|
||||
public string NewLevel { get; set; } = null!;
|
||||
public DateTime ChangedAt { get; set; }
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for changing membership level.
|
||||
/// VI: Handler để thay đổi cấp thành viên.
|
||||
/// </summary>
|
||||
public class ChangeMembershipLevelCommandHandler : IRequestHandler<ChangeMembershipLevelCommand, ChangeMembershipLevelResult>
|
||||
{
|
||||
private readonly IMemberRepository _memberRepository;
|
||||
private readonly ILogger<ChangeMembershipLevelCommandHandler> _logger;
|
||||
|
||||
public ChangeMembershipLevelCommandHandler(
|
||||
IMemberRepository memberRepository,
|
||||
ILogger<ChangeMembershipLevelCommandHandler> logger)
|
||||
{
|
||||
_memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ChangeMembershipLevelResult> Handle(ChangeMembershipLevelCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken);
|
||||
if (member == null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Member {request.MemberId} not found");
|
||||
}
|
||||
|
||||
var newLevel = MembershipLevel.FromValue<MembershipLevel>(request.NewLevelId);
|
||||
if (newLevel == null)
|
||||
{
|
||||
throw new ArgumentException($"Invalid membership level ID: {request.NewLevelId}");
|
||||
}
|
||||
|
||||
var oldLevel = member.MembershipLevel;
|
||||
member.ChangeMembershipLevel(newLevel);
|
||||
|
||||
_memberRepository.Update(member);
|
||||
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Changed membership level for member {MemberId} from {OldLevel} to {NewLevel}",
|
||||
request.MemberId, oldLevel.Name, newLevel.Name);
|
||||
|
||||
return new ChangeMembershipLevelResult
|
||||
{
|
||||
MemberId = member.Id,
|
||||
OldLevel = oldLevel.Name,
|
||||
NewLevel = newLevel.Name,
|
||||
ChangedAt = member.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ public class CreateMemberResult
|
||||
{
|
||||
public Guid MemberId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string MembershipLevel { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Initial level (always 1 for new members).
|
||||
/// VI: Level ban đầu (luôn là 1 cho member mới).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -30,20 +30,21 @@ public class CreateMemberCommandHandler : IRequestHandler<CreateMemberCommand, C
|
||||
throw new InvalidOperationException($"Member already exists for user {request.UserId}");
|
||||
}
|
||||
|
||||
// EN: Create new member with gender
|
||||
// VI: Tạo member mới với gender
|
||||
// EN: Create new member with gender (starts at level 1, exp 0)
|
||||
// VI: Tạo member mới với gender (bắt đầu ở level 1, exp 0)
|
||||
var member = new Member(request.UserId, request.CountryCode, request.Gender);
|
||||
|
||||
_memberRepository.Add(member);
|
||||
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Created member {MemberId} for user {UserId}", member.Id, request.UserId);
|
||||
_logger.LogInformation("Created member {MemberId} for user {UserId} at level {Level}",
|
||||
member.Id, request.UserId, member.CurrentLevel);
|
||||
|
||||
return new CreateMemberResult
|
||||
{
|
||||
MemberId = member.Id,
|
||||
UserId = member.UserId,
|
||||
MembershipLevel = member.MembershipLevel.Name,
|
||||
CurrentLevel = member.CurrentLevel,
|
||||
CreatedAt = member.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MembershipService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get experience history for a member.
|
||||
/// VI: Query để lấy lịch sử EXP của member.
|
||||
/// </summary>
|
||||
public class GetExperienceHistoryQuery : IRequest<ExperienceHistoryResult>
|
||||
{
|
||||
public Guid MemberId { get; set; }
|
||||
public int PageIndex { get; set; } = 0;
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
public GetExperienceHistoryQuery(Guid memberId, int pageIndex = 0, int pageSize = 20)
|
||||
{
|
||||
MemberId = memberId;
|
||||
PageIndex = pageIndex;
|
||||
PageSize = pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience history result.
|
||||
/// VI: Kết quả lịch sử EXP.
|
||||
/// </summary>
|
||||
public class ExperienceHistoryResult
|
||||
{
|
||||
public IEnumerable<ExperienceTransactionDto> Transactions { get; set; } = Enumerable.Empty<ExperienceTransactionDto>();
|
||||
public int TotalCount { get; set; }
|
||||
public int PageIndex { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience transaction DTO.
|
||||
/// VI: DTO giao dịch EXP.
|
||||
/// </summary>
|
||||
public class ExperienceTransactionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Transaction ID.
|
||||
/// VI: ID giao dịch.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Points earned.
|
||||
/// VI: Điểm kiếm được.
|
||||
/// </summary>
|
||||
public int Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Source name.
|
||||
/// VI: Tên nguồn.
|
||||
/// </summary>
|
||||
public string Source { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Source ID.
|
||||
/// VI: ID nguồn.
|
||||
/// </summary>
|
||||
public int SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference ID.
|
||||
/// VI: ID tham chiếu.
|
||||
/// </summary>
|
||||
public string? ReferenceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level at time of transaction.
|
||||
/// VI: Level tại thời điểm giao dịch.
|
||||
/// </summary>
|
||||
public int LevelAtTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for getting experience history.
|
||||
/// VI: Handler để lấy lịch sử EXP.
|
||||
/// </summary>
|
||||
public class GetExperienceHistoryQueryHandler : IRequestHandler<GetExperienceHistoryQuery, ExperienceHistoryResult>
|
||||
{
|
||||
private readonly IExperienceTransactionRepository _experienceTransactionRepository;
|
||||
|
||||
public GetExperienceHistoryQueryHandler(IExperienceTransactionRepository experienceTransactionRepository)
|
||||
{
|
||||
_experienceTransactionRepository = experienceTransactionRepository
|
||||
?? throw new ArgumentNullException(nameof(experienceTransactionRepository));
|
||||
}
|
||||
|
||||
public async Task<ExperienceHistoryResult> Handle(GetExperienceHistoryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var skip = request.PageIndex * request.PageSize;
|
||||
|
||||
var transactions = await _experienceTransactionRepository.GetByMemberIdAsync(
|
||||
request.MemberId,
|
||||
skip,
|
||||
request.PageSize);
|
||||
|
||||
var totalCount = await _experienceTransactionRepository.GetCountByMemberIdAsync(request.MemberId);
|
||||
|
||||
var transactionDtos = transactions.Select(t => new ExperienceTransactionDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Points = t.Points,
|
||||
Source = t.Source?.Name ?? "Unknown",
|
||||
SourceId = t.SourceId,
|
||||
ReferenceId = t.ReferenceId,
|
||||
LevelAtTime = t.LevelAtTime,
|
||||
CreatedAt = t.CreatedAt
|
||||
});
|
||||
|
||||
return new ExperienceHistoryResult
|
||||
{
|
||||
Transactions = transactionDtos,
|
||||
TotalCount = totalCount,
|
||||
PageIndex = request.PageIndex,
|
||||
PageSize = request.PageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MembershipService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all level definitions.
|
||||
/// VI: Query để lấy tất cả level definitions.
|
||||
/// </summary>
|
||||
public class GetLevelDefinitionsQuery : IRequest<IEnumerable<LevelDefinitionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Whether to include inactive levels.
|
||||
/// VI: Có bao gồm levels không active không.
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level definition DTO.
|
||||
/// VI: DTO level definition.
|
||||
/// </summary>
|
||||
public class LevelDefinitionDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public int LevelNumber { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
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 IEnumerable<LevelBenefitDto> Benefits { get; set; } = Enumerable.Empty<LevelBenefitDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level benefit DTO.
|
||||
/// VI: DTO level benefit.
|
||||
/// </summary>
|
||||
public class LevelBenefitDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string BenefitType { get; set; } = null!;
|
||||
public string BenefitValue { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for getting level definitions.
|
||||
/// VI: Handler để lấy level definitions.
|
||||
/// </summary>
|
||||
public class GetLevelDefinitionsQueryHandler : IRequestHandler<GetLevelDefinitionsQuery, IEnumerable<LevelDefinitionDto>>
|
||||
{
|
||||
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
|
||||
|
||||
public GetLevelDefinitionsQueryHandler(ILevelDefinitionRepository levelDefinitionRepository)
|
||||
{
|
||||
_levelDefinitionRepository = levelDefinitionRepository
|
||||
?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LevelDefinitionDto>> Handle(GetLevelDefinitionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var levels = request.IncludeInactive
|
||||
? await _levelDefinitionRepository.GetAllAsync()
|
||||
: await _levelDefinitionRepository.GetAllActiveAsync();
|
||||
|
||||
return levels.Select(l => new LevelDefinitionDto
|
||||
{
|
||||
Id = l.Id,
|
||||
LevelNumber = l.LevelNumber,
|
||||
Name = l.Name,
|
||||
RequiredExp = l.RequiredExp,
|
||||
Description = l.Description,
|
||||
IconUrl = l.IconUrl,
|
||||
BadgeColor = l.BadgeColor,
|
||||
IsActive = l.IsActive,
|
||||
Benefits = l.Benefits.Select(b => new LevelBenefitDto
|
||||
{
|
||||
Id = b.Id,
|
||||
BenefitType = b.BenefitType,
|
||||
BenefitValue = b.BenefitValue,
|
||||
Description = b.Description,
|
||||
IsActive = b.IsActive
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,17 +31,25 @@ public class MemberDto
|
||||
public string? Gender { get; set; }
|
||||
public string CountryCode { get; set; } = null!;
|
||||
public string? Preferences { get; set; }
|
||||
public MembershipLevelDto MembershipLevel { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current member level (1, 2, 3...).
|
||||
/// VI: Level hiện tại của member (1, 2, 3...).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current experience points.
|
||||
/// VI: Điểm kinh nghiệm hiện tại.
|
||||
/// </summary>
|
||||
public int CurrentExp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total experience points ever earned.
|
||||
/// VI: Tổng điểm kinh nghiệm đã kiếm được.
|
||||
/// </summary>
|
||||
public int TotalExpEarned { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Membership level DTO.
|
||||
/// VI: DTO cấp thành viên.
|
||||
/// </summary>
|
||||
public class MembershipLevelDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -36,11 +36,9 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
|
||||
Gender = member.Gender,
|
||||
CountryCode = member.CountryCode,
|
||||
Preferences = member.Preferences,
|
||||
MembershipLevel = new MembershipLevelDto
|
||||
{
|
||||
Id = member.MembershipLevel.Id,
|
||||
Name = member.MembershipLevel.Name
|
||||
},
|
||||
CurrentLevel = member.CurrentLevel,
|
||||
CurrentExp = member.CurrentExp,
|
||||
TotalExpEarned = member.TotalExpEarned,
|
||||
CreatedAt = member.CreatedAt,
|
||||
UpdatedAt = member.UpdatedAt
|
||||
};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MembershipService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get member level progress.
|
||||
/// VI: Query để lấy tiến độ level của member.
|
||||
/// </summary>
|
||||
public class GetMemberProgressQuery : IRequest<MemberProgressDto?>
|
||||
{
|
||||
public Guid MemberId { get; set; }
|
||||
|
||||
public GetMemberProgressQuery(Guid memberId)
|
||||
{
|
||||
MemberId = memberId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Member progress DTO.
|
||||
/// VI: DTO tiến độ member.
|
||||
/// </summary>
|
||||
public class MemberProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Member ID.
|
||||
/// VI: ID của member.
|
||||
/// </summary>
|
||||
public Guid MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current level number.
|
||||
/// VI: Số level hiện tại.
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current level name.
|
||||
/// VI: Tên level hiện tại.
|
||||
/// </summary>
|
||||
public string CurrentLevelName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current experience points.
|
||||
/// VI: Điểm kinh nghiệm hiện tại.
|
||||
/// </summary>
|
||||
public int CurrentExp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total experience points ever earned.
|
||||
/// VI: Tổng điểm kinh nghiệm đã kiếm được.
|
||||
/// </summary>
|
||||
public int TotalExpEarned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP required for next level.
|
||||
/// VI: EXP cần để lên level tiếp.
|
||||
/// </summary>
|
||||
public int ExpToNextLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Progress percentage to next level (0-100).
|
||||
/// VI: Phần trăm tiến độ đến level tiếp (0-100).
|
||||
/// </summary>
|
||||
public int ProgressPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Next level number (null if max level).
|
||||
/// VI: Số level tiếp theo (null nếu đã max).
|
||||
/// </summary>
|
||||
public int? NextLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Next level name (null if max level).
|
||||
/// VI: Tên level tiếp theo (null nếu đã max).
|
||||
/// </summary>
|
||||
public string? NextLevelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Badge color for current level.
|
||||
/// VI: Màu badge của level hiện tại.
|
||||
/// </summary>
|
||||
public string? BadgeColor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
namespace MembershipService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for getting member level progress.
|
||||
/// VI: Handler để lấy tiến độ level của member.
|
||||
/// </summary>
|
||||
public class GetMemberProgressQueryHandler : IRequestHandler<GetMemberProgressQuery, MemberProgressDto?>
|
||||
{
|
||||
private readonly IMemberRepository _memberRepository;
|
||||
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
|
||||
|
||||
public GetMemberProgressQueryHandler(
|
||||
IMemberRepository memberRepository,
|
||||
ILevelDefinitionRepository levelDefinitionRepository)
|
||||
{
|
||||
_memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository));
|
||||
_levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
|
||||
}
|
||||
|
||||
public async Task<MemberProgressDto?> Handle(GetMemberProgressQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken);
|
||||
if (member == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// EN: Get all active level definitions
|
||||
// VI: Lấy tất cả level definitions đang active
|
||||
var levelRules = await _levelDefinitionRepository.GetAllActiveAsync();
|
||||
if (!levelRules.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// EN: Find current and next level
|
||||
// VI: Tìm level hiện tại và level tiếp theo
|
||||
var currentLevelDef = levelRules.FirstOrDefault(l => l.LevelNumber == member.CurrentLevel);
|
||||
var nextLevelDef = levelRules
|
||||
.Where(l => l.LevelNumber > member.CurrentLevel)
|
||||
.OrderBy(l => l.LevelNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
// EN: Calculate progress
|
||||
// VI: Tính toán tiến độ
|
||||
int expToNextLevel = 0;
|
||||
int progressPercent = 100;
|
||||
|
||||
if (nextLevelDef != null && currentLevelDef != null)
|
||||
{
|
||||
var expInCurrentLevel = member.CurrentExp - currentLevelDef.RequiredExp;
|
||||
var expNeededForNextLevel = nextLevelDef.RequiredExp - currentLevelDef.RequiredExp;
|
||||
|
||||
expToNextLevel = nextLevelDef.RequiredExp - member.CurrentExp;
|
||||
progressPercent = expNeededForNextLevel > 0
|
||||
? (int)(expInCurrentLevel * 100.0 / expNeededForNextLevel)
|
||||
: 100;
|
||||
|
||||
progressPercent = Math.Clamp(progressPercent, 0, 100);
|
||||
}
|
||||
|
||||
return new MemberProgressDto
|
||||
{
|
||||
MemberId = member.Id,
|
||||
CurrentLevel = member.CurrentLevel,
|
||||
CurrentLevelName = currentLevelDef?.Name ?? $"Level {member.CurrentLevel}",
|
||||
CurrentExp = member.CurrentExp,
|
||||
TotalExpEarned = member.TotalExpEarned,
|
||||
ExpToNextLevel = expToNextLevel,
|
||||
ProgressPercent = progressPercent,
|
||||
NextLevel = nextLevelDef?.LevelNumber,
|
||||
NextLevelName = nextLevelDef?.Name,
|
||||
BadgeColor = currentLevelDef?.BadgeColor
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,9 @@ public class GetMembersQueryHandler : IRequestHandler<GetMembersQuery, GetMember
|
||||
Gender = m.Gender,
|
||||
CountryCode = m.CountryCode,
|
||||
Preferences = m.Preferences,
|
||||
MembershipLevel = new MembershipLevelDto
|
||||
{
|
||||
Id = m.MembershipLevel.Id,
|
||||
Name = m.MembershipLevel.Name
|
||||
},
|
||||
CurrentLevel = m.CurrentLevel,
|
||||
CurrentExp = m.CurrentExp,
|
||||
TotalExpEarned = m.TotalExpEarned,
|
||||
CreatedAt = m.CreatedAt,
|
||||
UpdatedAt = m.UpdatedAt
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MembershipService.API.Application.Queries;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace MembershipService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for managing level definitions.
|
||||
/// VI: Controller để quản lý level definitions.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[SwaggerTag("Level definitions management endpoints")]
|
||||
public class LevelsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<LevelsController> _logger;
|
||||
|
||||
public LevelsController(IMediator mediator, ILogger<LevelsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all level definitions.
|
||||
/// VI: Lấy tất cả level definitions.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[SwaggerOperation(Summary = "Get level definitions", Description = "Retrieves all level definitions")]
|
||||
[SwaggerResponse(200, "Level definitions retrieved", typeof(IEnumerable<LevelDefinitionDto>))]
|
||||
public async Task<ActionResult<IEnumerable<LevelDefinitionDto>>> GetAll(
|
||||
[FromQuery] bool includeInactive = false)
|
||||
{
|
||||
var query = new GetLevelDefinitionsQuery { IncludeInactive = includeInactive };
|
||||
var levels = await _mediator.Send(query);
|
||||
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)
|
||||
}
|
||||
@@ -144,19 +144,24 @@ public class MembersController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add experience and level endpoints in Phase 4
|
||||
// POST /api/v1/members/{id}/experience - Add EXP
|
||||
// GET /api/v1/members/{id}/progress - Get level progress
|
||||
// GET /api/v1/members/{id}/experience - Get EXP history
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change membership level.
|
||||
/// VI: Thay đổi cấp thành viên.
|
||||
/// EN: Add experience points to a member.
|
||||
/// VI: Thêm điểm kinh nghiệm cho member.
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}/level")]
|
||||
[SwaggerOperation(Summary = "Change membership level", Description = "Changes a member's membership level")]
|
||||
[SwaggerResponse(200, "Level changed", typeof(ChangeMembershipLevelResult))]
|
||||
[HttpPost("{id:guid}/experience")]
|
||||
[SwaggerOperation(Summary = "Add experience points", Description = "Adds experience points to a member")]
|
||||
[SwaggerResponse(200, "Experience added", typeof(AddExperienceResult))]
|
||||
[SwaggerResponse(400, "Invalid request")]
|
||||
[SwaggerResponse(404, "Member not found")]
|
||||
[SwaggerResponse(401, "Unauthorized")]
|
||||
public async Task<ActionResult<ChangeMembershipLevelResult>> ChangeLevel(
|
||||
public async Task<ActionResult<AddExperienceResult>> AddExperience(
|
||||
Guid id,
|
||||
[FromBody] ChangeMembershipLevelCommand command)
|
||||
[FromBody] AddExperienceCommand command)
|
||||
{
|
||||
command.MemberId = id;
|
||||
|
||||
@@ -175,6 +180,42 @@ public class MembersController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get member's level progress.
|
||||
/// VI: Lấy tiến độ level của member.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/progress")]
|
||||
[SwaggerOperation(Summary = "Get level progress", Description = "Retrieves member's level progress")]
|
||||
[SwaggerResponse(200, "Progress retrieved", typeof(MemberProgressDto))]
|
||||
[SwaggerResponse(404, "Member not found")]
|
||||
[SwaggerResponse(401, "Unauthorized")]
|
||||
public async Task<ActionResult<MemberProgressDto>> GetProgress(Guid id)
|
||||
{
|
||||
var progress = await _mediator.Send(new GetMemberProgressQuery(id));
|
||||
if (progress == null)
|
||||
{
|
||||
return NotFound(new { message = $"Member {id} not found" });
|
||||
}
|
||||
return Ok(progress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get member's experience history.
|
||||
/// VI: Lấy lịch sử EXP của member.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/experience")]
|
||||
[SwaggerOperation(Summary = "Get experience history", Description = "Retrieves member's experience transaction history")]
|
||||
[SwaggerResponse(200, "History retrieved", typeof(ExperienceHistoryResult))]
|
||||
[SwaggerResponse(401, "Unauthorized")]
|
||||
public async Task<ActionResult<ExperienceHistoryResult>> GetExperienceHistory(
|
||||
Guid id,
|
||||
[FromQuery] int pageIndex = 0,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _mediator.Send(new GetExperienceHistoryQuery(id, pageIndex, pageSize));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst("id")?.Value;
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience source enumeration - defines where EXP comes from.
|
||||
/// VI: Experience source enumeration - định nghĩa nguồn gốc EXP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Uses Enumeration pattern for type-safe enum with rich behavior.
|
||||
/// VI: Sử dụng Enumeration pattern cho enum an toàn kiểu với hành vi phong phú.
|
||||
/// </remarks>
|
||||
public class ExperienceSource : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: EXP from purchase/order completion.
|
||||
/// VI: EXP từ hoàn thành đơn hàng.
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource Purchase = new(1, nameof(Purchase));
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP from friend referral.
|
||||
/// VI: EXP từ giới thiệu bạn bè.
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource Referral = new(2, nameof(Referral));
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP from app activity (browsing, engagement).
|
||||
/// VI: EXP từ hoạt động trên app (duyệt, tương tác).
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource Activity = new(3, nameof(Activity));
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP from promotional campaigns.
|
||||
/// VI: EXP từ chiến dịch khuyến mãi.
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource Promotion = new(4, nameof(Promotion));
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP from product reviews.
|
||||
/// VI: EXP từ đánh giá sản phẩm.
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource Review = new(5, nameof(Review));
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP from daily check-in.
|
||||
/// VI: EXP từ check-in hàng ngày.
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource CheckIn = new(6, nameof(CheckIn));
|
||||
|
||||
/// <summary>
|
||||
/// EN: EXP manually granted by admin.
|
||||
/// VI: EXP được admin cấp thủ công.
|
||||
/// </summary>
|
||||
public static readonly ExperienceSource Admin = new(7, nameof(Admin));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for Enumeration pattern.
|
||||
/// VI: Constructor private cho Enumeration pattern.
|
||||
/// </summary>
|
||||
public ExperienceSource(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience transaction entity - tracks EXP history and source.
|
||||
/// VI: Experience transaction entity - theo dõi lịch sử và nguồn gốc EXP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Every time a member receives EXP, a transaction is created for audit trail.
|
||||
/// This enables analytics on where EXP comes from and member engagement patterns.
|
||||
/// VI: Mỗi lần member nhận EXP, một transaction được tạo để audit.
|
||||
/// Điều này cho phép phân tích nguồn gốc EXP và patterns tương tác của member.
|
||||
/// </remarks>
|
||||
public class ExperienceTransaction : Entity
|
||||
{
|
||||
private Guid _memberId;
|
||||
private int _points;
|
||||
private int _sourceId;
|
||||
private ExperienceSource _source = null!;
|
||||
private string? _referenceId;
|
||||
private string? _metadata; // JSON
|
||||
private int _levelAtTime;
|
||||
private DateTime _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Member ID who received the EXP.
|
||||
/// VI: ID của member nhận EXP.
|
||||
/// </summary>
|
||||
public Guid MemberId => _memberId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Amount of EXP points earned.
|
||||
/// VI: Số điểm EXP kiếm được.
|
||||
/// </summary>
|
||||
public int Points => _points;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience source ID (for EF Core mapping).
|
||||
/// VI: ID nguồn EXP (cho EF Core mapping).
|
||||
/// </summary>
|
||||
public int SourceId => _sourceId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience source.
|
||||
/// VI: Nguồn EXP.
|
||||
/// </summary>
|
||||
public ExperienceSource Source => _source;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference ID (Order ID, Referral Code, etc.).
|
||||
/// VI: ID tham chiếu (Order ID, Referral Code, etc.).
|
||||
/// </summary>
|
||||
public string? ReferenceId => _referenceId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Additional metadata as JSON string.
|
||||
/// VI: Metadata bổ sung dạng chuỗi JSON.
|
||||
/// </summary>
|
||||
public string? Metadata => _metadata;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Member's level when this EXP was earned.
|
||||
/// VI: Level của member khi nhận EXP này.
|
||||
/// </summary>
|
||||
public int LevelAtTime => _levelAtTime;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected ExperienceTransaction()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create new experience transaction.
|
||||
/// VI: Tạo experience transaction mới.
|
||||
/// </summary>
|
||||
/// <param name="memberId">Member ID / ID của member</param>
|
||||
/// <param name="points">EXP points / Số điểm EXP</param>
|
||||
/// <param name="source">Experience source / Nguồn EXP</param>
|
||||
/// <param name="levelAtTime">Member's current level / Level hiện tại của member</param>
|
||||
/// <param name="referenceId">Reference ID (optional) / ID tham chiếu (tùy chọn)</param>
|
||||
/// <param name="metadata">Metadata JSON (optional) / Metadata JSON (tùy chọn)</param>
|
||||
public ExperienceTransaction(
|
||||
Guid memberId,
|
||||
int points,
|
||||
ExperienceSource source,
|
||||
int levelAtTime,
|
||||
string? referenceId = null,
|
||||
string? metadata = null)
|
||||
{
|
||||
if (memberId == Guid.Empty)
|
||||
throw new ArgumentException("Member ID cannot be empty", nameof(memberId));
|
||||
if (points <= 0)
|
||||
throw new ArgumentException("Points must be positive", nameof(points));
|
||||
if (source == null)
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
if (levelAtTime < 1)
|
||||
throw new ArgumentException("Level at time must be at least 1", nameof(levelAtTime));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_memberId = memberId;
|
||||
_points = points;
|
||||
_sourceId = source.Id;
|
||||
_source = source;
|
||||
_levelAtTime = levelAtTime;
|
||||
_referenceId = referenceId;
|
||||
_metadata = metadata;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set experience source (for EF Core loading).
|
||||
/// VI: Set experience source (cho EF Core loading).
|
||||
/// </summary>
|
||||
public void SetSource(ExperienceSource source)
|
||||
{
|
||||
_source = source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for ExperienceTransaction.
|
||||
/// VI: Interface repository cho ExperienceTransaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: ExperienceTransaction is not an aggregate root, it belongs to Member aggregate.
|
||||
/// This repository is for convenience of querying transactions.
|
||||
/// VI: ExperienceTransaction không phải aggregate root, nó thuộc Member aggregate.
|
||||
/// Repository này để tiện truy vấn transactions.
|
||||
/// </remarks>
|
||||
public interface IExperienceTransactionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The unit of work for this repository.
|
||||
/// VI: Unit of work cho repository này.
|
||||
/// </summary>
|
||||
IUnitOfWork UnitOfWork { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get experience transaction by ID.
|
||||
/// VI: Lấy experience transaction theo ID.
|
||||
/// </summary>
|
||||
Task<ExperienceTransaction?> GetAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get paginated experience transactions for a member.
|
||||
/// VI: Lấy experience transactions phân trang cho một member.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExperienceTransaction>> GetByMemberIdAsync(
|
||||
Guid memberId,
|
||||
int skip = 0,
|
||||
int take = 20);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get experience transactions by source for a member.
|
||||
/// VI: Lấy experience transactions theo nguồn cho một member.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExperienceTransaction>> GetBySourceAsync(
|
||||
Guid memberId,
|
||||
ExperienceSource source);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get total EXP points for a member.
|
||||
/// VI: Lấy tổng điểm EXP của một member.
|
||||
/// </summary>
|
||||
Task<int> GetTotalPointsByMemberIdAsync(Guid memberId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get total EXP points by source for a member.
|
||||
/// VI: Lấy tổng điểm EXP theo nguồn cho một member.
|
||||
/// </summary>
|
||||
Task<int> GetTotalPointsBySourceAsync(Guid memberId, ExperienceSource source);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get transaction count for a member.
|
||||
/// VI: Lấy số lượng transactions của một member.
|
||||
/// </summary>
|
||||
Task<int> GetCountByMemberIdAsync(Guid memberId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add new experience transaction.
|
||||
/// VI: Thêm experience transaction mới.
|
||||
/// </summary>
|
||||
ExperienceTransaction Add(ExperienceTransaction transaction);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for LevelDefinition aggregate.
|
||||
/// VI: Interface repository cho LevelDefinition aggregate.
|
||||
/// </summary>
|
||||
public interface ILevelDefinitionRepository : IRepository<LevelDefinition>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get level definition by ID.
|
||||
/// VI: Lấy level definition theo ID.
|
||||
/// </summary>
|
||||
Task<LevelDefinition?> GetAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get level definition by level number.
|
||||
/// VI: Lấy level definition theo số thứ tự level.
|
||||
/// </summary>
|
||||
Task<LevelDefinition?> GetByLevelNumberAsync(int levelNumber);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get level definition with benefits.
|
||||
/// VI: Lấy level definition kèm benefits.
|
||||
/// </summary>
|
||||
Task<LevelDefinition?> GetWithBenefitsAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all active level definitions ordered by level number.
|
||||
/// VI: Lấy tất cả level definitions đang active, sắp xếp theo số thứ tự.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LevelDefinition>> GetAllActiveAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all level definitions (including inactive).
|
||||
/// VI: Lấy tất cả level definitions (bao gồm cả inactive).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LevelDefinition>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if level number already exists.
|
||||
/// VI: Kiểm tra xem số thứ tự level đã tồn tại chưa.
|
||||
/// </summary>
|
||||
Task<bool> ExistsByLevelNumberAsync(int levelNumber);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add new level definition.
|
||||
/// VI: Thêm level definition mới.
|
||||
/// </summary>
|
||||
LevelDefinition Add(LevelDefinition levelDefinition);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update level definition.
|
||||
/// VI: Cập nhật level definition.
|
||||
/// </summary>
|
||||
void Update(LevelDefinition levelDefinition);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level benefit entity - represents a reward/perk for a level.
|
||||
/// VI: Level benefit entity - đại diện cho reward/perk của một level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Benefits are stored as JSON value for flexibility. Common types:
|
||||
/// - discount_percent: {"percent": 10}
|
||||
/// - free_shipping: {"enabled": true}
|
||||
/// - priority_support: {"enabled": true}
|
||||
/// - bonus_points: {"multiplier": 1.5}
|
||||
/// - exclusive_access: {"feature": "early_sale"}
|
||||
/// VI: Benefits được lưu dạng JSON để linh hoạt. Các loại thường gặp:
|
||||
/// - discount_percent (giảm giá %)
|
||||
/// - free_shipping (miễn phí ship)
|
||||
/// - priority_support (hỗ trợ ưu tiên)
|
||||
/// - bonus_points (nhân điểm)
|
||||
/// - exclusive_access (truy cập độc quyền)
|
||||
/// </remarks>
|
||||
public class LevelBenefit : Entity
|
||||
{
|
||||
private Guid _levelDefinitionId;
|
||||
private string _benefitType;
|
||||
private string _benefitValue; // JSON
|
||||
private string? _description;
|
||||
private bool _isActive;
|
||||
private DateTime _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Foreign key to LevelDefinition.
|
||||
/// VI: Foreign key đến LevelDefinition.
|
||||
/// </summary>
|
||||
public Guid LevelDefinitionId => _levelDefinitionId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Benefit type (discount_percent, free_shipping, etc.).
|
||||
/// VI: Loại benefit (discount_percent, free_shipping, etc.).
|
||||
/// </summary>
|
||||
public string BenefitType => _benefitType;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Benefit value as JSON string.
|
||||
/// VI: Giá trị benefit dạng chuỗi JSON.
|
||||
/// </summary>
|
||||
public string BenefitValue => _benefitValue;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Benefit description.
|
||||
/// VI: Mô tả benefit.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether this benefit is active.
|
||||
/// VI: Benefit có đang active không.
|
||||
/// </summary>
|
||||
public bool IsActive => _isActive;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected LevelBenefit()
|
||||
{
|
||||
_benefitType = string.Empty;
|
||||
_benefitValue = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create new level benefit.
|
||||
/// VI: Tạo level benefit mới.
|
||||
/// </summary>
|
||||
/// <param name="levelDefinitionId">Level definition ID / ID của level definition</param>
|
||||
/// <param name="benefitType">Benefit type / Loại benefit</param>
|
||||
/// <param name="benefitValue">Benefit value as JSON / Giá trị benefit dạng JSON</param>
|
||||
/// <param name="description">Description / Mô tả</param>
|
||||
public LevelBenefit(
|
||||
Guid levelDefinitionId,
|
||||
string benefitType,
|
||||
string benefitValue,
|
||||
string? description = null) : this()
|
||||
{
|
||||
if (levelDefinitionId == Guid.Empty)
|
||||
throw new ArgumentException("Level definition ID cannot be empty", nameof(levelDefinitionId));
|
||||
if (string.IsNullOrWhiteSpace(benefitType))
|
||||
throw new ArgumentException("Benefit type cannot be empty", nameof(benefitType));
|
||||
if (string.IsNullOrWhiteSpace(benefitValue))
|
||||
throw new ArgumentException("Benefit value cannot be empty", nameof(benefitValue));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_levelDefinitionId = levelDefinitionId;
|
||||
_benefitType = benefitType;
|
||||
_benefitValue = benefitValue;
|
||||
_description = description;
|
||||
_isActive = true;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update benefit value.
|
||||
/// VI: Cập nhật giá trị benefit.
|
||||
/// </summary>
|
||||
public void UpdateBenefitValue(string benefitValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(benefitValue))
|
||||
throw new ArgumentException("Benefit value cannot be empty", nameof(benefitValue));
|
||||
|
||||
_benefitValue = benefitValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update description.
|
||||
/// VI: Cập nhật mô tả.
|
||||
/// </summary>
|
||||
public void UpdateDescription(string? description)
|
||||
{
|
||||
_description = description;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate this benefit.
|
||||
/// VI: Kích hoạt benefit này.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
_isActive = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate this benefit.
|
||||
/// VI: Vô hiệu hóa benefit này.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level definition aggregate root - configurable level rules.
|
||||
/// VI: Level definition aggregate root - cấu hình level có thể tùy chỉnh.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Admin can customize level definitions through API.
|
||||
/// Each level has a required EXP threshold and associated benefits.
|
||||
/// VI: Admin có thể tùy chỉnh level definitions qua API.
|
||||
/// Mỗi level có ngưỡng EXP yêu cầu và các benefits đi kèm.
|
||||
/// </remarks>
|
||||
public class LevelDefinition : Entity, IAggregateRoot
|
||||
{
|
||||
private readonly List<LevelBenefit> _benefits = new();
|
||||
|
||||
private int _levelNumber;
|
||||
private string _name;
|
||||
private int _requiredExp;
|
||||
private string? _description;
|
||||
private string? _iconUrl;
|
||||
private string? _badgeColor;
|
||||
private bool _isActive;
|
||||
private DateTime _createdAt;
|
||||
private DateTime _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level number (1, 2, 3...).
|
||||
/// VI: Số thứ tự level (1, 2, 3...).
|
||||
/// </summary>
|
||||
public int LevelNumber => _levelNumber;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level name (Bronze, Silver, Gold...).
|
||||
/// VI: Tên level (Bronze, Silver, Gold...).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Required EXP to reach this level.
|
||||
/// VI: EXP cần thiết để đạt level này.
|
||||
/// </summary>
|
||||
public int RequiredExp => _requiredExp;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level description.
|
||||
/// VI: Mô tả level.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Icon URL for the level badge.
|
||||
/// VI: URL icon cho badge level.
|
||||
/// </summary>
|
||||
public string? IconUrl => _iconUrl;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Badge color in hex format (#CD7F32, #FFD700...).
|
||||
/// VI: Màu badge dạng hex (#CD7F32, #FFD700...).
|
||||
/// </summary>
|
||||
public string? BadgeColor => _badgeColor;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether this level is active.
|
||||
/// VI: Level có đang active không.
|
||||
/// </summary>
|
||||
public bool IsActive => _isActive;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Benefits associated with this level.
|
||||
/// VI: Các benefits đi kèm với level này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<LevelBenefit> Benefits => _benefits.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected LevelDefinition()
|
||||
{
|
||||
_name = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create new level definition.
|
||||
/// VI: Tạo level definition mới.
|
||||
/// </summary>
|
||||
/// <param name="levelNumber">Level number (1, 2, 3...) / Số thứ tự level</param>
|
||||
/// <param name="name">Level name / Tên level</param>
|
||||
/// <param name="requiredExp">Required EXP / EXP yêu cầu</param>
|
||||
/// <param name="description">Description / Mô tả</param>
|
||||
/// <param name="iconUrl">Icon URL / URL icon</param>
|
||||
/// <param name="badgeColor">Badge color (hex) / Màu badge</param>
|
||||
public LevelDefinition(
|
||||
int levelNumber,
|
||||
string name,
|
||||
int requiredExp,
|
||||
string? description = null,
|
||||
string? iconUrl = null,
|
||||
string? badgeColor = null) : this()
|
||||
{
|
||||
if (levelNumber < 1)
|
||||
throw new ArgumentException("Level number must be positive", nameof(levelNumber));
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Level name cannot be empty", nameof(name));
|
||||
if (requiredExp < 0)
|
||||
throw new ArgumentException("Required EXP cannot be negative", nameof(requiredExp));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_levelNumber = levelNumber;
|
||||
_name = name;
|
||||
_requiredExp = requiredExp;
|
||||
_description = description;
|
||||
_iconUrl = iconUrl;
|
||||
_badgeColor = badgeColor;
|
||||
_isActive = true;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update level name.
|
||||
/// VI: Cập nhật tên level.
|
||||
/// </summary>
|
||||
public void UpdateName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Level name cannot be empty", nameof(name));
|
||||
|
||||
_name = name;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update required EXP threshold.
|
||||
/// VI: Cập nhật ngưỡng EXP yêu cầu.
|
||||
/// </summary>
|
||||
public void UpdateRequiredExp(int requiredExp)
|
||||
{
|
||||
if (requiredExp < 0)
|
||||
throw new ArgumentException("Required EXP cannot be negative", nameof(requiredExp));
|
||||
|
||||
_requiredExp = requiredExp;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update level description.
|
||||
/// VI: Cập nhật mô tả level.
|
||||
/// </summary>
|
||||
public void UpdateDescription(string? description)
|
||||
{
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update icon URL.
|
||||
/// VI: Cập nhật URL icon.
|
||||
/// </summary>
|
||||
public void UpdateIconUrl(string? iconUrl)
|
||||
{
|
||||
_iconUrl = iconUrl;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update badge color.
|
||||
/// VI: Cập nhật màu badge.
|
||||
/// </summary>
|
||||
public void UpdateBadgeColor(string? badgeColor)
|
||||
{
|
||||
_badgeColor = badgeColor;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a benefit to this level.
|
||||
/// VI: Thêm một benefit cho level này.
|
||||
/// </summary>
|
||||
public void AddBenefit(LevelBenefit benefit)
|
||||
{
|
||||
if (benefit == null)
|
||||
throw new ArgumentNullException(nameof(benefit));
|
||||
|
||||
_benefits.Add(benefit);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a benefit from this level.
|
||||
/// VI: Xóa một benefit khỏi level này.
|
||||
/// </summary>
|
||||
public void RemoveBenefit(LevelBenefit benefit)
|
||||
{
|
||||
_benefits.Remove(benefit);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate this level.
|
||||
/// VI: Kích hoạt level này.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
_isActive = true;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate this level (soft delete).
|
||||
/// VI: Vô hiệu hóa level này (xóa mềm).
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.Events;
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
@@ -9,9 +11,9 @@ namespace MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This entity stores membership-specific data. Profile information (avatar, phone, address, DOB)
|
||||
/// is managed by IAM Service's UserProfile. This service only handles membership level, gender, and preferences.
|
||||
/// is managed by IAM Service's UserProfile. This service handles level, experience, and preferences.
|
||||
/// VI: Entity này lưu trữ dữ liệu membership. Thông tin profile (avatar, phone, address, DOB)
|
||||
/// được quản lý bởi UserProfile của IAM Service. Service này chỉ xử lý membership level, gender, và preferences.
|
||||
/// được quản lý bởi UserProfile của IAM Service. Service này xử lý level, experience, và preferences.
|
||||
/// </remarks>
|
||||
public class Member : Entity, IAggregateRoot
|
||||
{
|
||||
@@ -19,8 +21,9 @@ public class Member : Entity, IAggregateRoot
|
||||
// VI: Fields private để đóng gói
|
||||
private string _countryCode;
|
||||
private string? _gender;
|
||||
private int _membershipLevelId;
|
||||
private MembershipLevel _membershipLevel = null!;
|
||||
private int _currentLevel;
|
||||
private int _currentExp;
|
||||
private int _totalExpEarned;
|
||||
private string? _preferences; // JSON string
|
||||
private DateTime _createdAt;
|
||||
private DateTime _updatedAt;
|
||||
@@ -45,16 +48,22 @@ public class Member : Entity, IAggregateRoot
|
||||
public string? Gender => _gender;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Membership level ID for EF Core mapping.
|
||||
/// VI: Membership level ID cho EF Core mapping.
|
||||
/// EN: Current member level.
|
||||
/// VI: Level hiện tại của member.
|
||||
/// </summary>
|
||||
public int MembershipLevelId => _membershipLevelId;
|
||||
public int CurrentLevel => _currentLevel;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current membership level.
|
||||
/// VI: Cấp thành viên hiện tại.
|
||||
/// EN: Current experience points.
|
||||
/// VI: Điểm kinh nghiệm hiện tại.
|
||||
/// </summary>
|
||||
public MembershipLevel MembershipLevel => _membershipLevel;
|
||||
public int CurrentExp => _currentExp;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total experience points ever earned.
|
||||
/// VI: Tổng điểm kinh nghiệm đã kiếm được.
|
||||
/// </summary>
|
||||
public int TotalExpEarned => _totalExpEarned;
|
||||
|
||||
/// <summary>
|
||||
/// EN: User preferences as JSON string.
|
||||
@@ -104,8 +113,9 @@ public class Member : Entity, IAggregateRoot
|
||||
Id = userId;
|
||||
_countryCode = countryCode;
|
||||
_gender = gender;
|
||||
_membershipLevelId = MembershipLevel.Free.Id;
|
||||
_membershipLevel = MembershipLevel.Free;
|
||||
_currentLevel = 1; // Start at level 1
|
||||
_currentExp = 0;
|
||||
_totalExpEarned = 0;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -114,6 +124,81 @@ public class Member : Entity, IAggregateRoot
|
||||
AddDomainEvent(new MemberCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add experience points and automatically level up if thresholds are met.
|
||||
/// VI: Thêm điểm kinh nghiệm và tự động lên level nếu đạt ngưỡng.
|
||||
/// </summary>
|
||||
/// <param name="points">Amount of EXP to add / Số EXP cộng thêm</param>
|
||||
/// <param name="source">Source of experience / Nguồn EXP</param>
|
||||
/// <param name="levelRules">Active level definitions / Các level definitions đang active</param>
|
||||
/// <param name="referenceId">Reference ID (optional) / ID tham chiếu (tùy chọn)</param>
|
||||
/// <param name="metadata">Metadata JSON (optional) / Metadata JSON (tùy chọn)</param>
|
||||
/// <returns>ExperienceTransaction for tracking / ExperienceTransaction để tracking</returns>
|
||||
public ExperienceTransaction AddExperience(
|
||||
int points,
|
||||
ExperienceSource source,
|
||||
IReadOnlyList<LevelDefinition> levelRules,
|
||||
string? referenceId = null,
|
||||
string? metadata = null)
|
||||
{
|
||||
if (points <= 0)
|
||||
throw new ArgumentException("EXP points must be positive", nameof(points));
|
||||
if (source == null)
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
if (levelRules == null || !levelRules.Any())
|
||||
throw new ArgumentException("Level rules cannot be empty", nameof(levelRules));
|
||||
|
||||
int oldLevel = _currentLevel;
|
||||
|
||||
// EN: Create transaction for tracking
|
||||
// VI: Tạo transaction để tracking
|
||||
var transaction = new ExperienceTransaction(
|
||||
memberId: Id,
|
||||
points: points,
|
||||
source: source,
|
||||
levelAtTime: oldLevel,
|
||||
referenceId: referenceId,
|
||||
metadata: metadata);
|
||||
|
||||
// EN: Update experience points
|
||||
// VI: Cập nhật điểm kinh nghiệm
|
||||
_currentExp += points;
|
||||
_totalExpEarned += points;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
// EN: Calculate new level based on rules
|
||||
// VI: Tính level mới dựa trên rules
|
||||
var newLevel = CalculateLevel(_currentExp, levelRules);
|
||||
|
||||
// EN: Check if leveled up
|
||||
// VI: Kiểm tra có lên level không
|
||||
if (newLevel > oldLevel)
|
||||
{
|
||||
_currentLevel = newLevel;
|
||||
// EN: Raise level up event for side effects (rewards, notifications, etc.)
|
||||
// VI: Raise level up event cho side effects (rewards, notifications, etc.)
|
||||
AddDomainEvent(new MemberLevelUpDomainEvent(this, oldLevel, newLevel));
|
||||
}
|
||||
|
||||
// EN: Raise experience added event
|
||||
// VI: Raise event khi thêm experience
|
||||
AddDomainEvent(new MemberExperienceAddedDomainEvent(this, points, source));
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Calculate level based on current EXP and level rules.
|
||||
/// VI: Tính level dựa trên EXP hiện tại và level rules.
|
||||
/// </summary>
|
||||
private static int CalculateLevel(int exp, IReadOnlyList<LevelDefinition> rules)
|
||||
{
|
||||
return rules
|
||||
.Where(r => r.IsActive && r.RequiredExp <= exp)
|
||||
.OrderByDescending(r => r.LevelNumber)
|
||||
.FirstOrDefault()?.LevelNumber ?? 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update gender.
|
||||
/// VI: Cập nhật giới tính.
|
||||
@@ -153,28 +238,6 @@ public class Member : Entity, IAggregateRoot
|
||||
AddDomainEvent(new MemberUpdatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change membership level.
|
||||
/// VI: Thay đổi cấp thành viên.
|
||||
/// </summary>
|
||||
public void ChangeMembershipLevel(MembershipLevel newLevel)
|
||||
{
|
||||
if (newLevel == null)
|
||||
throw new ArgumentNullException(nameof(newLevel));
|
||||
|
||||
// EN: Skip if level is the same
|
||||
// VI: Bỏ qua nếu level giống nhau
|
||||
if (_membershipLevelId == newLevel.Id)
|
||||
return;
|
||||
|
||||
var oldLevel = _membershipLevel;
|
||||
_membershipLevelId = newLevel.Id;
|
||||
_membershipLevel = newLevel;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new MembershipLevelChangedDomainEvent(this, oldLevel, newLevel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark member as deleted (soft delete).
|
||||
/// VI: Đánh dấu member đã xóa (xóa mềm).
|
||||
@@ -194,13 +257,4 @@ public class Member : Entity, IAggregateRoot
|
||||
_isDeleted = false;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set membership level (for EF Core loading).
|
||||
/// VI: Set membership level (cho EF Core loading).
|
||||
/// </summary>
|
||||
internal void SetMembershipLevel(MembershipLevel level)
|
||||
{
|
||||
_membershipLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
namespace MembershipService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a member receives experience points.
|
||||
/// VI: Domain event được phát ra khi member nhận điểm kinh nghiệm.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This event is used for logging, analytics, and potential side effects.
|
||||
/// VI: Event này được dùng cho logging, analytics, và các side effects tiềm năng.
|
||||
/// </remarks>
|
||||
public class MemberExperienceAddedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Event ID.
|
||||
/// VI: ID của event.
|
||||
/// </summary>
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// EN: When the event occurred.
|
||||
/// VI: Thời điểm event xảy ra.
|
||||
/// </summary>
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// EN: The member who received EXP.
|
||||
/// VI: Member đã nhận EXP.
|
||||
/// </summary>
|
||||
public Member Member { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Amount of EXP points received.
|
||||
/// VI: Số điểm EXP nhận được.
|
||||
/// </summary>
|
||||
public int Points { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Source of the experience points.
|
||||
/// VI: Nguồn gốc của điểm kinh nghiệm.
|
||||
/// </summary>
|
||||
public ExperienceSource Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Member ID.
|
||||
/// VI: ID của member.
|
||||
/// </summary>
|
||||
public Guid MemberId => Member.Id;
|
||||
|
||||
public MemberExperienceAddedDomainEvent(
|
||||
Member member,
|
||||
int points,
|
||||
ExperienceSource source)
|
||||
{
|
||||
Member = member ?? throw new ArgumentNullException(nameof(member));
|
||||
Points = points;
|
||||
Source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
namespace MembershipService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a member levels up.
|
||||
/// VI: Domain event được phát ra khi member lên level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This event is used to trigger side effects like rewards, notifications, etc.
|
||||
/// VI: Event này được dùng để trigger các side effects như rewards, notifications, etc.
|
||||
/// </remarks>
|
||||
public class MemberLevelUpDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Event ID.
|
||||
/// VI: ID của event.
|
||||
/// </summary>
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// EN: When the event occurred.
|
||||
/// VI: Thời điểm event xảy ra.
|
||||
/// </summary>
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// EN: The member who leveled up.
|
||||
/// VI: Member đã lên level.
|
||||
/// </summary>
|
||||
public Member Member { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous level before leveling up.
|
||||
/// VI: Level trước khi lên.
|
||||
/// </summary>
|
||||
public int OldLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New level after leveling up.
|
||||
/// VI: Level mới sau khi lên.
|
||||
/// </summary>
|
||||
public int NewLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Member ID.
|
||||
/// VI: ID của member.
|
||||
/// </summary>
|
||||
public Guid MemberId => Member.Id;
|
||||
|
||||
public MemberLevelUpDomainEvent(Member member, int oldLevel, int newLevel)
|
||||
{
|
||||
Member = member ?? throw new ArgumentNullException(nameof(member));
|
||||
OldLevel = oldLevel;
|
||||
NewLevel = newLevel;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
using MembershipService.Infrastructure.ExternalServices;
|
||||
using MembershipService.Infrastructure.Idempotency;
|
||||
@@ -49,6 +51,8 @@ public static class DependencyInjection
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<IMemberRepository, MemberRepository>();
|
||||
services.AddScoped<ILevelDefinitionRepository, LevelDefinitionRepository>();
|
||||
services.AddScoped<IExperienceTransactionRepository, ExperienceTransactionRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
|
||||
namespace MembershipService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for ExperienceTransaction entity.
|
||||
/// VI: Cấu hình entity cho ExperienceTransaction entity.
|
||||
/// </summary>
|
||||
public class ExperienceTransactionEntityTypeConfiguration : IEntityTypeConfiguration<ExperienceTransaction>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ExperienceTransaction> builder)
|
||||
{
|
||||
builder.ToTable("experience_transactions");
|
||||
|
||||
// EN: Primary key
|
||||
// VI: Primary key
|
||||
builder.HasKey(t => t.Id);
|
||||
|
||||
builder.Property(t => t.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// EN: Member ID (foreign key)
|
||||
// VI: ID member (foreign key)
|
||||
builder.Property("_memberId")
|
||||
.HasColumnName("member_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex("_memberId")
|
||||
.HasDatabaseName("ix_experience_transactions_member_id");
|
||||
|
||||
// EN: Points
|
||||
// VI: Điểm
|
||||
builder.Property("_points")
|
||||
.HasColumnName("points")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Source ID
|
||||
// VI: ID nguồn
|
||||
builder.Property("_sourceId")
|
||||
.HasColumnName("source_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex("_sourceId")
|
||||
.HasDatabaseName("ix_experience_transactions_source");
|
||||
|
||||
// EN: Reference ID (Order ID, etc.)
|
||||
// VI: ID tham chiếu (Order ID, etc.)
|
||||
builder.Property("_referenceId")
|
||||
.HasColumnName("reference_id")
|
||||
.HasMaxLength(100);
|
||||
|
||||
// EN: Metadata (JSON)
|
||||
// VI: Metadata (JSON)
|
||||
builder.Property("_metadata")
|
||||
.HasColumnName("metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
// EN: Level at time of transaction
|
||||
// VI: Level tại thời điểm giao dịch
|
||||
builder.Property("_levelAtTime")
|
||||
.HasColumnName("level_at_time")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Created timestamp
|
||||
// VI: Thời gian tạo
|
||||
builder.Property("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex("_createdAt")
|
||||
.HasDatabaseName("ix_experience_transactions_created_at");
|
||||
|
||||
// EN: Ignore Source navigation property (it's an Enumeration)
|
||||
// VI: Bỏ qua navigation property Source (nó là Enumeration)
|
||||
builder.Ignore(t => t.Source);
|
||||
|
||||
// EN: Ignore domain events
|
||||
// VI: Bỏ qua domain events
|
||||
builder.Ignore(t => t.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
namespace MembershipService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for LevelBenefit entity.
|
||||
/// VI: Cấu hình entity cho LevelBenefit entity.
|
||||
/// </summary>
|
||||
public class LevelBenefitEntityTypeConfiguration : IEntityTypeConfiguration<LevelBenefit>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LevelBenefit> builder)
|
||||
{
|
||||
builder.ToTable("level_benefits");
|
||||
|
||||
// EN: Primary key
|
||||
// VI: Primary key
|
||||
builder.HasKey(b => b.Id);
|
||||
|
||||
builder.Property(b => b.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// EN: Foreign key to LevelDefinition
|
||||
// VI: Foreign key đến LevelDefinition
|
||||
builder.Property("_levelDefinitionId")
|
||||
.HasColumnName("level_definition_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex("_levelDefinitionId")
|
||||
.HasDatabaseName("ix_level_benefits_level_definition_id");
|
||||
|
||||
// EN: Benefit type
|
||||
// VI: Loại benefit
|
||||
builder.Property("_benefitType")
|
||||
.HasColumnName("benefit_type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Benefit value (JSON)
|
||||
// VI: Giá trị benefit (JSON)
|
||||
builder.Property("_benefitValue")
|
||||
.HasColumnName("benefit_value")
|
||||
.HasColumnType("jsonb")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Description
|
||||
// VI: Mô tả
|
||||
builder.Property("_description")
|
||||
.HasColumnName("description");
|
||||
|
||||
// EN: Active status
|
||||
// VI: Trạng thái active
|
||||
builder.Property("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(true);
|
||||
|
||||
// EN: Timestamps
|
||||
// VI: Timestamps
|
||||
builder.Property("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Ignore domain events
|
||||
// VI: Bỏ qua domain events
|
||||
builder.Ignore(b => b.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
|
||||
namespace MembershipService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for LevelDefinition aggregate.
|
||||
/// VI: Cấu hình entity cho LevelDefinition aggregate.
|
||||
/// </summary>
|
||||
public class LevelDefinitionEntityTypeConfiguration : IEntityTypeConfiguration<LevelDefinition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LevelDefinition> builder)
|
||||
{
|
||||
builder.ToTable("level_definitions");
|
||||
|
||||
// EN: Primary key
|
||||
// VI: Primary key
|
||||
builder.HasKey(l => l.Id);
|
||||
|
||||
builder.Property(l => l.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// EN: Level number (unique)
|
||||
// VI: Số thứ tự level (unique)
|
||||
builder.Property("_levelNumber")
|
||||
.HasColumnName("level_number")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex("_levelNumber")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_level_definitions_level_number");
|
||||
|
||||
// EN: Level name
|
||||
// VI: Tên level
|
||||
builder.Property("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Required EXP
|
||||
// VI: EXP yêu cầu
|
||||
builder.Property("_requiredExp")
|
||||
.HasColumnName("required_exp")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0);
|
||||
|
||||
// EN: Description
|
||||
// VI: Mô tả
|
||||
builder.Property("_description")
|
||||
.HasColumnName("description");
|
||||
|
||||
// EN: Icon URL
|
||||
// VI: URL icon
|
||||
builder.Property("_iconUrl")
|
||||
.HasColumnName("icon_url")
|
||||
.HasMaxLength(500);
|
||||
|
||||
// EN: Badge color
|
||||
// VI: Màu badge
|
||||
builder.Property("_badgeColor")
|
||||
.HasColumnName("badge_color")
|
||||
.HasMaxLength(20);
|
||||
|
||||
// EN: Active status
|
||||
// VI: Trạng thái active
|
||||
builder.Property("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.HasIndex("_isActive")
|
||||
.HasDatabaseName("ix_level_definitions_is_active");
|
||||
|
||||
// EN: Timestamps
|
||||
// VI: Timestamps
|
||||
builder.Property("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property("_updatedAt")
|
||||
.HasColumnName("updated_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Relationship with benefits
|
||||
// VI: Quan hệ với benefits
|
||||
builder.HasMany(l => l.Benefits)
|
||||
.WithOne()
|
||||
.HasForeignKey("_levelDefinitionId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: Ignore domain events
|
||||
// VI: Bỏ qua domain events
|
||||
builder.Ignore(l => l.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -35,16 +35,26 @@ public class MemberEntityTypeConfiguration : IEntityTypeConfiguration<Member>
|
||||
.HasColumnName("gender")
|
||||
.HasMaxLength(10);
|
||||
|
||||
// EN: Membership level
|
||||
// VI: Cấp thành viên
|
||||
builder.Property("_membershipLevelId")
|
||||
.HasColumnName("membership_level_id")
|
||||
.IsRequired();
|
||||
// EN: Current level
|
||||
// VI: Level hiện tại
|
||||
builder.Property("_currentLevel")
|
||||
.HasColumnName("current_level")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(1);
|
||||
|
||||
builder.HasOne(m => m.MembershipLevel)
|
||||
.WithMany()
|
||||
.HasForeignKey("_membershipLevelId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
// EN: Current experience points
|
||||
// VI: Điểm kinh nghiệm hiện tại
|
||||
builder.Property("_currentExp")
|
||||
.HasColumnName("current_exp")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0);
|
||||
|
||||
// EN: Total experience points ever earned
|
||||
// VI: Tổng điểm kinh nghiệm đã kiếm được
|
||||
builder.Property("_totalExpEarned")
|
||||
.HasColumnName("total_exp_earned")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0);
|
||||
|
||||
// EN: Preferences (JSON)
|
||||
// VI: Preferences (JSON)
|
||||
@@ -77,8 +87,11 @@ public class MemberEntityTypeConfiguration : IEntityTypeConfiguration<Member>
|
||||
builder.HasIndex("_createdAt")
|
||||
.HasDatabaseName("ix_members_created_at");
|
||||
|
||||
builder.HasIndex("_membershipLevelId")
|
||||
.HasDatabaseName("ix_members_membership_level");
|
||||
builder.HasIndex("_currentLevel")
|
||||
.HasDatabaseName("ix_members_current_level");
|
||||
|
||||
builder.HasIndex("_currentExp")
|
||||
.HasDatabaseName("ix_members_current_exp");
|
||||
|
||||
builder.HasIndex("_isDeleted")
|
||||
.HasDatabaseName("ix_members_is_deleted");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
@@ -34,10 +36,22 @@ public class MembershipServiceContext : DbContext, IUnitOfWork
|
||||
public DbSet<Member> Members { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Membership levels table.
|
||||
/// VI: Bảng cấp thành viên.
|
||||
/// EN: Level definitions table.
|
||||
/// VI: Bảng level definitions.
|
||||
/// </summary>
|
||||
public DbSet<MembershipLevel> MembershipLevels { get; set; } = null!;
|
||||
public DbSet<LevelDefinition> LevelDefinitions { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Level benefits table.
|
||||
/// VI: Bảng level benefits.
|
||||
/// </summary>
|
||||
public DbSet<LevelBenefit> LevelBenefits { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Experience transactions table.
|
||||
/// VI: Bảng experience transactions.
|
||||
/// </summary>
|
||||
public DbSet<ExperienceTransaction> ExperienceTransactions { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if there's an active transaction.
|
||||
@@ -52,15 +66,6 @@ public class MembershipServiceContext : DbContext, IUnitOfWork
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng entity configurations
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MembershipServiceContext).Assembly);
|
||||
|
||||
// EN: Seed MembershipLevel enumeration
|
||||
// VI: Seed MembershipLevel enumeration
|
||||
modelBuilder.Entity<MembershipLevel>().ToTable("membership_levels");
|
||||
modelBuilder.Entity<MembershipLevel>().HasData(
|
||||
MembershipLevel.Free,
|
||||
MembershipLevel.Basic,
|
||||
MembershipLevel.Premium
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for ExperienceTransaction.
|
||||
/// VI: Repository implementation cho ExperienceTransaction.
|
||||
/// </summary>
|
||||
public class ExperienceTransactionRepository : IExperienceTransactionRepository
|
||||
{
|
||||
private readonly MembershipServiceContext _context;
|
||||
|
||||
public ExperienceTransactionRepository(MembershipServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get experience transaction by ID.
|
||||
/// VI: Lấy experience transaction theo ID.
|
||||
/// </summary>
|
||||
public async Task<ExperienceTransaction?> GetAsync(Guid id)
|
||||
{
|
||||
var transaction = await _context.ExperienceTransactions
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (transaction != null)
|
||||
{
|
||||
// EN: Set the Source enumeration based on SourceId
|
||||
// VI: Set Source enumeration dựa trên SourceId
|
||||
var source = ExperienceSource.FromValue<ExperienceSource>(transaction.SourceId);
|
||||
transaction.SetSource(source);
|
||||
}
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get paginated experience transactions for a member.
|
||||
/// VI: Lấy experience transactions phân trang cho một member.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ExperienceTransaction>> GetByMemberIdAsync(
|
||||
Guid memberId,
|
||||
int skip = 0,
|
||||
int take = 20)
|
||||
{
|
||||
var transactions = await _context.ExperienceTransactions
|
||||
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId)
|
||||
.OrderByDescending(t => EF.Property<DateTime>(t, "_createdAt"))
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
// EN: Set Source for each transaction
|
||||
// VI: Set Source cho mỗi transaction
|
||||
foreach (var transaction in transactions)
|
||||
{
|
||||
var source = ExperienceSource.FromValue<ExperienceSource>(transaction.SourceId);
|
||||
transaction.SetSource(source);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get experience transactions by source for a member.
|
||||
/// VI: Lấy experience transactions theo nguồn cho một member.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ExperienceTransaction>> GetBySourceAsync(
|
||||
Guid memberId,
|
||||
ExperienceSource source)
|
||||
{
|
||||
var transactions = await _context.ExperienceTransactions
|
||||
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId &&
|
||||
EF.Property<int>(t, "_sourceId") == source.Id)
|
||||
.OrderByDescending(t => EF.Property<DateTime>(t, "_createdAt"))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var transaction in transactions)
|
||||
{
|
||||
transaction.SetSource(source);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get total EXP points for a member.
|
||||
/// VI: Lấy tổng điểm EXP của một member.
|
||||
/// </summary>
|
||||
public async Task<int> GetTotalPointsByMemberIdAsync(Guid memberId)
|
||||
{
|
||||
return await _context.ExperienceTransactions
|
||||
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId)
|
||||
.SumAsync(t => EF.Property<int>(t, "_points"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get total EXP points by source for a member.
|
||||
/// VI: Lấy tổng điểm EXP theo nguồn cho một member.
|
||||
/// </summary>
|
||||
public async Task<int> GetTotalPointsBySourceAsync(Guid memberId, ExperienceSource source)
|
||||
{
|
||||
return await _context.ExperienceTransactions
|
||||
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId &&
|
||||
EF.Property<int>(t, "_sourceId") == source.Id)
|
||||
.SumAsync(t => EF.Property<int>(t, "_points"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get transaction count for a member.
|
||||
/// VI: Lấy số lượng transactions của một member.
|
||||
/// </summary>
|
||||
public async Task<int> GetCountByMemberIdAsync(Guid memberId)
|
||||
{
|
||||
return await _context.ExperienceTransactions
|
||||
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId)
|
||||
.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add new experience transaction.
|
||||
/// VI: Thêm experience transaction mới.
|
||||
/// </summary>
|
||||
public ExperienceTransaction Add(ExperienceTransaction transaction)
|
||||
{
|
||||
return _context.ExperienceTransactions.Add(transaction).Entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.SeedWork;
|
||||
|
||||
namespace MembershipService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for LevelDefinition aggregate.
|
||||
/// VI: Repository implementation cho LevelDefinition aggregate.
|
||||
/// </summary>
|
||||
public class LevelDefinitionRepository : ILevelDefinitionRepository
|
||||
{
|
||||
private readonly MembershipServiceContext _context;
|
||||
|
||||
public LevelDefinitionRepository(MembershipServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get level definition by ID.
|
||||
/// VI: Lấy level definition theo ID.
|
||||
/// </summary>
|
||||
public async Task<LevelDefinition?> GetAsync(Guid id)
|
||||
{
|
||||
return await _context.LevelDefinitions
|
||||
.FirstOrDefaultAsync(l => l.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get level definition by level number.
|
||||
/// VI: Lấy level definition theo số thứ tự level.
|
||||
/// </summary>
|
||||
public async Task<LevelDefinition?> GetByLevelNumberAsync(int levelNumber)
|
||||
{
|
||||
return await _context.LevelDefinitions
|
||||
.FirstOrDefaultAsync(l => EF.Property<int>(l, "_levelNumber") == levelNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get level definition with benefits.
|
||||
/// VI: Lấy level definition kèm benefits.
|
||||
/// </summary>
|
||||
public async Task<LevelDefinition?> GetWithBenefitsAsync(Guid id)
|
||||
{
|
||||
return await _context.LevelDefinitions
|
||||
.Include(l => l.Benefits)
|
||||
.FirstOrDefaultAsync(l => l.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all active level definitions ordered by level number.
|
||||
/// VI: Lấy tất cả level definitions đang active, sắp xếp theo số thứ tự.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LevelDefinition>> GetAllActiveAsync()
|
||||
{
|
||||
return await _context.LevelDefinitions
|
||||
.Where(l => EF.Property<bool>(l, "_isActive"))
|
||||
.OrderBy(l => EF.Property<int>(l, "_levelNumber"))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all level definitions (including inactive).
|
||||
/// VI: Lấy tất cả level definitions (bao gồm cả inactive).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LevelDefinition>> GetAllAsync()
|
||||
{
|
||||
return await _context.LevelDefinitions
|
||||
.OrderBy(l => EF.Property<int>(l, "_levelNumber"))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if level number already exists.
|
||||
/// VI: Kiểm tra xem số thứ tự level đã tồn tại chưa.
|
||||
/// </summary>
|
||||
public async Task<bool> ExistsByLevelNumberAsync(int levelNumber)
|
||||
{
|
||||
return await _context.LevelDefinitions
|
||||
.AnyAsync(l => EF.Property<int>(l, "_levelNumber") == levelNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add new level definition.
|
||||
/// VI: Thêm level definition mới.
|
||||
/// </summary>
|
||||
public LevelDefinition Add(LevelDefinition levelDefinition)
|
||||
{
|
||||
return _context.LevelDefinitions.Add(levelDefinition).Entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update level definition.
|
||||
/// VI: Cập nhật level definition.
|
||||
/// </summary>
|
||||
public void Update(LevelDefinition levelDefinition)
|
||||
{
|
||||
_context.Entry(levelDefinition).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ public class MemberRepository : IMemberRepository
|
||||
public async Task<Member?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Members
|
||||
.Include(m => m.MembershipLevel)
|
||||
.FirstOrDefaultAsync(m => m.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -51,17 +50,15 @@ public class MemberRepository : IMemberRepository
|
||||
string? searchTerm = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.Members
|
||||
.Include(m => m.MembershipLevel)
|
||||
.AsQueryable();
|
||||
var query = _context.Members.AsQueryable();
|
||||
|
||||
// EN: Apply search filter if provided
|
||||
// VI: Áp dụng filter tìm kiếm nếu có
|
||||
// EN: Apply search filter if provided (search by country code or gender)
|
||||
// VI: Áp dụng filter tìm kiếm nếu có (tìm theo country code hoặc gender)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
query = query.Where(m =>
|
||||
EF.Property<string?>(m, "_phoneNumber") != null &&
|
||||
EF.Property<string>(m, "_phoneNumber").Contains(searchTerm));
|
||||
m.CountryCode.Contains(searchTerm) ||
|
||||
(m.Gender != null && m.Gender.Contains(searchTerm)));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using FluentAssertions;
|
||||
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.LevelAggregate;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
using MembershipService.Domain.Events;
|
||||
using Xunit;
|
||||
|
||||
namespace MembershipService.UnitTests.Domain;
|
||||
@@ -11,7 +14,7 @@ namespace MembershipService.UnitTests.Domain;
|
||||
public class MemberAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateMember_WithValidUserId_ShouldCreateMemberWithFreeLevel()
|
||||
public void CreateMember_WithValidUserId_ShouldCreateMemberWithLevel1()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
@@ -22,7 +25,9 @@ public class MemberAggregateTests
|
||||
// Assert
|
||||
member.Id.Should().Be(userId);
|
||||
member.UserId.Should().Be(userId);
|
||||
member.MembershipLevel.Should().Be(MembershipLevel.Free);
|
||||
member.CurrentLevel.Should().Be(1);
|
||||
member.CurrentExp.Should().Be(0);
|
||||
member.TotalExpEarned.Should().Be(0);
|
||||
member.CountryCode.Should().Be("VN");
|
||||
member.IsDeleted.Should().BeFalse();
|
||||
member.DomainEvents.Should().ContainSingle();
|
||||
@@ -102,33 +107,70 @@ public class MemberAggregateTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeMembershipLevel_ShouldChangeLevel()
|
||||
public void AddExperience_ShouldAddExpAndRaiseEvent()
|
||||
{
|
||||
// Arrange
|
||||
var member = new Member(Guid.NewGuid());
|
||||
member.ClearDomainEvents();
|
||||
var levelRules = CreateDefaultLevelRules();
|
||||
|
||||
// Act
|
||||
member.ChangeMembershipLevel(MembershipLevel.Premium);
|
||||
var transaction = member.AddExperience(50, ExperienceSource.Purchase, levelRules, "ORDER-123");
|
||||
|
||||
// Assert
|
||||
member.MembershipLevel.Should().Be(MembershipLevel.Premium);
|
||||
member.DomainEvents.Should().ContainSingle();
|
||||
member.CurrentExp.Should().Be(50);
|
||||
member.TotalExpEarned.Should().Be(50);
|
||||
member.CurrentLevel.Should().Be(1); // Not enough for level 2
|
||||
transaction.Points.Should().Be(50);
|
||||
transaction.Source.Should().Be(ExperienceSource.Purchase);
|
||||
transaction.ReferenceId.Should().Be("ORDER-123");
|
||||
member.DomainEvents.Should().ContainSingle(e => e is MemberExperienceAddedDomainEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeMembershipLevel_ToSameLevel_ShouldNotRaiseEvent()
|
||||
public void AddExperience_ShouldLevelUp_WhenThresholdReached()
|
||||
{
|
||||
// Arrange
|
||||
var member = new Member(Guid.NewGuid());
|
||||
member.ClearDomainEvents();
|
||||
var levelRules = CreateDefaultLevelRules();
|
||||
|
||||
// Act
|
||||
member.ChangeMembershipLevel(MembershipLevel.Free);
|
||||
var transaction = member.AddExperience(150, ExperienceSource.Purchase, levelRules);
|
||||
|
||||
// Assert
|
||||
member.MembershipLevel.Should().Be(MembershipLevel.Free);
|
||||
member.DomainEvents.Should().BeEmpty();
|
||||
member.CurrentExp.Should().Be(150);
|
||||
member.CurrentLevel.Should().Be(2); // Silver at 100 EXP
|
||||
member.DomainEvents.Should().Contain(e => e is MemberLevelUpDomainEvent);
|
||||
member.DomainEvents.Should().Contain(e => e is MemberExperienceAddedDomainEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExperience_ShouldLevelUpMultipleLevels_WhenBigExpGain()
|
||||
{
|
||||
// Arrange
|
||||
var member = new Member(Guid.NewGuid());
|
||||
member.ClearDomainEvents();
|
||||
var levelRules = CreateDefaultLevelRules();
|
||||
|
||||
// Act
|
||||
member.AddExperience(500, ExperienceSource.Admin, levelRules);
|
||||
|
||||
// Assert
|
||||
member.CurrentExp.Should().Be(500);
|
||||
member.CurrentLevel.Should().Be(3); // Gold at 300 EXP
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExperience_WithNegativePoints_ShouldThrow()
|
||||
{
|
||||
// Arrange
|
||||
var member = new Member(Guid.NewGuid());
|
||||
var levelRules = CreateDefaultLevelRules();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => member.AddExperience(-10, ExperienceSource.Purchase, levelRules);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*positive*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -143,4 +185,16 @@ public class MemberAggregateTests
|
||||
// Assert
|
||||
member.IsDeleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LevelDefinition> CreateDefaultLevelRules()
|
||||
{
|
||||
return new List<LevelDefinition>
|
||||
{
|
||||
new LevelDefinition(1, "Bronze", 0, "Starting level"),
|
||||
new LevelDefinition(2, "Silver", 100, "Reach 100 EXP"),
|
||||
new LevelDefinition(3, "Gold", 300, "Reach 300 EXP"),
|
||||
new LevelDefinition(4, "Platinum", 600, "Reach 600 EXP"),
|
||||
new LevelDefinition(5, "Diamond", 1000, "Reach 1000 EXP")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
40
services/merchant-service-net/.env.example
Normal file
40
services/merchant-service-net/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Environment / Môi Trường
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
|
||||
# Database / Cơ Sở Dữ Liệu
|
||||
# PostgreSQL connection string (Neon or local)
|
||||
DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Authentication / Xác Thực JWT
|
||||
JWT_SECRET=your-secret-key-min-32-characters-long-here
|
||||
JWT_ISSUER=goodgo-platform
|
||||
JWT_AUDIENCE=goodgo-services
|
||||
JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_EXPIRY_DAYS=7
|
||||
|
||||
# API Configuration / Cấu Hình API
|
||||
API_PORT=5000
|
||||
API_BASE_PATH=/api/v1/myservice
|
||||
|
||||
# Observability / Quan Sát
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=myservice
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=Information
|
||||
SEQ_URL=http://localhost:5341
|
||||
|
||||
# Feature Flags
|
||||
FEATURE_SWAGGER_ENABLED=true
|
||||
FEATURE_DETAILED_ERRORS=true
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PERMITS_PER_MINUTE=100
|
||||
RATE_LIMIT_QUEUE_LIMIT=10
|
||||
|
||||
# Health Checks
|
||||
HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
75
services/merchant-service-net/.gitignore
vendored
Normal file
75
services/merchant-service-net/.gitignore
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
# Build results
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.sln.docstates
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# Coverage
|
||||
TestResults/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# Secrets
|
||||
appsettings.*.json
|
||||
!appsettings.json
|
||||
!appsettings.Development.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# JetBrains
|
||||
*.resharper
|
||||
|
||||
# dotnet tools
|
||||
.config/dotnet-tools.json
|
||||
|
||||
# Migration scripts (only keep structure)
|
||||
Migrations/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
22
services/merchant-service-net/Directory.Build.props
Normal file
22
services/merchant-service-net/Directory.Build.props
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Authors>GoodGo Team</Authors>
|
||||
<Company>GoodGo</Company>
|
||||
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
66
services/merchant-service-net/Dockerfile
Normal file
66
services/merchant-service-net/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Build stage / Giai đoạn build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy project files for layer caching
|
||||
# VI: Sao chép các file project để tận dụng layer caching
|
||||
COPY ["src/MerchantService.API/MerchantService.API.csproj", "src/MerchantService.API/"]
|
||||
COPY ["src/MerchantService.Domain/MerchantService.Domain.csproj", "src/MerchantService.Domain/"]
|
||||
COPY ["src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj", "src/MerchantService.Infrastructure/"]
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
|
||||
# EN: Restore dependencies
|
||||
# VI: Khôi phục dependencies
|
||||
RUN dotnet restore "src/MerchantService.API/MerchantService.API.csproj"
|
||||
|
||||
# EN: Copy all source code
|
||||
# VI: Sao chép toàn bộ source code
|
||||
COPY src/ ./src/
|
||||
|
||||
# EN: Build the application
|
||||
# VI: Build ứng dụng
|
||||
WORKDIR "/src/src/MerchantService.API"
|
||||
RUN dotnet build "MerchantService.API.csproj" -c Release -o /app/build --no-restore
|
||||
|
||||
# Publish stage / Giai đoạn publish
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "MerchantService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||
|
||||
# Runtime stage / Giai đoạn runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# EN: Create non-root user for security
|
||||
# VI: Tạo user non-root cho bảo mật
|
||||
RUN groupadd -g 1001 dotnetuser && \
|
||||
useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser
|
||||
|
||||
# EN: Copy published application
|
||||
# VI: Sao chép ứng dụng đã publish
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# EN: Change ownership to non-root user
|
||||
# VI: Thay đổi quyền sở hữu sang user non-root
|
||||
RUN chown -R dotnetuser:dotnetuser /app
|
||||
|
||||
# EN: Switch to non-root user
|
||||
# VI: Chuyển sang user non-root
|
||||
USER dotnetuser
|
||||
|
||||
# EN: Expose port
|
||||
# VI: Mở cổng
|
||||
EXPOSE 8080
|
||||
|
||||
# EN: Set environment variables
|
||||
# VI: Thiết lập biến môi trường
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
# EN: Health check
|
||||
# VI: Kiểm tra health
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health/live || exit 1
|
||||
|
||||
# EN: Start the application
|
||||
# VI: Khởi động ứng dụng
|
||||
ENTRYPOINT ["dotnet", "MerchantService.API.dll"]
|
||||
11
services/merchant-service-net/MerchantService.slnx
Normal file
11
services/merchant-service-net/MerchantService.slnx
Normal file
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/MerchantService.API/MerchantService.API.csproj" />
|
||||
<Project Path="src/MerchantService.Domain/MerchantService.Domain.csproj" />
|
||||
<Project Path="src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/MerchantService.FunctionalTests/MerchantService.FunctionalTests.csproj" />
|
||||
<Project Path="tests/MerchantService.UnitTests/MerchantService.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
72
services/merchant-service-net/docker-compose.yml
Normal file
72
services/merchant-service-net/docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
version: '3.8'
|
||||
|
||||
# EN: Docker Compose for local development
|
||||
# VI: Docker Compose cho phát triển local
|
||||
|
||||
services:
|
||||
myservice-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: myservice-api
|
||||
ports:
|
||||
- "5000:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
- REDIS_URL=redis:6379
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- myservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: myservice-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: myservice_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- myservice-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: myservice-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- myservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
myservice-network:
|
||||
driver: bridge
|
||||
7
services/merchant-service-net/global.json
Normal file
7
services/merchant-service-net/global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.101",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
|
||||
namespace MerchantService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for logging request handling.
|
||||
/// VI: MediatR behavior để logging việc xử lý request.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Handling {RequestName} / Đang xử lý {RequestName}",
|
||||
requestName);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogError(ex,
|
||||
"Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MerchantService.Infrastructure;
|
||||
|
||||
namespace MerchantService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for handling database transactions.
|
||||
/// VI: MediatR behavior để xử lý database transactions.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly MerchantServiceContext _dbContext;
|
||||
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public TransactionBehavior(
|
||||
MerchantServiceContext dbContext,
|
||||
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
// EN: Skip transaction for queries (read operations)
|
||||
// VI: Bỏ qua transaction cho queries (các thao tác đọc)
|
||||
if (requestName.EndsWith("Query"))
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
// EN: Skip if already in a transaction
|
||||
// VI: Bỏ qua nếu đã trong transaction
|
||||
if (_dbContext.HasActiveTransaction)
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
var strategy = _dbContext.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _dbContext.BeginTransactionAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}",
|
||||
transaction?.TransactionId, requestName);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
if (transaction != null)
|
||||
{
|
||||
await _dbContext.CommitTransactionAsync(transaction);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}",
|
||||
transaction.TransactionId, requestName);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}",
|
||||
transaction?.TransactionId, requestName);
|
||||
|
||||
_dbContext.RollbackTransaction();
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace MerchantService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for FluentValidation integration.
|
||||
/// VI: MediatR behavior để tích hợp FluentValidation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class ValidatorBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
private readonly ILogger<ValidatorBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public ValidatorBehavior(
|
||||
IEnumerable<IValidator<TRequest>> validators,
|
||||
ILogger<ValidatorBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_validators = validators ?? throw new ArgumentNullException(nameof(validators));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
if (!_validators.Any())
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Validating {RequestName} / Đang validate {RequestName}",
|
||||
requestName);
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
|
||||
var validationResults = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||
|
||||
var failures = validationResults
|
||||
.SelectMany(r => r.Errors)
|
||||
.Where(f => f != null)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count != 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi",
|
||||
requestName, failures.Count);
|
||||
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MerchantService.API</AssemblyName>
|
||||
<RootNamespace>MerchantService.API</RootNamespace>
|
||||
<Description>Web API layer with CQRS pattern</Description>
|
||||
<UserSecretsId>myservice-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: MediatR for CQRS / VI: MediatR cho CQRS -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
<!-- EN: API Versioning / VI: API Versioning -->
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
|
||||
<!-- EN: Health checks / VI: Health checks -->
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||
|
||||
<!-- EN: Problem Details (RFC 7807) / VI: Problem Details (RFC 7807) -->
|
||||
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
|
||||
|
||||
<!-- EN: Serilog for structured logging / VI: Serilog cho structured logging -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MerchantService.Domain\MerchantService.Domain.csproj" />
|
||||
<ProjectReference Include="..\MerchantService.Infrastructure\MerchantService.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
144
services/merchant-service-net/src/MerchantService.API/Program.cs
Normal file
144
services/merchant-service-net/src/MerchantService.API/Program.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Asp.Versioning;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using MerchantService.API.Application.Behaviors;
|
||||
using MerchantService.Infrastructure;
|
||||
using Serilog;
|
||||
|
||||
// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting MerchantService API / Khởi động MerchantService API");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EN: Configure Serilog / VI: Cấu hình Serilog
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console());
|
||||
|
||||
// EN: Add Infrastructure services / VI: Thêm Infrastructure services
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
||||
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
|
||||
});
|
||||
|
||||
// EN: Add FluentValidation / VI: Thêm FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
// EN: Add API versioning / VI: Thêm API versioning
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.ReportApiVersions = true;
|
||||
options.ApiVersionReader = ApiVersionReader.Combine(
|
||||
new UrlSegmentApiVersionReader(),
|
||||
new HeaderApiVersionReader("X-Api-Version"));
|
||||
})
|
||||
.AddApiExplorer(options =>
|
||||
{
|
||||
options.GroupNameFormat = "'v'VVV";
|
||||
options.SubstituteApiVersionInUrl = true;
|
||||
});
|
||||
|
||||
// EN: Add controllers / VI: Thêm controllers
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware
|
||||
builder.Services.AddProblemDetails(options =>
|
||||
{
|
||||
options.IncludeExceptionDetails = (ctx, ex) =>
|
||||
builder.Environment.IsDevelopment();
|
||||
});
|
||||
|
||||
// EN: Add Swagger / VI: Thêm Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "MerchantService API",
|
||||
Version = "v1",
|
||||
Description = "MerchantService microservice API / API microservice MerchantService"
|
||||
});
|
||||
});
|
||||
|
||||
// EN: Add health checks / VI: Thêm health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(
|
||||
builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? "",
|
||||
name: "postgresql",
|
||||
tags: ["db", "postgresql"]);
|
||||
|
||||
// EN: Add CORS / VI: Thêm CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseProblemDetails();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "MerchantService API v1");
|
||||
c.RoutePrefix = "swagger";
|
||||
});
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseRouting();
|
||||
|
||||
// EN: Map health check endpoints / VI: Map health check endpoints
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHealthChecks("/health/live", new()
|
||||
{
|
||||
Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy
|
||||
});
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
// EN: Map controllers / VI: Map controllers
|
||||
app.MapControllers();
|
||||
|
||||
// EN: Run the application / VI: Chạy ứng dụng
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
// EN: Make Program class accessible for integration tests
|
||||
// VI: Làm cho class Program có thể truy cập cho integration tests
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||
"System": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "your-super-secret-key-min-32-characters",
|
||||
"Issuer": "goodgo-platform",
|
||||
"Audience": "goodgo-services",
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// EN: Bank account value object.
|
||||
// VI: Value object tài khoản ngân hàng.
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Value object representing a bank account for settlement.
|
||||
/// VI: Value object đại diện cho tài khoản ngân hàng để thanh toán.
|
||||
/// </summary>
|
||||
public record BankAccount
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Bank code (e.g., "VCB", "TCB").
|
||||
/// VI: Mã ngân hàng (ví dụ: "VCB", "TCB").
|
||||
/// </summary>
|
||||
public string BankCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Full bank name.
|
||||
/// VI: Tên đầy đủ ngân hàng.
|
||||
/// </summary>
|
||||
public string BankName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Account number.
|
||||
/// VI: Số tài khoản.
|
||||
/// </summary>
|
||||
public string AccountNumber { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Account holder name (must match business name).
|
||||
/// VI: Tên chủ tài khoản (phải trùng với tên doanh nghiệp).
|
||||
/// </summary>
|
||||
public string AccountHolderName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creates empty bank account.
|
||||
/// VI: Tạo tài khoản ngân hàng rỗng.
|
||||
/// </summary>
|
||||
public static BankAccount Empty => new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if bank account is valid for settlement.
|
||||
/// VI: Kiểm tra tài khoản ngân hàng hợp lệ để thanh toán.
|
||||
/// </summary>
|
||||
public bool IsValid =>
|
||||
!string.IsNullOrWhiteSpace(BankCode) &&
|
||||
!string.IsNullOrWhiteSpace(AccountNumber) &&
|
||||
!string.IsNullOrWhiteSpace(AccountHolderName);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// EN: Business information value object.
|
||||
// VI: Value object thông tin doanh nghiệp.
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Value object representing business information for verification.
|
||||
/// VI: Value object đại diện cho thông tin doanh nghiệp để xác minh.
|
||||
/// </summary>
|
||||
public record BusinessInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Tax identification number.
|
||||
/// VI: Mã số thuế.
|
||||
/// </summary>
|
||||
public string? TaxId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Business license number.
|
||||
/// VI: Số giấy phép kinh doanh.
|
||||
/// </summary>
|
||||
public string? BusinessLicenseNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Company registration number.
|
||||
/// VI: Số đăng ký công ty.
|
||||
/// </summary>
|
||||
public string? CompanyRegistrationNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Date when the business was established.
|
||||
/// VI: Ngày thành lập doanh nghiệp.
|
||||
/// </summary>
|
||||
public DateTime? EstablishedDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creates empty business info.
|
||||
/// VI: Tạo thông tin doanh nghiệp rỗng.
|
||||
/// </summary>
|
||||
public static BusinessInfo Empty => new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if business info is complete for verification.
|
||||
/// VI: Kiểm tra thông tin doanh nghiệp đã đủ để xác minh chưa.
|
||||
/// </summary>
|
||||
public bool IsCompleteForVerification =>
|
||||
!string.IsNullOrWhiteSpace(TaxId) &&
|
||||
!string.IsNullOrWhiteSpace(BusinessLicenseNumber);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// EN: Merchant repository interface.
|
||||
// VI: Interface repository cho Merchant.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Merchant aggregate.
|
||||
/// VI: Interface repository cho Merchant aggregate.
|
||||
/// </summary>
|
||||
public interface IMerchantRepository : IRepository<Merchant>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get merchant by ID.
|
||||
/// VI: Lấy merchant theo ID.
|
||||
/// </summary>
|
||||
Task<Merchant?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get merchant by user ID (IAM User).
|
||||
/// VI: Lấy merchant theo user ID (IAM User).
|
||||
/// </summary>
|
||||
Task<Merchant?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if user already has a merchant account.
|
||||
/// VI: Kiểm tra user đã có tài khoản merchant chưa.
|
||||
/// </summary>
|
||||
Task<bool> ExistsByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all merchants with pagination.
|
||||
/// VI: Lấy tất cả merchants với phân trang.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Merchant>> GetAllAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get merchants by status.
|
||||
/// VI: Lấy merchants theo trạng thái.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Merchant>> GetByStatusAsync(MerchantStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new merchant.
|
||||
/// VI: Thêm merchant mới.
|
||||
/// </summary>
|
||||
Merchant Add(Merchant merchant);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a merchant.
|
||||
/// VI: Cập nhật merchant.
|
||||
/// </summary>
|
||||
void Update(Merchant merchant);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// EN: Merchant aggregate root.
|
||||
// VI: Aggregate root Merchant.
|
||||
|
||||
using MerchantService.Domain.Events;
|
||||
using MerchantService.Domain.Exceptions;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant aggregate root - represents a shop owner who can own multiple shops.
|
||||
/// VI: Aggregate root Merchant - đại diện cho chủ shop có thể sở hữu nhiều shop.
|
||||
/// </summary>
|
||||
public class Merchant : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private Guid _userId;
|
||||
private string _businessName = null!;
|
||||
private MerchantType _type = null!;
|
||||
private MerchantStatus _status = null!;
|
||||
private VerificationStatus _verificationStatus = null!;
|
||||
private BusinessInfo _businessInfo = null!;
|
||||
private SettlementConfig _settlementConfig = null!;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
private bool _isDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference to IAM User (Owner).
|
||||
/// VI: Tham chiếu đến IAM User (Chủ sở hữu).
|
||||
/// </summary>
|
||||
public Guid UserId => _userId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Business/Company name.
|
||||
/// VI: Tên doanh nghiệp/công ty.
|
||||
/// </summary>
|
||||
public string BusinessName => _businessName;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant type (Individual or Company).
|
||||
/// VI: Loại merchant (Cá nhân hoặc Doanh nghiệp).
|
||||
/// </summary>
|
||||
public MerchantType Type => _type;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant type ID for EF Core mapping.
|
||||
/// VI: Type ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int TypeId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current merchant status.
|
||||
/// VI: Trạng thái merchant hiện tại.
|
||||
/// </summary>
|
||||
public MerchantStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: Status ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verification status.
|
||||
/// VI: Trạng thái xác minh.
|
||||
/// </summary>
|
||||
public VerificationStatus VerificationStatus => _verificationStatus;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verification status ID for EF Core mapping.
|
||||
/// VI: Verification status ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int VerificationStatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Business information for verification.
|
||||
/// VI: Thông tin doanh nghiệp để xác minh.
|
||||
/// </summary>
|
||||
public BusinessInfo BusinessInfo => _businessInfo;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Settlement configuration.
|
||||
/// VI: Cấu hình thanh toán.
|
||||
/// </summary>
|
||||
public SettlementConfig SettlementConfig => _settlementConfig;
|
||||
|
||||
/// <summary>
|
||||
/// EN: When merchant was verified.
|
||||
/// VI: Thời điểm merchant được xác minh.
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Who verified the merchant.
|
||||
/// VI: Ai đã xác minh merchant.
|
||||
/// </summary>
|
||||
public Guid? VerifiedBy { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete flag.
|
||||
/// VI: Cờ xóa mềm.
|
||||
/// </summary>
|
||||
public bool IsDeleted => _isDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Merchant()
|
||||
{
|
||||
_businessInfo = BusinessInfo.Empty;
|
||||
_settlementConfig = SettlementConfig.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Factory method to register a new merchant.
|
||||
/// VI: Factory method để đăng ký merchant mới.
|
||||
/// </summary>
|
||||
/// <param name="userId">IAM User ID / ID người dùng IAM</param>
|
||||
/// <param name="businessName">Business name / Tên doanh nghiệp</param>
|
||||
/// <param name="type">Merchant type / Loại merchant</param>
|
||||
/// <returns>New Merchant instance / Instance Merchant mới</returns>
|
||||
public static Merchant Register(Guid userId, string businessName, MerchantType type)
|
||||
{
|
||||
if (userId == Guid.Empty)
|
||||
throw new DomainException("User ID cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(businessName))
|
||||
throw new DomainException("Business name cannot be empty");
|
||||
ArgumentNullException.ThrowIfNull(type, nameof(type));
|
||||
|
||||
var merchant = new Merchant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
_userId = userId,
|
||||
_businessName = businessName.Trim(),
|
||||
_type = type,
|
||||
TypeId = type.Id,
|
||||
_status = MerchantStatus.PendingApproval,
|
||||
StatusId = MerchantStatus.PendingApproval.Id,
|
||||
_verificationStatus = VerificationStatus.Unverified,
|
||||
VerificationStatusId = VerificationStatus.Unverified.Id,
|
||||
_businessInfo = BusinessInfo.Empty,
|
||||
_settlementConfig = SettlementConfig.Default,
|
||||
_createdAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
merchant.AddDomainEvent(new MerchantRegisteredDomainEvent(merchant));
|
||||
return merchant;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update business name.
|
||||
/// VI: Cập nhật tên doanh nghiệp.
|
||||
/// </summary>
|
||||
public void UpdateBusinessName(string businessName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(businessName))
|
||||
throw new DomainException("Business name cannot be empty");
|
||||
|
||||
_businessName = businessName.Trim();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update business information.
|
||||
/// VI: Cập nhật thông tin doanh nghiệp.
|
||||
/// </summary>
|
||||
public void UpdateBusinessInfo(BusinessInfo businessInfo)
|
||||
{
|
||||
_businessInfo = businessInfo ?? BusinessInfo.Empty;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update settlement configuration.
|
||||
/// VI: Cập nhật cấu hình thanh toán.
|
||||
/// </summary>
|
||||
public void UpdateSettlementConfig(SettlementConfig config)
|
||||
{
|
||||
_settlementConfig = config ?? SettlementConfig.Default;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Submit verification documents.
|
||||
/// VI: Nộp tài liệu xác minh.
|
||||
/// </summary>
|
||||
public void SubmitForVerification()
|
||||
{
|
||||
if (!_businessInfo.IsCompleteForVerification)
|
||||
throw new DomainException("Business information is incomplete for verification");
|
||||
|
||||
_verificationStatus = VerificationStatus.Pending;
|
||||
VerificationStatusId = VerificationStatus.Pending.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new MerchantVerificationSubmittedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Approve merchant and activate account.
|
||||
/// VI: Phê duyệt merchant và kích hoạt tài khoản.
|
||||
/// </summary>
|
||||
/// <param name="approvedBy">Admin user ID who approved / ID admin phê duyệt</param>
|
||||
public void Approve(Guid approvedBy)
|
||||
{
|
||||
if (!_status.CanBeActivated)
|
||||
throw new DomainException($"Cannot approve merchant with status {_status.Name}");
|
||||
|
||||
_status = MerchantStatus.Active;
|
||||
StatusId = MerchantStatus.Active.Id;
|
||||
_verificationStatus = VerificationStatus.Verified;
|
||||
VerificationStatusId = VerificationStatus.Verified.Id;
|
||||
VerifiedAt = DateTime.UtcNow;
|
||||
VerifiedBy = approvedBy;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new MerchantApprovedDomainEvent(this, approvedBy));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Suspend merchant.
|
||||
/// VI: Tạm ngưng merchant.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for suspension / Lý do tạm ngưng</param>
|
||||
public void Suspend(string reason)
|
||||
{
|
||||
if (!_status.CanBeSuspended)
|
||||
throw new DomainException($"Cannot suspend merchant with status {_status.Name}");
|
||||
|
||||
_status = MerchantStatus.Suspended;
|
||||
StatusId = MerchantStatus.Suspended.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new MerchantSuspendedDomainEvent(this, reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reactivate suspended merchant.
|
||||
/// VI: Kích hoạt lại merchant bị tạm ngưng.
|
||||
/// </summary>
|
||||
public void Reactivate()
|
||||
{
|
||||
if (_status != MerchantStatus.Suspended)
|
||||
throw new DomainException("Can only reactivate suspended merchants");
|
||||
|
||||
_status = MerchantStatus.Active;
|
||||
StatusId = MerchantStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ban merchant permanently.
|
||||
/// VI: Cấm merchant vĩnh viễn.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for ban / Lý do cấm</param>
|
||||
public void Ban(string reason)
|
||||
{
|
||||
if (_status == MerchantStatus.Banned)
|
||||
throw new DomainException("Merchant is already banned");
|
||||
|
||||
_status = MerchantStatus.Banned;
|
||||
StatusId = MerchantStatus.Banned.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new MerchantBannedDomainEvent(this, reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete merchant.
|
||||
/// VI: Xóa mềm merchant.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
if (_isDeleted)
|
||||
throw new DomainException("Merchant is already deleted");
|
||||
|
||||
_isDeleted = true;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// EN: Merchant status enumeration.
|
||||
// VI: Enumeration trạng thái Merchant.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the status of a merchant.
|
||||
/// VI: Đại diện cho trạng thái của merchant.
|
||||
/// </summary>
|
||||
public class MerchantStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Merchant is pending admin approval.
|
||||
/// VI: Merchant đang chờ admin phê duyệt.
|
||||
/// </summary>
|
||||
public static readonly MerchantStatus PendingApproval = new(1, nameof(PendingApproval));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant is active and can operate.
|
||||
/// VI: Merchant đang hoạt động và có thể kinh doanh.
|
||||
/// </summary>
|
||||
public static readonly MerchantStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant is temporarily suspended.
|
||||
/// VI: Merchant tạm thời bị đình chỉ.
|
||||
/// </summary>
|
||||
public static readonly MerchantStatus Suspended = new(3, nameof(Suspended));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant is permanently banned.
|
||||
/// VI: Merchant bị cấm vĩnh viễn.
|
||||
/// </summary>
|
||||
public static readonly MerchantStatus Banned = new(4, nameof(Banned));
|
||||
|
||||
public MerchantStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if merchant can create shops.
|
||||
/// VI: Kiểm tra merchant có thể tạo shop không.
|
||||
/// </summary>
|
||||
public bool CanCreateShop => this == Active;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if merchant can be suspended.
|
||||
/// VI: Kiểm tra merchant có thể bị đình chỉ không.
|
||||
/// </summary>
|
||||
public bool CanBeSuspended => this == Active;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if merchant can be activated.
|
||||
/// VI: Kiểm tra merchant có thể được kích hoạt không.
|
||||
/// </summary>
|
||||
public bool CanBeActivated => this == PendingApproval || this == Suspended;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// EN: Merchant type enumeration.
|
||||
// VI: Enumeration loại Merchant.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the type of merchant (Individual or Company).
|
||||
/// VI: Đại diện cho loại merchant (Cá nhân hoặc Doanh nghiệp).
|
||||
/// </summary>
|
||||
public class MerchantType : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Individual merchant (sole proprietor).
|
||||
/// VI: Merchant cá nhân (hộ kinh doanh).
|
||||
/// </summary>
|
||||
public static readonly MerchantType Individual = new(1, nameof(Individual));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Company/Business merchant.
|
||||
/// VI: Merchant doanh nghiệp.
|
||||
/// </summary>
|
||||
public static readonly MerchantType Company = new(2, nameof(Company));
|
||||
|
||||
public MerchantType(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// EN: Settlement configuration value object.
|
||||
// VI: Value object cấu hình thanh toán.
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Value object representing settlement configuration for merchant payouts.
|
||||
/// VI: Value object đại diện cho cấu hình thanh toán cho merchant.
|
||||
/// </summary>
|
||||
public record SettlementConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Bank account for receiving settlements.
|
||||
/// VI: Tài khoản ngân hàng để nhận thanh toán.
|
||||
/// </summary>
|
||||
public BankAccount BankAccount { get; init; } = BankAccount.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Platform commission rate (percentage, e.g., 5.0 = 5%).
|
||||
/// VI: Tỷ lệ hoa hồng platform (phần trăm, ví dụ: 5.0 = 5%).
|
||||
/// </summary>
|
||||
public decimal CommissionRate { get; init; } = 0m;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Settlement cycle (Daily, Weekly, Monthly).
|
||||
/// VI: Chu kỳ thanh toán (Hàng ngày, Hàng tuần, Hàng tháng).
|
||||
/// </summary>
|
||||
public int SettlementCycleId { get; init; } = SettlementCycle.Monthly.Id;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether to automatically settle or require manual trigger.
|
||||
/// VI: Có tự động thanh toán hay yêu cầu kích hoạt thủ công.
|
||||
/// </summary>
|
||||
public bool AutoSettlement { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creates default settlement config.
|
||||
/// VI: Tạo cấu hình thanh toán mặc định.
|
||||
/// </summary>
|
||||
public static SettlementConfig Default => new()
|
||||
{
|
||||
BankAccount = BankAccount.Empty,
|
||||
CommissionRate = 0m,
|
||||
SettlementCycleId = SettlementCycle.Monthly.Id,
|
||||
AutoSettlement = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if settlement config is complete.
|
||||
/// VI: Kiểm tra cấu hình thanh toán đã đầy đủ chưa.
|
||||
/// </summary>
|
||||
public bool IsComplete => BankAccount.IsValid;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// EN: Settlement cycle enumeration.
|
||||
// VI: Enumeration chu kỳ thanh toán.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the settlement cycle for merchant payouts.
|
||||
/// VI: Đại diện cho chu kỳ thanh toán cho merchant.
|
||||
/// </summary>
|
||||
public class SettlementCycle : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Daily settlement.
|
||||
/// VI: Thanh toán hàng ngày.
|
||||
/// </summary>
|
||||
public static readonly SettlementCycle Daily = new(1, nameof(Daily));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Weekly settlement.
|
||||
/// VI: Thanh toán hàng tuần.
|
||||
/// </summary>
|
||||
public static readonly SettlementCycle Weekly = new(2, nameof(Weekly));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Monthly settlement.
|
||||
/// VI: Thanh toán hàng tháng.
|
||||
/// </summary>
|
||||
public static readonly SettlementCycle Monthly = new(3, nameof(Monthly));
|
||||
|
||||
public SettlementCycle(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Verification status enumeration.
|
||||
// VI: Enumeration trạng thái xác minh.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the verification status of a merchant.
|
||||
/// VI: Đại diện cho trạng thái xác minh của merchant.
|
||||
/// </summary>
|
||||
public class VerificationStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Merchant has not submitted verification documents.
|
||||
/// VI: Merchant chưa nộp tài liệu xác minh.
|
||||
/// </summary>
|
||||
public static readonly VerificationStatus Unverified = new(1, nameof(Unverified));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verification documents are pending review.
|
||||
/// VI: Tài liệu xác minh đang chờ xem xét.
|
||||
/// </summary>
|
||||
public static readonly VerificationStatus Pending = new(2, nameof(Pending));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant is verified.
|
||||
/// VI: Merchant đã được xác minh.
|
||||
/// </summary>
|
||||
public static readonly VerificationStatus Verified = new(3, nameof(Verified));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verification was rejected.
|
||||
/// VI: Xác minh bị từ chối.
|
||||
/// </summary>
|
||||
public static readonly VerificationStatus Rejected = new(4, nameof(Rejected));
|
||||
|
||||
public VerificationStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// EN: Device token entity for POS and push notifications.
|
||||
// VI: Entity device token cho POS và push notifications.
|
||||
|
||||
using MerchantService.Domain.Exceptions;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents a registered device for staff (POS, Mobile).
|
||||
/// VI: Đại diện cho thiết bị đã đăng ký của nhân viên (POS, Mobile).
|
||||
/// </summary>
|
||||
public class DeviceToken : Entity
|
||||
{
|
||||
private Guid _staffId;
|
||||
private string _deviceId = null!;
|
||||
private string? _deviceName;
|
||||
private string? _fcmToken;
|
||||
private string _platform = null!;
|
||||
private DateTime? _lastUsedAt;
|
||||
private DateTime _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff ID this device belongs to.
|
||||
/// VI: ID nhân viên sở hữu thiết bị này.
|
||||
/// </summary>
|
||||
public Guid StaffId => _staffId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unique device identifier.
|
||||
/// VI: Định danh thiết bị duy nhất.
|
||||
/// </summary>
|
||||
public string DeviceId => _deviceId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Human-readable device name.
|
||||
/// VI: Tên thiết bị dễ đọc.
|
||||
/// </summary>
|
||||
public string? DeviceName => _deviceName;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Firebase Cloud Messaging token for push notifications.
|
||||
/// VI: Token Firebase Cloud Messaging cho push notifications.
|
||||
/// </summary>
|
||||
public string? FcmToken => _fcmToken;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Platform (ios, android, pos).
|
||||
/// VI: Nền tảng (ios, android, pos).
|
||||
/// </summary>
|
||||
public string Platform => _platform;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last time this device was used.
|
||||
/// VI: Lần cuối thiết bị này được sử dụng.
|
||||
/// </summary>
|
||||
public DateTime? LastUsedAt => _lastUsedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: When the device was registered.
|
||||
/// VI: Thời điểm thiết bị được đăng ký.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected DeviceToken()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new device token.
|
||||
/// VI: Tạo device token mới.
|
||||
/// </summary>
|
||||
public DeviceToken(Guid staffId, string deviceId, string? deviceName, string? fcmToken, string platform)
|
||||
{
|
||||
if (staffId == Guid.Empty)
|
||||
throw new DomainException("Staff ID cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
throw new DomainException("Device ID cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(platform))
|
||||
throw new DomainException("Platform cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_staffId = staffId;
|
||||
_deviceId = deviceId;
|
||||
_deviceName = deviceName;
|
||||
_fcmToken = fcmToken;
|
||||
_platform = platform.ToLowerInvariant();
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update FCM token.
|
||||
/// VI: Cập nhật FCM token.
|
||||
/// </summary>
|
||||
public void UpdateFcmToken(string? fcmToken)
|
||||
{
|
||||
_fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update device name.
|
||||
/// VI: Cập nhật tên thiết bị.
|
||||
/// </summary>
|
||||
public void UpdateDeviceName(string? deviceName)
|
||||
{
|
||||
_deviceName = deviceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark device as used now.
|
||||
/// VI: Đánh dấu thiết bị vừa được sử dụng.
|
||||
/// </summary>
|
||||
public void MarkUsed()
|
||||
{
|
||||
_lastUsedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// EN: MerchantStaff repository interface.
|
||||
// VI: Interface repository cho MerchantStaff.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for MerchantStaff aggregate.
|
||||
/// VI: Interface repository cho MerchantStaff aggregate.
|
||||
/// </summary>
|
||||
public interface IMerchantStaffRepository : IRepository<MerchantStaff>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get staff by ID.
|
||||
/// VI: Lấy nhân viên theo ID.
|
||||
/// </summary>
|
||||
Task<MerchantStaff?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get staff by user ID (IAM User).
|
||||
/// VI: Lấy nhân viên theo user ID (IAM User).
|
||||
/// </summary>
|
||||
Task<MerchantStaff?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get staff by user ID and merchant ID.
|
||||
/// VI: Lấy nhân viên theo user ID và merchant ID.
|
||||
/// </summary>
|
||||
Task<MerchantStaff?> GetByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all staff by merchant ID.
|
||||
/// VI: Lấy tất cả nhân viên theo merchant ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MerchantStaff>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get staff by shop ID (through ShopMember).
|
||||
/// VI: Lấy nhân viên theo shop ID (thông qua ShopMember).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MerchantStaff>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if user is already staff of merchant.
|
||||
/// VI: Kiểm tra user đã là nhân viên của merchant chưa.
|
||||
/// </summary>
|
||||
Task<bool> ExistsByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new staff member.
|
||||
/// VI: Thêm nhân viên mới.
|
||||
/// </summary>
|
||||
MerchantStaff Add(MerchantStaff staff);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a staff member.
|
||||
/// VI: Cập nhật nhân viên.
|
||||
/// </summary>
|
||||
void Update(MerchantStaff staff);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// EN: MerchantStaff aggregate root.
|
||||
// VI: Aggregate root MerchantStaff.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.Events;
|
||||
using MerchantService.Domain.Exceptions;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MerchantStaff aggregate root - represents an employee of a merchant.
|
||||
/// VI: Aggregate root MerchantStaff - đại diện cho nhân viên của merchant.
|
||||
/// </summary>
|
||||
public class MerchantStaff : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private Guid _userId;
|
||||
private Guid _merchantId;
|
||||
private string? _employeeCode;
|
||||
private StaffRole _role = null!;
|
||||
private StaffStatus _status = null!;
|
||||
private StaffPermissions _permissions;
|
||||
private string? _phone;
|
||||
private string? _email;
|
||||
private string? _pinCodeHash;
|
||||
private DateTime _joinedAt;
|
||||
private DateTime? _terminatedAt;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
private readonly List<DeviceToken> _deviceTokens = new();
|
||||
private readonly List<ShopMember> _shopAssignments = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference to IAM User.
|
||||
/// VI: Tham chiếu đến IAM User.
|
||||
/// </summary>
|
||||
public Guid UserId => _userId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant ID (employer).
|
||||
/// VI: ID merchant (chủ lao động).
|
||||
/// </summary>
|
||||
public Guid MerchantId => _merchantId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Employee code for internal reference.
|
||||
/// VI: Mã nhân viên để tham chiếu nội bộ.
|
||||
/// </summary>
|
||||
public string? EmployeeCode => _employeeCode;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff role.
|
||||
/// VI: Vai trò nhân viên.
|
||||
/// </summary>
|
||||
public StaffRole Role => _role;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Role ID for EF Core mapping.
|
||||
/// VI: Role ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int RoleId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff status.
|
||||
/// VI: Trạng thái nhân viên.
|
||||
/// </summary>
|
||||
public StaffStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: Status ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff permissions bitmask.
|
||||
/// VI: Bitmask quyền hạn nhân viên.
|
||||
/// </summary>
|
||||
public StaffPermissions Permissions => _permissions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff phone number.
|
||||
/// VI: Số điện thoại nhân viên.
|
||||
/// </summary>
|
||||
public string? Phone => _phone;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff email.
|
||||
/// VI: Email nhân viên.
|
||||
/// </summary>
|
||||
public string? Email => _email;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hashed PIN code for POS authentication.
|
||||
/// VI: Mã PIN đã hash cho xác thực POS.
|
||||
/// </summary>
|
||||
public string? PinCodeHash => _pinCodeHash;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Registered devices for this staff.
|
||||
/// VI: Các thiết bị đã đăng ký của nhân viên này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<DeviceToken> DeviceTokens => _deviceTokens.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop assignments for this staff.
|
||||
/// VI: Các shop được gán cho nhân viên này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<ShopMember> ShopAssignments => _shopAssignments.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: When staff joined.
|
||||
/// VI: Thời điểm nhân viên gia nhập.
|
||||
/// </summary>
|
||||
public DateTime JoinedAt => _joinedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: When employment was terminated.
|
||||
/// VI: Thời điểm chấm dứt hợp đồng.
|
||||
/// </summary>
|
||||
public DateTime? TerminatedAt => _terminatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected MerchantStaff()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Invite a new staff member.
|
||||
/// VI: Mời nhân viên mới.
|
||||
/// </summary>
|
||||
public static MerchantStaff Invite(
|
||||
Guid merchantId,
|
||||
string email,
|
||||
StaffRole role,
|
||||
StaffPermissions permissions = StaffPermissions.None)
|
||||
{
|
||||
if (merchantId == Guid.Empty)
|
||||
throw new DomainException("Merchant ID cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
throw new DomainException("Email cannot be empty");
|
||||
ArgumentNullException.ThrowIfNull(role, nameof(role));
|
||||
|
||||
var staff = new MerchantStaff
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
_userId = Guid.Empty, // Will be set when invitation is accepted
|
||||
_merchantId = merchantId,
|
||||
_email = email.Trim().ToLowerInvariant(),
|
||||
_role = role,
|
||||
RoleId = role.Id,
|
||||
_status = StaffStatus.Invited,
|
||||
StatusId = StaffStatus.Invited.Id,
|
||||
_permissions = permissions,
|
||||
_createdAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
staff.AddDomainEvent(new StaffInvitedDomainEvent(staff));
|
||||
return staff;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Accept invitation and link to IAM user.
|
||||
/// VI: Chấp nhận lời mời và liên kết với IAM user.
|
||||
/// </summary>
|
||||
public void AcceptInvitation(Guid userId)
|
||||
{
|
||||
if (_status != StaffStatus.Invited)
|
||||
throw new DomainException("Can only accept pending invitations");
|
||||
if (userId == Guid.Empty)
|
||||
throw new DomainException("User ID cannot be empty");
|
||||
|
||||
_userId = userId;
|
||||
_status = StaffStatus.Active;
|
||||
StatusId = StaffStatus.Active.Id;
|
||||
_joinedAt = DateTime.UtcNow;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new StaffJoinedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update staff information.
|
||||
/// VI: Cập nhật thông tin nhân viên.
|
||||
/// </summary>
|
||||
public void Update(string? employeeCode, string? phone)
|
||||
{
|
||||
_employeeCode = employeeCode?.Trim();
|
||||
_phone = phone?.Trim();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update staff role.
|
||||
/// VI: Cập nhật vai trò nhân viên.
|
||||
/// </summary>
|
||||
public void UpdateRole(StaffRole role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(role, nameof(role));
|
||||
_role = role;
|
||||
RoleId = role.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update staff permissions.
|
||||
/// VI: Cập nhật quyền hạn nhân viên.
|
||||
/// </summary>
|
||||
public void UpdatePermissions(StaffPermissions permissions)
|
||||
{
|
||||
_permissions = permissions;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set PIN code for POS authentication.
|
||||
/// VI: Đặt mã PIN cho xác thực POS.
|
||||
/// </summary>
|
||||
public void SetPinCode(string pin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pin))
|
||||
throw new DomainException("PIN cannot be empty");
|
||||
if (pin.Length < 4 || pin.Length > 6)
|
||||
throw new DomainException("PIN must be 4-6 digits");
|
||||
if (!pin.All(char.IsDigit))
|
||||
throw new DomainException("PIN must contain only digits");
|
||||
|
||||
_pinCodeHash = HashPin(pin);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new StaffPinCodeSetDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verify PIN code for POS login.
|
||||
/// VI: Xác minh mã PIN cho đăng nhập POS.
|
||||
/// </summary>
|
||||
public bool VerifyPinCode(string pin)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_pinCodeHash))
|
||||
return false;
|
||||
|
||||
var hash = HashPin(pin);
|
||||
return hash == _pinCodeHash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Register a device for POS/push notifications.
|
||||
/// VI: Đăng ký thiết bị cho POS/push notifications.
|
||||
/// </summary>
|
||||
public DeviceToken RegisterDevice(string deviceId, string? deviceName, string? fcmToken, string platform)
|
||||
{
|
||||
var existingToken = _deviceTokens.FirstOrDefault(t => t.DeviceId == deviceId);
|
||||
if (existingToken != null)
|
||||
{
|
||||
existingToken.UpdateFcmToken(fcmToken);
|
||||
existingToken.UpdateDeviceName(deviceName);
|
||||
existingToken.MarkUsed();
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
var token = new DeviceToken(Id, deviceId, deviceName, fcmToken, platform);
|
||||
_deviceTokens.Add(token);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new StaffDeviceRegisteredDomainEvent(this, token));
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a registered device.
|
||||
/// VI: Xóa thiết bị đã đăng ký.
|
||||
/// </summary>
|
||||
public void RemoveDevice(string deviceId)
|
||||
{
|
||||
var token = _deviceTokens.FirstOrDefault(t => t.DeviceId == deviceId);
|
||||
if (token != null)
|
||||
{
|
||||
_deviceTokens.Remove(token);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Assign staff to a shop.
|
||||
/// VI: Gán nhân viên vào shop.
|
||||
/// </summary>
|
||||
public ShopMember AssignToShop(Guid shopId, ShopRole role, Guid? branchId = null, bool isPrimary = false)
|
||||
{
|
||||
if (_shopAssignments.Any(a => a.ShopId == shopId))
|
||||
throw new DomainException("Staff is already assigned to this shop");
|
||||
|
||||
// If setting as primary, unset other primaries
|
||||
if (isPrimary)
|
||||
{
|
||||
foreach (var assignment in _shopAssignments)
|
||||
{
|
||||
assignment.UnsetAsPrimary();
|
||||
}
|
||||
}
|
||||
|
||||
var shopMember = new ShopMember(Id, shopId, role, branchId, isPrimary);
|
||||
_shopAssignments.Add(shopMember);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new StaffAssignedToShopDomainEvent(this, shopMember));
|
||||
return shopMember;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove shop assignment.
|
||||
/// VI: Xóa gán shop.
|
||||
/// </summary>
|
||||
public void RemoveFromShop(Guid shopId)
|
||||
{
|
||||
var assignment = _shopAssignments.FirstOrDefault(a => a.ShopId == shopId);
|
||||
if (assignment != null)
|
||||
{
|
||||
_shopAssignments.Remove(assignment);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate staff.
|
||||
/// VI: Vô hiệu hóa nhân viên.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
if (_status == StaffStatus.Terminated)
|
||||
throw new DomainException("Cannot deactivate terminated staff");
|
||||
|
||||
_status = StaffStatus.Inactive;
|
||||
StatusId = StaffStatus.Inactive.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reactivate staff.
|
||||
/// VI: Kích hoạt lại nhân viên.
|
||||
/// </summary>
|
||||
public void Reactivate()
|
||||
{
|
||||
if (_status != StaffStatus.Inactive)
|
||||
throw new DomainException("Can only reactivate inactive staff");
|
||||
|
||||
_status = StaffStatus.Active;
|
||||
StatusId = StaffStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Terminate employment.
|
||||
/// VI: Chấm dứt hợp đồng.
|
||||
/// </summary>
|
||||
public void Terminate()
|
||||
{
|
||||
if (_status == StaffStatus.Terminated)
|
||||
throw new DomainException("Staff is already terminated");
|
||||
|
||||
_status = StaffStatus.Terminated;
|
||||
StatusId = StaffStatus.Terminated.Id;
|
||||
_terminatedAt = DateTime.UtcNow;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new StaffTerminatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if staff has a specific permission.
|
||||
/// VI: Kiểm tra nhân viên có quyền cụ thể không.
|
||||
/// </summary>
|
||||
public bool HasPermission(StaffPermissions permission)
|
||||
{
|
||||
if (_permissions == StaffPermissions.All)
|
||||
return true;
|
||||
return (_permissions & permission) == permission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hash PIN code.
|
||||
/// VI: Hash mã PIN.
|
||||
/// </summary>
|
||||
private static string HashPin(string pin)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(pin));
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// EN: Shop member entity - staff assignment to shop.
|
||||
// VI: Entity shop member - gán nhân viên vào shop.
|
||||
|
||||
using MerchantService.Domain.Exceptions;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents a staff member's assignment to a shop.
|
||||
/// VI: Đại diện cho việc gán nhân viên vào shop.
|
||||
/// </summary>
|
||||
public class ShopMember : Entity
|
||||
{
|
||||
private Guid _staffId;
|
||||
private Guid _shopId;
|
||||
private Guid? _branchId;
|
||||
private ShopRole _role = null!;
|
||||
private StaffPermissions? _customPermissions;
|
||||
private bool _isPrimary;
|
||||
private DateTime _assignedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff ID.
|
||||
/// VI: ID nhân viên.
|
||||
/// </summary>
|
||||
public Guid StaffId => _staffId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop ID.
|
||||
/// VI: ID shop.
|
||||
/// </summary>
|
||||
public Guid ShopId => _shopId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Specific branch ID (null = all branches).
|
||||
/// VI: ID chi nhánh cụ thể (null = tất cả chi nhánh).
|
||||
/// </summary>
|
||||
public Guid? BranchId => _branchId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Role at this shop.
|
||||
/// VI: Vai trò tại shop này.
|
||||
/// </summary>
|
||||
public ShopRole Role => _role;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Role ID for EF Core mapping.
|
||||
/// VI: Role ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int RoleId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom permissions override for this shop.
|
||||
/// VI: Quyền tùy chỉnh ghi đè cho shop này.
|
||||
/// </summary>
|
||||
public StaffPermissions? CustomPermissions => _customPermissions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether this is the staff's primary shop.
|
||||
/// VI: Đây có phải shop chính của nhân viên không.
|
||||
/// </summary>
|
||||
public bool IsPrimary => _isPrimary;
|
||||
|
||||
/// <summary>
|
||||
/// EN: When the staff was assigned to this shop.
|
||||
/// VI: Thời điểm nhân viên được gán vào shop này.
|
||||
/// </summary>
|
||||
public DateTime AssignedAt => _assignedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected ShopMember()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new shop member assignment.
|
||||
/// VI: Tạo việc gán shop member mới.
|
||||
/// </summary>
|
||||
public ShopMember(Guid staffId, Guid shopId, ShopRole role, Guid? branchId = null, bool isPrimary = false)
|
||||
{
|
||||
if (staffId == Guid.Empty)
|
||||
throw new DomainException("Staff ID cannot be empty");
|
||||
if (shopId == Guid.Empty)
|
||||
throw new DomainException("Shop ID cannot be empty");
|
||||
ArgumentNullException.ThrowIfNull(role, nameof(role));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_staffId = staffId;
|
||||
_shopId = shopId;
|
||||
_branchId = branchId;
|
||||
_role = role;
|
||||
RoleId = role.Id;
|
||||
_isPrimary = isPrimary;
|
||||
_assignedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update role at this shop.
|
||||
/// VI: Cập nhật vai trò tại shop này.
|
||||
/// </summary>
|
||||
public void UpdateRole(ShopRole role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(role, nameof(role));
|
||||
_role = role;
|
||||
RoleId = role.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set custom permissions for this shop.
|
||||
/// VI: Đặt quyền tùy chỉnh cho shop này.
|
||||
/// </summary>
|
||||
public void SetCustomPermissions(StaffPermissions? permissions)
|
||||
{
|
||||
_customPermissions = permissions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set as primary shop.
|
||||
/// VI: Đặt làm shop chính.
|
||||
/// </summary>
|
||||
public void SetAsPrimary()
|
||||
{
|
||||
_isPrimary = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unset as primary shop.
|
||||
/// VI: Bỏ đặt làm shop chính.
|
||||
/// </summary>
|
||||
public void UnsetAsPrimary()
|
||||
{
|
||||
_isPrimary = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Assign to specific branch.
|
||||
/// VI: Gán vào chi nhánh cụ thể.
|
||||
/// </summary>
|
||||
public void AssignToBranch(Guid branchId)
|
||||
{
|
||||
_branchId = branchId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove branch assignment (assign to all branches).
|
||||
/// VI: Xóa gán chi nhánh (gán cho tất cả chi nhánh).
|
||||
/// </summary>
|
||||
public void AssignToAllBranches()
|
||||
{
|
||||
_branchId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// EN: Staff role and status enumerations.
|
||||
// VI: Enumeration vai trò và trạng thái nhân viên.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents staff roles within a merchant organization.
|
||||
/// VI: Đại diện cho các vai trò nhân viên trong tổ chức merchant.
|
||||
/// </summary>
|
||||
public class StaffRole : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Cashier - handles payments.
|
||||
/// VI: Thu ngân - xử lý thanh toán.
|
||||
/// </summary>
|
||||
public static readonly StaffRole Cashier = new(1, nameof(Cashier));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Waiter/Server - serves customers.
|
||||
/// VI: Phục vụ - phục vụ khách hàng.
|
||||
/// </summary>
|
||||
public static readonly StaffRole Waiter = new(2, nameof(Waiter));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Manager - manages staff and operations.
|
||||
/// VI: Quản lý - quản lý nhân viên và hoạt động.
|
||||
/// </summary>
|
||||
public static readonly StaffRole Manager = new(3, nameof(Manager));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin - full access to merchant features.
|
||||
/// VI: Admin - toàn quyền truy cập tính năng merchant.
|
||||
/// </summary>
|
||||
public static readonly StaffRole Admin = new(4, nameof(Admin));
|
||||
|
||||
public StaffRole(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the status of a staff member.
|
||||
/// VI: Đại diện cho trạng thái của nhân viên.
|
||||
/// </summary>
|
||||
public class StaffStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Invited but not yet accepted.
|
||||
/// VI: Đã mời nhưng chưa chấp nhận.
|
||||
/// </summary>
|
||||
public static readonly StaffStatus Invited = new(1, nameof(Invited));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active staff member.
|
||||
/// VI: Nhân viên đang hoạt động.
|
||||
/// </summary>
|
||||
public static readonly StaffStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Temporarily inactive.
|
||||
/// VI: Tạm thời không hoạt động.
|
||||
/// </summary>
|
||||
public static readonly StaffStatus Inactive = new(3, nameof(Inactive));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Employment terminated.
|
||||
/// VI: Đã chấm dứt hợp đồng.
|
||||
/// </summary>
|
||||
public static readonly StaffStatus Terminated = new(4, nameof(Terminated));
|
||||
|
||||
public StaffStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Staff permissions bitmask.
|
||||
/// VI: Bitmask quyền hạn nhân viên.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum StaffPermissions
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: No permissions.
|
||||
/// VI: Không có quyền.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// EN: View sales data.
|
||||
/// VI: Xem dữ liệu bán hàng.
|
||||
/// </summary>
|
||||
ViewSales = 1,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Process payment transactions.
|
||||
/// VI: Xử lý giao dịch thanh toán.
|
||||
/// </summary>
|
||||
ProcessPayment = 2,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Process refunds.
|
||||
/// VI: Xử lý hoàn tiền.
|
||||
/// </summary>
|
||||
RefundOrder = 4,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Manage inventory.
|
||||
/// VI: Quản lý kho.
|
||||
/// </summary>
|
||||
ManageInventory = 8,
|
||||
|
||||
/// <summary>
|
||||
/// EN: View reports and analytics.
|
||||
/// VI: Xem báo cáo và phân tích.
|
||||
/// </summary>
|
||||
ViewReports = 16,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Manage other staff members.
|
||||
/// VI: Quản lý nhân viên khác.
|
||||
/// </summary>
|
||||
ManageStaff = 32,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Manage shop settings.
|
||||
/// VI: Quản lý cài đặt shop.
|
||||
/// </summary>
|
||||
ManageSettings = 64,
|
||||
|
||||
/// <summary>
|
||||
/// EN: All permissions.
|
||||
/// VI: Tất cả quyền.
|
||||
/// </summary>
|
||||
All = int.MaxValue
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop role for staff assignment at shop level.
|
||||
/// VI: Vai trò tại shop cho việc gán nhân viên ở cấp shop.
|
||||
/// </summary>
|
||||
public class ShopRole : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Cashier role at shop level.
|
||||
/// VI: Vai trò thu ngân ở cấp shop.
|
||||
/// </summary>
|
||||
public static readonly ShopRole Cashier = new(1, nameof(Cashier));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Waiter role at shop level.
|
||||
/// VI: Vai trò phục vụ ở cấp shop.
|
||||
/// </summary>
|
||||
public static readonly ShopRole Waiter = new(2, nameof(Waiter));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Manager role at shop level.
|
||||
/// VI: Vai trò quản lý ở cấp shop.
|
||||
/// </summary>
|
||||
public static readonly ShopRole Manager = new(3, nameof(Manager));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Owner role at shop level (merchant themselves).
|
||||
/// VI: Vai trò chủ sở hữu ở cấp shop (chính merchant).
|
||||
/// </summary>
|
||||
public static readonly ShopRole Owner = new(4, nameof(Owner));
|
||||
|
||||
public ShopRole(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// EN: Business category enumeration.
|
||||
// VI: Enumeration ngành nghề kinh doanh.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents business categories for shops.
|
||||
/// VI: Đại diện cho ngành nghề kinh doanh của shop.
|
||||
/// </summary>
|
||||
public class BusinessCategory : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Food and Beverage.
|
||||
/// VI: Ẩm thực và Đồ uống.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory FoodBeverage = new(1, nameof(FoodBeverage));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Fashion and Apparel.
|
||||
/// VI: Thời trang và May mặc.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Fashion = new(2, nameof(Fashion));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Electronics and Technology.
|
||||
/// VI: Điện tử và Công nghệ.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Electronics = new(3, nameof(Electronics));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Healthcare and Pharmacy.
|
||||
/// VI: Chăm sóc sức khỏe và Dược phẩm.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Healthcare = new(4, nameof(Healthcare));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Beauty and Personal Care.
|
||||
/// VI: Làm đẹp và Chăm sóc cá nhân.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Beauty = new(5, nameof(Beauty));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Education and Training.
|
||||
/// VI: Giáo dục và Đào tạo.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Education = new(6, nameof(Education));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entertainment and Leisure.
|
||||
/// VI: Giải trí và Nghỉ dưỡng.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Entertainment = new(7, nameof(Entertainment));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Professional Services.
|
||||
/// VI: Dịch vụ chuyên nghiệp.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Services = new(8, nameof(Services));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Grocery and Supermarket.
|
||||
/// VI: Tạp hóa và Siêu thị.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Grocery = new(9, nameof(Grocery));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Home and Furniture.
|
||||
/// VI: Nhà cửa và Nội thất.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory HomeFurniture = new(10, nameof(HomeFurniture));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Other categories.
|
||||
/// VI: Các ngành nghề khác.
|
||||
/// </summary>
|
||||
public static readonly BusinessCategory Other = new(99, nameof(Other));
|
||||
|
||||
public BusinessCategory(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// EN: Shop repository interface.
|
||||
// VI: Interface repository cho Shop.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Shop aggregate.
|
||||
/// VI: Interface repository cho Shop aggregate.
|
||||
/// </summary>
|
||||
public interface IShopRepository : IRepository<Shop>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get shop by ID.
|
||||
/// VI: Lấy shop theo ID.
|
||||
/// </summary>
|
||||
Task<Shop?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get shop by ID with branches loaded.
|
||||
/// VI: Lấy shop theo ID kèm theo các chi nhánh.
|
||||
/// </summary>
|
||||
Task<Shop?> GetByIdWithBranchesAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get shop by slug (URL-friendly identifier).
|
||||
/// VI: Lấy shop theo slug (định danh thân thiện URL).
|
||||
/// </summary>
|
||||
Task<Shop?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if slug is already taken.
|
||||
/// VI: Kiểm tra slug đã được sử dụng chưa.
|
||||
/// </summary>
|
||||
Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all shops by merchant ID.
|
||||
/// VI: Lấy tất cả shops theo merchant ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Shop>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get shops by category.
|
||||
/// VI: Lấy shops theo ngành nghề.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Shop>> GetByCategoryAsync(BusinessCategory category, int pageNumber, int pageSize, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new shop.
|
||||
/// VI: Thêm shop mới.
|
||||
/// </summary>
|
||||
Shop Add(Shop shop);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a shop.
|
||||
/// VI: Cập nhật shop.
|
||||
/// </summary>
|
||||
void Update(Shop shop);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// EN: Shop aggregate root.
|
||||
// VI: Aggregate root Shop.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using MerchantService.Domain.Events;
|
||||
using MerchantService.Domain.Exceptions;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop aggregate root - represents a store owned by a merchant.
|
||||
/// VI: Aggregate root Shop - đại diện cho cửa hàng thuộc sở hữu của merchant.
|
||||
/// </summary>
|
||||
public partial class Shop : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private Guid _merchantId;
|
||||
private string _name = null!;
|
||||
private string _slug = null!;
|
||||
private ShopType _type = null!;
|
||||
private BusinessCategory _category = null!;
|
||||
private ShopStatus _status = null!;
|
||||
private ContactInfo _contactInfo = null!;
|
||||
private OperatingHours? _operatingHours;
|
||||
private string? _description;
|
||||
private string? _logoUrl;
|
||||
private string? _coverImageUrl;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
private bool _isDeleted;
|
||||
|
||||
private readonly List<ShopBranch> _branches = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parent merchant ID.
|
||||
/// VI: ID merchant cha.
|
||||
/// </summary>
|
||||
public Guid MerchantId => _merchantId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop name.
|
||||
/// VI: Tên shop.
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: URL-friendly unique identifier.
|
||||
/// VI: Định danh duy nhất thân thiện URL.
|
||||
/// </summary>
|
||||
public string Slug => _slug;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop type (Online, Physical, Hybrid).
|
||||
/// VI: Loại shop (Online, Cửa hàng vật lý, Hybrid).
|
||||
/// </summary>
|
||||
public ShopType Type => _type;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Type ID for EF Core mapping.
|
||||
/// VI: Type ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int TypeId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Business category.
|
||||
/// VI: Ngành nghề kinh doanh.
|
||||
/// </summary>
|
||||
public BusinessCategory Category => _category;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category ID for EF Core mapping.
|
||||
/// VI: Category ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int CategoryId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current shop status.
|
||||
/// VI: Trạng thái shop hiện tại.
|
||||
/// </summary>
|
||||
public ShopStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: Status ID cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Contact information.
|
||||
/// VI: Thông tin liên hệ.
|
||||
/// </summary>
|
||||
public ContactInfo ContactInfo => _contactInfo;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Operating hours.
|
||||
/// VI: Giờ hoạt động.
|
||||
/// </summary>
|
||||
public OperatingHours? OperatingHours => _operatingHours;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop description.
|
||||
/// VI: Mô tả shop.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop logo URL.
|
||||
/// VI: URL logo shop.
|
||||
/// </summary>
|
||||
public string? LogoUrl => _logoUrl;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cover image URL.
|
||||
/// VI: URL ảnh bìa.
|
||||
/// </summary>
|
||||
public string? CoverImageUrl => _coverImageUrl;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Physical branches of the shop.
|
||||
/// VI: Các chi nhánh vật lý của shop.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<ShopBranch> Branches => _branches.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete flag.
|
||||
/// VI: Cờ xóa mềm.
|
||||
/// </summary>
|
||||
public bool IsDeleted => _isDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Shop()
|
||||
{
|
||||
_contactInfo = ContactInfo.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new shop.
|
||||
/// VI: Tạo shop mới.
|
||||
/// </summary>
|
||||
public Shop(
|
||||
Guid merchantId,
|
||||
string name,
|
||||
string slug,
|
||||
ShopType type,
|
||||
BusinessCategory category)
|
||||
{
|
||||
if (merchantId == Guid.Empty)
|
||||
throw new DomainException("Merchant ID cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Shop name cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
throw new DomainException("Shop slug cannot be empty");
|
||||
if (!IsValidSlug(slug))
|
||||
throw new DomainException("Slug must contain only lowercase letters, numbers, and hyphens");
|
||||
ArgumentNullException.ThrowIfNull(type, nameof(type));
|
||||
ArgumentNullException.ThrowIfNull(category, nameof(category));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_merchantId = merchantId;
|
||||
_name = name.Trim();
|
||||
_slug = slug.ToLowerInvariant().Trim();
|
||||
_type = type;
|
||||
TypeId = type.Id;
|
||||
_category = category;
|
||||
CategoryId = category.Id;
|
||||
_status = ShopStatus.Draft;
|
||||
StatusId = ShopStatus.Draft.Id;
|
||||
_contactInfo = ContactInfo.Empty;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new ShopCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update basic shop information.
|
||||
/// VI: Cập nhật thông tin cơ bản của shop.
|
||||
/// </summary>
|
||||
public void UpdateInfo(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Shop name cannot be empty");
|
||||
|
||||
_name = name.Trim();
|
||||
_description = description?.Trim();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update shop slug.
|
||||
/// VI: Cập nhật slug của shop.
|
||||
/// </summary>
|
||||
public void UpdateSlug(string slug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
throw new DomainException("Shop slug cannot be empty");
|
||||
if (!IsValidSlug(slug))
|
||||
throw new DomainException("Slug must contain only lowercase letters, numbers, and hyphens");
|
||||
|
||||
_slug = slug.ToLowerInvariant().Trim();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update contact information.
|
||||
/// VI: Cập nhật thông tin liên hệ.
|
||||
/// </summary>
|
||||
public void UpdateContactInfo(ContactInfo contactInfo)
|
||||
{
|
||||
_contactInfo = contactInfo ?? ContactInfo.Empty;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update operating hours.
|
||||
/// VI: Cập nhật giờ hoạt động.
|
||||
/// </summary>
|
||||
public void UpdateOperatingHours(OperatingHours? hours)
|
||||
{
|
||||
_operatingHours = hours;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update shop images.
|
||||
/// VI: Cập nhật hình ảnh shop.
|
||||
/// </summary>
|
||||
public void UpdateImages(string? logoUrl, string? coverImageUrl)
|
||||
{
|
||||
_logoUrl = logoUrl;
|
||||
_coverImageUrl = coverImageUrl;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Publish shop (make it visible to customers).
|
||||
/// VI: Công khai shop (hiển thị với khách hàng).
|
||||
/// </summary>
|
||||
public void Publish()
|
||||
{
|
||||
if (_status != ShopStatus.Draft && _status != ShopStatus.Inactive)
|
||||
throw new DomainException($"Cannot publish shop with status {_status.Name}");
|
||||
|
||||
_status = ShopStatus.Active;
|
||||
StatusId = ShopStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new ShopPublishedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set shop to inactive.
|
||||
/// VI: Đặt shop thành không hoạt động.
|
||||
/// </summary>
|
||||
public void SetInactive()
|
||||
{
|
||||
if (_status == ShopStatus.Closed)
|
||||
throw new DomainException("Cannot change status of closed shop");
|
||||
|
||||
_status = ShopStatus.Inactive;
|
||||
StatusId = ShopStatus.Inactive.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close shop permanently.
|
||||
/// VI: Đóng cửa shop vĩnh viễn.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
_status = ShopStatus.Closed;
|
||||
StatusId = ShopStatus.Closed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new ShopClosedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a physical branch to the shop.
|
||||
/// VI: Thêm chi nhánh vật lý cho shop.
|
||||
/// </summary>
|
||||
public ShopBranch AddBranch(string name, Address address, GeoLocation? location = null)
|
||||
{
|
||||
if (!_type.SupportsBranches)
|
||||
throw new DomainException("Online-only shops cannot have physical branches");
|
||||
|
||||
var branch = new ShopBranch(Id, name, address, location);
|
||||
_branches.Add(branch);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new ShopBranchAddedDomainEvent(this, branch));
|
||||
return branch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a branch.
|
||||
/// VI: Xóa chi nhánh.
|
||||
/// </summary>
|
||||
public void RemoveBranch(Guid branchId)
|
||||
{
|
||||
var branch = _branches.FirstOrDefault(b => b.Id == branchId);
|
||||
if (branch == null)
|
||||
throw new DomainException("Branch not found");
|
||||
|
||||
_branches.Remove(branch);
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete shop.
|
||||
/// VI: Xóa mềm shop.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
if (_isDeleted)
|
||||
throw new DomainException("Shop is already deleted");
|
||||
|
||||
_isDeleted = true;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate slug format.
|
||||
/// VI: Kiểm tra định dạng slug.
|
||||
/// </summary>
|
||||
private static bool IsValidSlug(string slug)
|
||||
{
|
||||
return SlugRegex().IsMatch(slug);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
|
||||
private static partial Regex SlugRegex();
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// EN: Shop branch entity.
|
||||
// VI: Entity chi nhánh shop.
|
||||
|
||||
using MerchantService.Domain.Exceptions;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents a physical branch/location of a shop.
|
||||
/// VI: Đại diện cho chi nhánh/địa điểm vật lý của shop.
|
||||
/// </summary>
|
||||
public class ShopBranch : Entity
|
||||
{
|
||||
private Guid _shopId;
|
||||
private string _name = null!;
|
||||
private string? _code;
|
||||
private Address _address = null!;
|
||||
private GeoLocation? _location;
|
||||
private string? _phone;
|
||||
private OperatingHours? _operatingHours;
|
||||
private bool _isActive;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parent shop ID.
|
||||
/// VI: ID shop cha.
|
||||
/// </summary>
|
||||
public Guid ShopId => _shopId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Branch name (e.g., "Chi Nhánh Quận 1").
|
||||
/// VI: Tên chi nhánh (ví dụ: "Chi Nhánh Quận 1").
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Branch code for internal reference (e.g., "HN01").
|
||||
/// VI: Mã chi nhánh để tham chiếu nội bộ (ví dụ: "HN01").
|
||||
/// </summary>
|
||||
public string? Code => _code;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Physical address of the branch.
|
||||
/// VI: Địa chỉ vật lý của chi nhánh.
|
||||
/// </summary>
|
||||
public Address Address => _address;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Geographic coordinates for map display.
|
||||
/// VI: Tọa độ địa lý để hiển thị bản đồ.
|
||||
/// </summary>
|
||||
public GeoLocation? Location => _location;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Branch phone number.
|
||||
/// VI: Số điện thoại chi nhánh.
|
||||
/// </summary>
|
||||
public string? Phone => _phone;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Operating hours for this branch.
|
||||
/// VI: Giờ hoạt động của chi nhánh này.
|
||||
/// </summary>
|
||||
public OperatingHours? OperatingHours => _operatingHours;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether branch is active.
|
||||
/// VI: Chi nhánh có đang hoạt động không.
|
||||
/// </summary>
|
||||
public bool IsActive => _isActive;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected ShopBranch()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new shop branch.
|
||||
/// VI: Tạo chi nhánh shop mới.
|
||||
/// </summary>
|
||||
public ShopBranch(Guid shopId, string name, Address address, GeoLocation? location = null)
|
||||
{
|
||||
if (shopId == Guid.Empty)
|
||||
throw new DomainException("Shop ID cannot be empty");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Branch name cannot be empty");
|
||||
ArgumentNullException.ThrowIfNull(address, nameof(address));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_shopId = shopId;
|
||||
_name = name.Trim();
|
||||
_address = address;
|
||||
_location = location;
|
||||
_isActive = true;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update branch information.
|
||||
/// VI: Cập nhật thông tin chi nhánh.
|
||||
/// </summary>
|
||||
public void Update(string name, string? code, Address address, GeoLocation? location)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Branch name cannot be empty");
|
||||
|
||||
_name = name.Trim();
|
||||
_code = code?.Trim();
|
||||
_address = address;
|
||||
_location = location;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set branch phone number.
|
||||
/// VI: Đặt số điện thoại chi nhánh.
|
||||
/// </summary>
|
||||
public void SetPhone(string? phone)
|
||||
{
|
||||
_phone = phone?.Trim();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set operating hours for this branch.
|
||||
/// VI: Đặt giờ hoạt động cho chi nhánh này.
|
||||
/// </summary>
|
||||
public void SetOperatingHours(OperatingHours? hours)
|
||||
{
|
||||
_operatingHours = hours;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the branch.
|
||||
/// VI: Kích hoạt chi nhánh.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
_isActive = true;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate the branch.
|
||||
/// VI: Vô hiệu hóa chi nhánh.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// EN: Shop status enumeration.
|
||||
// VI: Enumeration trạng thái Shop.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the status of a shop.
|
||||
/// VI: Đại diện cho trạng thái của shop.
|
||||
/// </summary>
|
||||
public class ShopStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Shop is in draft mode (not published).
|
||||
/// VI: Shop ở chế độ nháp (chưa công khai).
|
||||
/// </summary>
|
||||
public static readonly ShopStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop is active and visible to customers.
|
||||
/// VI: Shop đang hoạt động và hiển thị với khách hàng.
|
||||
/// </summary>
|
||||
public static readonly ShopStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop is temporarily inactive.
|
||||
/// VI: Shop tạm thời không hoạt động.
|
||||
/// </summary>
|
||||
public static readonly ShopStatus Inactive = new(3, nameof(Inactive));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop is permanently closed.
|
||||
/// VI: Shop đóng cửa vĩnh viễn.
|
||||
/// </summary>
|
||||
public static readonly ShopStatus Closed = new(4, nameof(Closed));
|
||||
|
||||
public ShopStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if shop is visible to customers.
|
||||
/// VI: Kiểm tra shop có hiển thị với khách hàng không.
|
||||
/// </summary>
|
||||
public bool IsVisible => this == Active;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if shop can accept orders.
|
||||
/// VI: Kiểm tra shop có thể nhận đơn hàng không.
|
||||
/// </summary>
|
||||
public bool CanAcceptOrders => this == Active;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Shop type enumeration.
|
||||
// VI: Enumeration loại Shop.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents the type of shop (Online, Physical, or Hybrid).
|
||||
/// VI: Đại diện cho loại shop (Online, Cửa hàng vật lý, hoặc Hybrid).
|
||||
/// </summary>
|
||||
public class ShopType : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Online-only shop (e-commerce).
|
||||
/// VI: Shop chỉ online (thương mại điện tử).
|
||||
/// </summary>
|
||||
public static readonly ShopType OnlineOnly = new(1, nameof(OnlineOnly));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Physical store only.
|
||||
/// VI: Chỉ cửa hàng vật lý.
|
||||
/// </summary>
|
||||
public static readonly ShopType PhysicalOnly = new(2, nameof(PhysicalOnly));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Both online and physical presence.
|
||||
/// VI: Cả online và cửa hàng vật lý.
|
||||
/// </summary>
|
||||
public static readonly ShopType Hybrid = new(3, nameof(Hybrid));
|
||||
|
||||
public ShopType(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if shop type supports physical branches.
|
||||
/// VI: Kiểm tra loại shop có hỗ trợ chi nhánh vật lý không.
|
||||
/// </summary>
|
||||
public bool SupportsBranches => this != OnlineOnly;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// EN: Common value objects for Shop aggregate.
|
||||
// VI: Các value objects chung cho Shop aggregate.
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Contact information value object.
|
||||
/// VI: Value object thông tin liên hệ.
|
||||
/// </summary>
|
||||
public record ContactInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Phone number.
|
||||
/// VI: Số điện thoại.
|
||||
/// </summary>
|
||||
public string Phone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Email address.
|
||||
/// VI: Địa chỉ email.
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Website URL.
|
||||
/// VI: URL website.
|
||||
/// </summary>
|
||||
public string? Website { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Empty contact info.
|
||||
/// VI: Thông tin liên hệ rỗng.
|
||||
/// </summary>
|
||||
public static ContactInfo Empty => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Physical address value object.
|
||||
/// VI: Value object địa chỉ vật lý.
|
||||
/// </summary>
|
||||
public record Address
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Street address.
|
||||
/// VI: Địa chỉ đường phố.
|
||||
/// </summary>
|
||||
public string Street { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ward/Commune.
|
||||
/// VI: Phường/Xã.
|
||||
/// </summary>
|
||||
public string? Ward { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: District.
|
||||
/// VI: Quận/Huyện.
|
||||
/// </summary>
|
||||
public string District { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: City.
|
||||
/// VI: Thành phố.
|
||||
/// </summary>
|
||||
public string City { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Province/State.
|
||||
/// VI: Tỉnh/Thành.
|
||||
/// </summary>
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Postal/ZIP code.
|
||||
/// VI: Mã bưu điện.
|
||||
/// </summary>
|
||||
public string? PostalCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Country code (ISO 3166-1 alpha-2).
|
||||
/// VI: Mã quốc gia (ISO 3166-1 alpha-2).
|
||||
/// </summary>
|
||||
public string CountryCode { get; init; } = "VN";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get full address string.
|
||||
/// VI: Lấy chuỗi địa chỉ đầy đủ.
|
||||
/// </summary>
|
||||
public string FullAddress => $"{Street}, {Ward}, {District}, {City}".Trim(' ', ',');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Geographic location value object.
|
||||
/// VI: Value object vị trí địa lý.
|
||||
/// </summary>
|
||||
public record GeoLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Latitude coordinate.
|
||||
/// VI: Tọa độ vĩ độ.
|
||||
/// </summary>
|
||||
public double Latitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Longitude coordinate.
|
||||
/// VI: Tọa độ kinh độ.
|
||||
/// </summary>
|
||||
public double Longitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Default location (Ho Chi Minh City center).
|
||||
/// VI: Vị trí mặc định (trung tâm TP.HCM).
|
||||
/// </summary>
|
||||
public static GeoLocation Default => new() { Latitude = 10.7769, Longitude = 106.7009 };
|
||||
|
||||
/// <summary>
|
||||
/// EN: Calculate distance to another location in kilometers.
|
||||
/// VI: Tính khoảng cách đến vị trí khác tính bằng km.
|
||||
/// </summary>
|
||||
public double DistanceTo(GeoLocation other)
|
||||
{
|
||||
const double R = 6371; // Earth's radius in kilometers
|
||||
var lat1 = Latitude * Math.PI / 180;
|
||||
var lat2 = other.Latitude * Math.PI / 180;
|
||||
var deltaLat = (other.Latitude - Latitude) * Math.PI / 180;
|
||||
var deltaLon = (other.Longitude - Longitude) * Math.PI / 180;
|
||||
|
||||
var a = Math.Sin(deltaLat / 2) * Math.Sin(deltaLat / 2) +
|
||||
Math.Cos(lat1) * Math.Cos(lat2) *
|
||||
Math.Sin(deltaLon / 2) * Math.Sin(deltaLon / 2);
|
||||
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Operating hours value object.
|
||||
/// VI: Value object giờ hoạt động.
|
||||
/// </summary>
|
||||
public record OperatingHours
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Opening time.
|
||||
/// VI: Giờ mở cửa.
|
||||
/// </summary>
|
||||
public TimeOnly OpenTime { get; init; } = new(8, 0);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Closing time.
|
||||
/// VI: Giờ đóng cửa.
|
||||
/// </summary>
|
||||
public TimeOnly CloseTime { get; init; } = new(22, 0);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Days the shop is open.
|
||||
/// VI: Các ngày shop mở cửa.
|
||||
/// </summary>
|
||||
public List<DayOfWeek> OpenDays { get; init; } = new()
|
||||
{
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday,
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if currently open.
|
||||
/// VI: Kiểm tra có đang mở cửa không.
|
||||
/// </summary>
|
||||
public bool IsCurrentlyOpen()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var currentTime = TimeOnly.FromDateTime(now);
|
||||
var currentDay = now.DayOfWeek;
|
||||
|
||||
return OpenDays.Contains(currentDay) &&
|
||||
currentTime >= OpenTime &&
|
||||
currentTime <= CloseTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Default 24/7 operating hours.
|
||||
/// VI: Giờ hoạt động mặc định 24/7.
|
||||
/// </summary>
|
||||
public static OperatingHours TwentyFourSeven => new()
|
||||
{
|
||||
OpenTime = new TimeOnly(0, 0),
|
||||
CloseTime = new TimeOnly(23, 59)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// EN: Domain event when a new merchant is registered.
|
||||
// VI: Domain event khi merchant mới được đăng ký.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
namespace MerchantService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a new merchant registers.
|
||||
/// VI: Được phát ra khi merchant mới đăng ký.
|
||||
/// </summary>
|
||||
public record MerchantRegisteredDomainEvent(Merchant Merchant) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a merchant submits verification documents.
|
||||
/// VI: Được phát ra khi merchant nộp tài liệu xác minh.
|
||||
/// </summary>
|
||||
public record MerchantVerificationSubmittedDomainEvent(Merchant Merchant) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a merchant is approved by admin.
|
||||
/// VI: Được phát ra khi merchant được admin phê duyệt.
|
||||
/// </summary>
|
||||
public record MerchantApprovedDomainEvent(Merchant Merchant, Guid ApprovedBy) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a merchant is suspended.
|
||||
/// VI: Được phát ra khi merchant bị tạm ngưng.
|
||||
/// </summary>
|
||||
public record MerchantSuspendedDomainEvent(Merchant Merchant, string Reason) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a merchant is permanently banned.
|
||||
/// VI: Được phát ra khi merchant bị cấm vĩnh viễn.
|
||||
/// </summary>
|
||||
public record MerchantBannedDomainEvent(Merchant Merchant, string Reason) : INotification;
|
||||
@@ -0,0 +1,31 @@
|
||||
// EN: Domain events for Shop aggregate.
|
||||
// VI: Domain events cho Shop aggregate.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
namespace MerchantService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a new shop is created.
|
||||
/// VI: Được phát ra khi shop mới được tạo.
|
||||
/// </summary>
|
||||
public record ShopCreatedDomainEvent(Shop Shop) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a shop is published (made visible to customers).
|
||||
/// VI: Được phát ra khi shop được công khai (hiển thị với khách hàng).
|
||||
/// </summary>
|
||||
public record ShopPublishedDomainEvent(Shop Shop) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a shop is permanently closed.
|
||||
/// VI: Được phát ra khi shop đóng cửa vĩnh viễn.
|
||||
/// </summary>
|
||||
public record ShopClosedDomainEvent(Shop Shop) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a new branch is added to a shop.
|
||||
/// VI: Được phát ra khi chi nhánh mới được thêm vào shop.
|
||||
/// </summary>
|
||||
public record ShopBranchAddedDomainEvent(Shop Shop, ShopBranch Branch) : INotification;
|
||||
@@ -0,0 +1,43 @@
|
||||
// EN: Domain events for MerchantStaff aggregate.
|
||||
// VI: Domain events cho MerchantStaff aggregate.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
|
||||
namespace MerchantService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a staff member is invited.
|
||||
/// VI: Được phát ra khi nhân viên được mời.
|
||||
/// </summary>
|
||||
public record StaffInvitedDomainEvent(MerchantStaff Staff) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a staff member accepts invitation and joins.
|
||||
/// VI: Được phát ra khi nhân viên chấp nhận lời mời và gia nhập.
|
||||
/// </summary>
|
||||
public record StaffJoinedDomainEvent(MerchantStaff Staff) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a staff member sets their PIN code.
|
||||
/// VI: Được phát ra khi nhân viên đặt mã PIN.
|
||||
/// </summary>
|
||||
public record StaffPinCodeSetDomainEvent(MerchantStaff Staff) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a staff member registers a device.
|
||||
/// VI: Được phát ra khi nhân viên đăng ký thiết bị.
|
||||
/// </summary>
|
||||
public record StaffDeviceRegisteredDomainEvent(MerchantStaff Staff, DeviceToken Device) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a staff member is assigned to a shop.
|
||||
/// VI: Được phát ra khi nhân viên được gán vào shop.
|
||||
/// </summary>
|
||||
public record StaffAssignedToShopDomainEvent(MerchantStaff Staff, ShopMember Assignment) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Raised when a staff member's employment is terminated.
|
||||
/// VI: Được phát ra khi nhân viên bị chấm dứt hợp đồng.
|
||||
/// </summary>
|
||||
public record StaffTerminatedDomainEvent(MerchantStaff Staff) : INotification;
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MerchantService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base exception for domain errors.
|
||||
/// VI: Exception cơ sở cho các lỗi domain.
|
||||
/// </summary>
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public DomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MerchantService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exception for Sample aggregate domain errors.
|
||||
/// VI: Exception cho các lỗi domain của Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleDomainException : DomainException
|
||||
{
|
||||
public SampleDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MerchantService.Domain</AssemblyName>
|
||||
<RootNamespace>MerchantService.Domain</RootNamespace>
|
||||
<Description>Domain layer containing core business logic and entities</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: MediatR for domain events / VI: MediatR cho domain events -->
|
||||
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MerchantService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for all domain entities.
|
||||
/// VI: Lớp cơ sở cho tất cả các entity trong domain.
|
||||
/// </summary>
|
||||
public abstract class Entity
|
||||
{
|
||||
private int? _requestedHashCode;
|
||||
private Guid _id;
|
||||
private List<INotification> _domainEvents = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unique identifier for the entity.
|
||||
/// VI: Định danh duy nhất cho entity.
|
||||
/// </summary>
|
||||
public virtual Guid Id
|
||||
{
|
||||
get => _id;
|
||||
protected set => _id = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain events raised by this entity.
|
||||
/// VI: Các domain event được phát ra bởi entity này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a domain event to be dispatched.
|
||||
/// VI: Thêm một domain event để dispatch.
|
||||
/// </summary>
|
||||
public void AddDomainEvent(INotification eventItem)
|
||||
{
|
||||
_domainEvents.Add(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a domain event.
|
||||
/// VI: Xóa một domain event.
|
||||
/// </summary>
|
||||
public void RemoveDomainEvent(INotification eventItem)
|
||||
{
|
||||
_domainEvents.Remove(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Clear all domain events.
|
||||
/// VI: Xóa tất cả domain events.
|
||||
/// </summary>
|
||||
public void ClearDomainEvents()
|
||||
{
|
||||
_domainEvents.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if entity is transient (not persisted yet).
|
||||
/// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không.
|
||||
/// </summary>
|
||||
public bool IsTransient()
|
||||
{
|
||||
return Id == default;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not Entity item)
|
||||
return false;
|
||||
|
||||
if (ReferenceEquals(this, item))
|
||||
return true;
|
||||
|
||||
if (GetType() != item.GetType())
|
||||
return false;
|
||||
|
||||
if (item.IsTransient() || IsTransient())
|
||||
return false;
|
||||
|
||||
return item.Id == Id;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
if (IsTransient())
|
||||
return base.GetHashCode();
|
||||
|
||||
_requestedHashCode ??= Id.GetHashCode() ^ 31;
|
||||
return _requestedHashCode.Value;
|
||||
}
|
||||
|
||||
public static bool operator ==(Entity? left, Entity? right)
|
||||
{
|
||||
return left?.Equals(right) ?? right is null;
|
||||
}
|
||||
|
||||
public static bool operator !=(Entity? left, Entity? right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace MerchantService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for enumeration classes (type-safe enum pattern).
|
||||
/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This provides a type-safe alternative to enums with additional functionality
|
||||
/// like validation, parsing, and rich behavior.
|
||||
/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung
|
||||
/// như validation, parsing, và hành vi phong phú.
|
||||
/// </remarks>
|
||||
public abstract class Enumeration : IComparable
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The name of the enumeration value.
|
||||
/// VI: Tên của giá trị enumeration.
|
||||
/// </summary>
|
||||
public string Name { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: The unique identifier of the enumeration value.
|
||||
/// VI: Định danh duy nhất của giá trị enumeration.
|
||||
/// </summary>
|
||||
public int Id { get; private set; }
|
||||
|
||||
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all enumeration values of a given type.
|
||||
/// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước.
|
||||
/// </summary>
|
||||
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
|
||||
typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.Select(f => f.GetValue(null))
|
||||
.Cast<T>();
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not Enumeration otherValue)
|
||||
return false;
|
||||
|
||||
var typeMatches = GetType() == obj.GetType();
|
||||
var valueMatches = Id.Equals(otherValue.Id);
|
||||
|
||||
return typeMatches && valueMatches;
|
||||
}
|
||||
|
||||
public override int GetHashCode() => Id.GetHashCode();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get absolute difference between two enumeration values.
|
||||
/// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration.
|
||||
/// </summary>
|
||||
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
|
||||
{
|
||||
return Math.Abs(firstValue.Id - secondValue.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse an integer ID to the corresponding enumeration value.
|
||||
/// VI: Parse một ID integer thành giá trị enumeration tương ứng.
|
||||
/// </summary>
|
||||
public static T FromValue<T>(int value) where T : Enumeration
|
||||
{
|
||||
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse a display name to the corresponding enumeration value.
|
||||
/// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng.
|
||||
/// </summary>
|
||||
public static T FromDisplayName<T>(string displayName) where T : Enumeration
|
||||
{
|
||||
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
private static T Parse<T, TValue>(TValue value, string description, Func<T, bool> predicate) where T : Enumeration
|
||||
{
|
||||
var matchingItem = GetAll<T>().FirstOrDefault(predicate);
|
||||
|
||||
if (matchingItem is null)
|
||||
throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}");
|
||||
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MerchantService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Marker interface for aggregate roots.
|
||||
/// VI: Interface đánh dấu cho aggregate roots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Aggregate roots are the entry points to aggregates and are the only objects
|
||||
/// that outside code should hold references to.
|
||||
/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất
|
||||
/// mà code bên ngoài nên giữ tham chiếu đến.
|
||||
/// </remarks>
|
||||
public interface IAggregateRoot
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MerchantService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic repository interface for aggregate roots.
|
||||
/// VI: Interface repository generic cho aggregate roots.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">EN: The aggregate root type / VI: Kiểu aggregate root</typeparam>
|
||||
public interface IRepository<T> where T : IAggregateRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The unit of work for this repository.
|
||||
/// VI: Unit of work cho repository này.
|
||||
/// </summary>
|
||||
IUnitOfWork UnitOfWork { get; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MerchantService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of Work pattern interface.
|
||||
/// VI: Interface cho Unit of Work pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Maintains a list of objects affected by a business transaction
|
||||
/// and coordinates the writing out of changes.
|
||||
/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ
|
||||
/// và điều phối việc ghi các thay đổi.
|
||||
/// </remarks>
|
||||
public interface IUnitOfWork : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Save all changes made in this unit of work.
|
||||
/// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: Number of entities written / VI: Số entity đã ghi</returns>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save all changes and dispatch domain events.
|
||||
/// VI: Lưu tất cả thay đổi và dispatch domain events.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: True if successful / VI: True nếu thành công</returns>
|
||||
Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace MerchantService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for Value Objects following DDD patterns.
|
||||
/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Value objects are immutable and compared by their values, not identity.
|
||||
/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh.
|
||||
/// </remarks>
|
||||
public abstract class ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get the atomic values that make up this value object.
|
||||
/// VI: Lấy các giá trị nguyên tử tạo nên value object này.
|
||||
/// </summary>
|
||||
protected abstract IEnumerable<object?> GetEqualityComponents();
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is null || obj.GetType() != GetType())
|
||||
return false;
|
||||
|
||||
var other = (ValueObject)obj;
|
||||
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return GetEqualityComponents()
|
||||
.Select(x => x?.GetHashCode() ?? 0)
|
||||
.Aggregate((x, y) => x ^ y);
|
||||
}
|
||||
|
||||
public static bool operator ==(ValueObject? left, ValueObject? right)
|
||||
{
|
||||
return left?.Equals(right) ?? right is null;
|
||||
}
|
||||
|
||||
public static bool operator !=(ValueObject? left, ValueObject? right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a copy of this value object with modifications.
|
||||
/// VI: Tạo bản sao của value object này với các thay đổi.
|
||||
/// </summary>
|
||||
protected ValueObject GetCopy()
|
||||
{
|
||||
return (ValueObject)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Infrastructure.Idempotency;
|
||||
using MerchantService.Infrastructure.Repositories;
|
||||
|
||||
namespace MerchantService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Dependency injection extensions for Infrastructure layer.
|
||||
/// VI: Extensions dependency injection cho lớp Infrastructure.
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add infrastructure services to the DI container.
|
||||
/// VI: Thêm các services infrastructure vào DI container.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
|
||||
services.AddDbContext<MerchantServiceContext>(options =>
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException("Connection string not configured");
|
||||
|
||||
options.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(MerchantServiceContext).Assembly.FullName);
|
||||
npgsqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(30),
|
||||
errorCodesToAdd: null);
|
||||
});
|
||||
|
||||
// EN: Enable sensitive data logging in development only
|
||||
// VI: Chỉ bật sensitive data logging trong development
|
||||
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
|
||||
{
|
||||
options.EnableSensitiveDataLogging();
|
||||
options.EnableDetailedErrors();
|
||||
}
|
||||
});
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<IMerchantRepository, MerchantRepository>();
|
||||
services.AddScoped<IShopRepository, ShopRepository>();
|
||||
services.AddScoped<IMerchantStaffRepository, MerchantStaffRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MerchantService.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity for tracking client requests to ensure idempotency.
|
||||
/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency.
|
||||
/// </summary>
|
||||
public class ClientRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Unique request identifier.
|
||||
/// VI: Định danh request duy nhất.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Name of the command/request type.
|
||||
/// VI: Tên của loại command/request.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Timestamp when the request was received.
|
||||
/// VI: Thời điểm request được nhận.
|
||||
/// </summary>
|
||||
public DateTime Time { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MerchantService.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for managing client request idempotency.
|
||||
/// VI: Interface để quản lý idempotency của client requests.
|
||||
/// </summary>
|
||||
public interface IRequestManager
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Check if a request with the given ID exists.
|
||||
/// VI: Kiểm tra xem request với ID cho trước có tồn tại không.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Request ID / VI: ID của request</param>
|
||||
/// <returns>EN: True if exists / VI: True nếu tồn tại</returns>
|
||||
Task<bool> ExistAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new request record for tracking.
|
||||
/// VI: Tạo bản ghi request mới để theo dõi.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">EN: Command type / VI: Loại command</typeparam>
|
||||
/// <param name="id">EN: Request ID / VI: ID của request</param>
|
||||
Task CreateRequestForCommandAsync<T>(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MerchantService.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Implementation of request manager for idempotency.
|
||||
/// VI: Triển khai request manager cho idempotency.
|
||||
/// </summary>
|
||||
public class RequestManager : IRequestManager
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
|
||||
public RequestManager(MerchantServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> ExistAsync(Guid id)
|
||||
{
|
||||
var request = await _context
|
||||
.FindAsync<ClientRequest>(id);
|
||||
|
||||
return request != null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateRequestForCommandAsync<T>(Guid id)
|
||||
{
|
||||
var exists = await ExistAsync(id);
|
||||
|
||||
var request = exists
|
||||
? throw new InvalidOperationException($"Request with {id} already exists")
|
||||
: new ClientRequest
|
||||
{
|
||||
Id = id,
|
||||
Name = typeof(T).Name,
|
||||
Time = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Add(request);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MerchantService.Infrastructure</AssemblyName>
|
||||
<RootNamespace>MerchantService.Infrastructure</RootNamespace>
|
||||
<Description>Infrastructure layer for data access and external services</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: Entity Framework Core with PostgreSQL / VI: Entity Framework Core với PostgreSQL -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: MediatR for dispatching domain events / VI: MediatR để dispatch domain events -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
|
||||
<!-- EN: Dapper for read-optimized queries / VI: Dapper cho queries tối ưu đọc -->
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
|
||||
<!-- EN: Resilience with Polly / VI: Resilience với Polly -->
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
|
||||
<PackageReference Include="Polly" Version="8.5.0" />
|
||||
|
||||
<!-- EN: Redis cache / VI: Redis cache -->
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MerchantService.Domain\MerchantService.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,194 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core DbContext for MerchantService.
|
||||
/// VI: EF Core DbContext cho MerchantService.
|
||||
/// </summary>
|
||||
public class MerchantServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
#region DbSets
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchants table.
|
||||
/// VI: Bảng Merchants.
|
||||
/// </summary>
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shops table.
|
||||
/// VI: Bảng Shops.
|
||||
/// </summary>
|
||||
public DbSet<Shop> Shops => Set<Shop>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop branches table.
|
||||
/// VI: Bảng chi nhánh Shop.
|
||||
/// </summary>
|
||||
public DbSet<ShopBranch> ShopBranches => Set<ShopBranch>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant staff table.
|
||||
/// VI: Bảng nhân viên Merchant.
|
||||
/// </summary>
|
||||
public DbSet<MerchantStaff> MerchantStaff => Set<MerchantStaff>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop members table (staff-shop assignments).
|
||||
/// VI: Bảng shop member (gán nhân viên-shop).
|
||||
/// </summary>
|
||||
public DbSet<ShopMember> ShopMembers => Set<ShopMember>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Device tokens table.
|
||||
/// VI: Bảng device tokens.
|
||||
/// </summary>
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
/// VI: Truy cập chỉ đọc đến transaction hiện tại.
|
||||
/// </summary>
|
||||
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if there is an active transaction.
|
||||
/// VI: Kiểm tra xem có transaction đang hoạt động không.
|
||||
/// </summary>
|
||||
public bool HasActiveTransaction => _currentTransaction != null;
|
||||
|
||||
public MerchantServiceContext(DbContextOptions<MerchantServiceContext> options) : base(options)
|
||||
{
|
||||
_mediator = null!;
|
||||
}
|
||||
|
||||
public MerchantServiceContext(DbContextOptions<MerchantServiceContext> options, IMediator mediator) : base(options)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
|
||||
System.Diagnostics.Debug.WriteLine("MerchantServiceContext::ctor - " + GetHashCode());
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// EN: Apply entity configurations from this assembly
|
||||
// VI: Áp dụng các cấu hình entity từ assembly này
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MerchantServiceContext).Assembly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save entities and dispatch domain events.
|
||||
/// VI: Lưu entities và dispatch domain events.
|
||||
/// </summary>
|
||||
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// EN: Dispatch domain events before saving (side effects)
|
||||
// VI: Dispatch domain events trước khi lưu (side effects)
|
||||
await DispatchDomainEventsAsync();
|
||||
|
||||
// EN: Save changes to database
|
||||
// VI: Lưu thay đổi vào database
|
||||
await base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Begin a new transaction if none is active.
|
||||
/// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động.
|
||||
/// </summary>
|
||||
public async Task<IDbContextTransaction?> BeginTransactionAsync()
|
||||
{
|
||||
if (_currentTransaction != null) return null;
|
||||
|
||||
_currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);
|
||||
|
||||
return _currentTransaction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Commit the current transaction.
|
||||
/// VI: Commit transaction hiện tại.
|
||||
/// </summary>
|
||||
public async Task CommitTransactionAsync(IDbContextTransaction transaction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
if (transaction != _currentTransaction)
|
||||
throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current");
|
||||
|
||||
try
|
||||
{
|
||||
await SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
RollbackTransaction();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_currentTransaction != null)
|
||||
{
|
||||
_currentTransaction.Dispose();
|
||||
_currentTransaction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Rollback the current transaction.
|
||||
/// VI: Rollback transaction hiện tại.
|
||||
/// </summary>
|
||||
public void RollbackTransaction()
|
||||
{
|
||||
try
|
||||
{
|
||||
_currentTransaction?.Rollback();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_currentTransaction != null)
|
||||
{
|
||||
_currentTransaction.Dispose();
|
||||
_currentTransaction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Dispatch all domain events from tracked entities.
|
||||
/// VI: Dispatch tất cả domain events từ các entities đang được track.
|
||||
/// </summary>
|
||||
private async Task DispatchDomainEventsAsync()
|
||||
{
|
||||
var domainEntities = ChangeTracker
|
||||
.Entries<Entity>()
|
||||
.Where(x => x.Entity.DomainEvents.Any())
|
||||
.ToList();
|
||||
|
||||
var domainEvents = domainEntities
|
||||
.SelectMany(x => x.Entity.DomainEvents)
|
||||
.ToList();
|
||||
|
||||
domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents());
|
||||
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
await _mediator.Publish(domainEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// EN: Merchant repository implementation.
|
||||
// VI: Implementation repository cho Merchant.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Merchant aggregate.
|
||||
/// VI: Implementation repository cho Merchant aggregate.
|
||||
/// </summary>
|
||||
public class MerchantRepository : IMerchantRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public MerchantRepository(MerchantServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Merchant?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Merchant?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Merchants
|
||||
.FirstOrDefaultAsync(m => m.UserId == userId && !m.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ExistsByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Merchants
|
||||
.AnyAsync(m => m.UserId == userId && !m.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Merchant>> GetAllAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Merchants
|
||||
.Where(m => !m.IsDeleted)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Merchant>> GetByStatusAsync(MerchantStatus status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Merchants
|
||||
.Where(m => m.StatusId == status.Id && !m.IsDeleted)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Merchant Add(Merchant merchant)
|
||||
{
|
||||
return _context.Merchants.Add(merchant).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Merchant merchant)
|
||||
{
|
||||
_context.Entry(merchant).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// EN: MerchantStaff repository implementation.
|
||||
// VI: Implementation repository cho MerchantStaff.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for MerchantStaff aggregate.
|
||||
/// VI: Implementation repository cho MerchantStaff aggregate.
|
||||
/// </summary>
|
||||
public class MerchantStaffRepository : IMerchantStaffRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public MerchantStaffRepository(MerchantServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantStaff?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.MerchantStaff
|
||||
.Include(s => s.ShopAssignments)
|
||||
.Include(s => s.DeviceTokens)
|
||||
.FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantStaff?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.MerchantStaff
|
||||
.Include(s => s.ShopAssignments)
|
||||
.FirstOrDefaultAsync(s => s.UserId == userId && s.StatusId != StaffStatus.Terminated.Id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantStaff?> GetByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.MerchantStaff
|
||||
.Include(s => s.ShopAssignments)
|
||||
.FirstOrDefaultAsync(s => s.UserId == userId && s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantStaff>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.MerchantStaff
|
||||
.Where(s => s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id)
|
||||
.OrderByDescending(s => s.JoinedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantStaff>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.MerchantStaff
|
||||
.Where(s => s.ShopAssignments.Any(a => a.ShopId == shopId) && s.StatusId != StaffStatus.Terminated.Id)
|
||||
.Include(s => s.ShopAssignments.Where(a => a.ShopId == shopId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ExistsByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.MerchantStaff
|
||||
.AnyAsync(s => s.UserId == userId && s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MerchantStaff Add(MerchantStaff staff)
|
||||
{
|
||||
return _context.MerchantStaff.Add(staff).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(MerchantStaff staff)
|
||||
{
|
||||
_context.Entry(staff).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// EN: Shop repository implementation.
|
||||
// VI: Implementation repository cho Shop.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Shop aggregate.
|
||||
/// VI: Implementation repository cho Shop aggregate.
|
||||
/// </summary>
|
||||
public class ShopRepository : IShopRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public ShopRepository(MerchantServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Shop?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Shops
|
||||
.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Shop?> GetByIdWithBranchesAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Shops
|
||||
.Include(s => s.Branches)
|
||||
.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Shop?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Shops
|
||||
.FirstOrDefaultAsync(s => s.Slug == slug.ToLowerInvariant() && !s.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Shops
|
||||
.AnyAsync(s => s.Slug == slug.ToLowerInvariant(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Shop>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Shops
|
||||
.Where(s => s.MerchantId == merchantId && !s.IsDeleted)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Shop>> GetByCategoryAsync(BusinessCategory category, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Shops
|
||||
.Where(s => s.CategoryId == category.Id && !s.IsDeleted && s.StatusId == ShopStatus.Active.Id)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Shop Add(Shop shop)
|
||||
{
|
||||
return _context.Shops.Add(shop).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Shop shop)
|
||||
{
|
||||
_context.Entry(shop).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MerchantService.Infrastructure;
|
||||
|
||||
namespace MerchantService.FunctionalTests;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom WebApplicationFactory for functional tests.
|
||||
/// VI: WebApplicationFactory tùy chỉnh cho functional tests.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// EN: Remove the existing DbContext registration
|
||||
// VI: Xóa đăng ký DbContext hiện tại
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<MerchantServiceContext>));
|
||||
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// EN: Remove DbContext service
|
||||
// VI: Xóa DbContext service
|
||||
var dbContextDescriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(MerchantServiceContext));
|
||||
|
||||
if (dbContextDescriptor != null)
|
||||
{
|
||||
services.Remove(dbContextDescriptor);
|
||||
}
|
||||
|
||||
// EN: Add in-memory database for testing
|
||||
// VI: Thêm in-memory database để test
|
||||
services.AddDbContext<MerchantServiceContext>(options =>
|
||||
{
|
||||
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
|
||||
});
|
||||
|
||||
// EN: Ensure database is created with seed data
|
||||
// VI: Đảm bảo database được tạo với seed data
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MerchantServiceContext>();
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user