feat: Implement core ad serving functionality with auction, pacing, and frequency capping, and initialize catalog service infrastructure, while removing the sample aggregate.
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change status of a Sample.
|
||||
/// VI: Command để thay đổi trạng thái của Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
|
||||
public record ChangeSampleStatusCommand(
|
||||
Guid SampleId,
|
||||
string NewStatus
|
||||
) : IRequest<bool>;
|
||||
@@ -1,70 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ChangeSampleStatusCommand.
|
||||
/// VI: Handler cho ChangeSampleStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
|
||||
|
||||
public ChangeSampleStatusCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<ChangeSampleStatusCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
ChangeSampleStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
|
||||
switch (request.NewStatus.ToLowerInvariant())
|
||||
{
|
||||
case "activate":
|
||||
sample.Activate();
|
||||
break;
|
||||
case "complete":
|
||||
sample.Complete();
|
||||
break;
|
||||
case "cancel":
|
||||
sample.Cancel();
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
|
||||
request.NewStatus);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Sample.
|
||||
/// VI: Command để tạo một Sample mới.
|
||||
/// </summary>
|
||||
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public record CreateSampleCommand(
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<CreateSampleCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateSampleCommand.
|
||||
/// VI: Kết quả của CreateSampleCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
|
||||
public record CreateSampleCommandResult(Guid Id);
|
||||
@@ -1,46 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateSampleCommand.
|
||||
/// VI: Handler cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<CreateSampleCommandHandler> _logger;
|
||||
|
||||
public CreateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<CreateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateSampleCommandResult> Handle(
|
||||
CreateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
|
||||
request.Name);
|
||||
|
||||
// EN: Create domain entity / VI: Tạo domain entity
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
|
||||
// EN: Add to repository / VI: Thêm vào repository
|
||||
_sampleRepository.Add(sample);
|
||||
|
||||
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
|
||||
sample.Id);
|
||||
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a Sample.
|
||||
/// VI: Command để xóa một Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
|
||||
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteSampleCommand.
|
||||
/// VI: Handler cho DeleteSampleCommand.
|
||||
/// </summary>
|
||||
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<DeleteSampleCommandHandler> _logger;
|
||||
|
||||
public DeleteSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<DeleteSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
DeleteSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleting sample {SampleId} / Xóa sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Delete sample / VI: Xóa sample
|
||||
_sampleRepository.Delete(sample);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing Sample.
|
||||
/// VI: Command để cập nhật một Sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
|
||||
/// <param name="Name">EN: New name / VI: Tên mới</param>
|
||||
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
|
||||
public record UpdateSampleCommand(
|
||||
Guid SampleId,
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateSampleCommand.
|
||||
/// VI: Handler cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<UpdateSampleCommandHandler> _logger;
|
||||
|
||||
public UpdateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<UpdateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
UpdateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating sample {SampleId} / Cập nhật sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
|
||||
sample.Update(request.Name, request.Description);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Sample by ID.
|
||||
/// VI: Query để lấy một Sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample view model for API responses.
|
||||
/// VI: Sample view model cho API responses.
|
||||
/// </summary>
|
||||
public record SampleViewModel(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSampleQuery.
|
||||
/// VI: Handler cho GetSampleQuery.
|
||||
/// </summary>
|
||||
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSampleQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<SampleViewModel?> Handle(
|
||||
GetSampleQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all Samples.
|
||||
/// VI: Query để lấy tất cả Samples.
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;
|
||||
@@ -1,34 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSamplesQuery.
|
||||
/// VI: Handler cho GetSamplesQuery.
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SampleViewModel>> Handle(
|
||||
GetSamplesQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = await _sampleRepository.GetAllAsync();
|
||||
|
||||
return samples.Select(sample => new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to serve an ad based on user context and placement.
|
||||
/// VI: Query để serve quảng cáo dựa trên ngữ cảnh người dùng và vị trí.
|
||||
/// </summary>
|
||||
public record ServeAdQuery : IRequest<ServedAdDto?>
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public string PlacementType { get; init; } = null!; // "feed", "story", "banner"
|
||||
public Dictionary<string, string> UserContext { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for served ad response.
|
||||
/// VI: DTO cho response quảng cáo được serve.
|
||||
/// </summary>
|
||||
public record ServedAdDto
|
||||
{
|
||||
public Guid AdId { get; init; }
|
||||
public Guid CampaignId { get; init; }
|
||||
public string AdFormat { get; init; } = null!;
|
||||
public string? Headline { get; init; }
|
||||
public string? PrimaryText { get; init; }
|
||||
public string? CallToAction { get; init; }
|
||||
public string? CreativeUrl { get; init; }
|
||||
public string? DestinationUrl { get; init; }
|
||||
public decimal FinalPrice { get; init; }
|
||||
public DateTime ServedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using AdsServingService.Domain.AggregatesModel.AuctionAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ServeAdQuery - performs real-time bidding auction.
|
||||
/// VI: Handler cho ServeAdQuery - thực hiện đấu giá thời gian thực.
|
||||
/// </summary>
|
||||
public class ServeAdQueryHandler : IRequestHandler<ServeAdQuery, ServedAdDto?>
|
||||
{
|
||||
private readonly ILogger<ServeAdQueryHandler> _logger;
|
||||
|
||||
public ServeAdQueryHandler(ILogger<ServeAdQueryHandler> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ServedAdDto?> Handle(ServeAdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Step 1 - Fetch eligible ads from cache/database
|
||||
// For now, mock data for demonstration
|
||||
var eligibleAds = GetMockEligibleAds(request.PlacementType);
|
||||
|
||||
if (!eligibleAds.Any())
|
||||
{
|
||||
_logger.LogWarning("No eligible ads for placement {PlacementType}", request.PlacementType);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2 - Create auction
|
||||
var auction = new Auction(request.UserId, request.PlacementType);
|
||||
|
||||
// Step 3 - Add bids for each eligible ad
|
||||
foreach (var ad in eligibleAds)
|
||||
{
|
||||
// TODO: Get actual bid amount, CTR prediction, quality score
|
||||
var bidAmount = ad.BidAmount;
|
||||
var predictedCTR = 0.02m; // 2% CTR (mock)
|
||||
var qualityScore = 1.0m; // Perfect quality (mock)
|
||||
|
||||
auction.AddBid(ad.AdId, ad.CampaignId, bidAmount, predictedCTR, qualityScore);
|
||||
}
|
||||
|
||||
// Step 4 - Run auction
|
||||
auction.RunAuction();
|
||||
|
||||
if (auction.Result == null)
|
||||
{
|
||||
_logger.LogWarning("Auction completed but no winner for user {UserId}", request.UserId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 5 - Get winning ad details
|
||||
var winningAd = eligibleAds.First(a => a.AdId == auction.Result.WinningAdId);
|
||||
|
||||
// TODO: Step 6 - Fire async events (impression tracking, billing)
|
||||
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
_logger.LogInformation(
|
||||
"Ad served in {Elapsed}ms - AdId: {AdId}, eCPM: {eCPM}, Price: {Price}",
|
||||
elapsed, winningAd.AdId, auction.Result.WinningeCPM, auction.Result.FinalPrice);
|
||||
|
||||
return new ServedAdDto
|
||||
{
|
||||
AdId = winningAd.AdId,
|
||||
CampaignId = winningAd.CampaignId,
|
||||
AdFormat = winningAd.Format,
|
||||
Headline = winningAd.Headline,
|
||||
PrimaryText = winningAd.PrimaryText,
|
||||
CallToAction = winningAd.CallToAction,
|
||||
CreativeUrl = winningAd.CreativeUrl,
|
||||
DestinationUrl = winningAd.DestinationUrl,
|
||||
FinalPrice = auction.Result.FinalPrice,
|
||||
ServedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error serving ad for user {UserId}", request.UserId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock data for demonstration
|
||||
private List<MockAdData> GetMockEligibleAds(string placementType)
|
||||
{
|
||||
return new List<MockAdData>
|
||||
{
|
||||
new MockAdData
|
||||
{
|
||||
AdId = Guid.NewGuid(),
|
||||
CampaignId = Guid.NewGuid(),
|
||||
BidAmount = 5000, // 5000 VND CPC
|
||||
Format = "single_image",
|
||||
Headline = "Amazing Product - Limited Offer!",
|
||||
PrimaryText = "Get 50% off today only",
|
||||
CallToAction = "Shop Now",
|
||||
CreativeUrl = "https://example.com/creative.jpg",
|
||||
DestinationUrl = "https://example.com/product"
|
||||
},
|
||||
new MockAdData
|
||||
{
|
||||
AdId = Guid.NewGuid(),
|
||||
CampaignId = Guid.NewGuid(),
|
||||
BidAmount = 4500,
|
||||
Format = "single_image",
|
||||
Headline = "Summer Sale",
|
||||
PrimaryText = "Up to 70% discount",
|
||||
CallToAction = "Learn More",
|
||||
CreativeUrl = "https://example.com/creative2.jpg",
|
||||
DestinationUrl = "https://example.com/sale"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal record MockAdData
|
||||
{
|
||||
public Guid AdId { get; init; }
|
||||
public Guid CampaignId { get; init; }
|
||||
public decimal BidAmount { get; init; }
|
||||
public string Format { get; init; } = null!;
|
||||
public string? Headline { get; init; }
|
||||
public string? PrimaryText { get; init; }
|
||||
public string? CallToAction { get; init; }
|
||||
public string? CreativeUrl { get; init; }
|
||||
public string? DestinationUrl { get; init; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using FluentValidation;
|
||||
using AdsServingService.API.Application.Commands;
|
||||
|
||||
namespace AdsServingService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateSampleCommand.
|
||||
/// VI: Validator cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
public CreateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using FluentValidation;
|
||||
using AdsServingService.API.Application.Commands;
|
||||
|
||||
namespace AdsServingService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateSampleCommand.
|
||||
/// VI: Validator cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
|
||||
{
|
||||
public UpdateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SampleId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sample ID is required / ID sample là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using AdsServingService.API.Application.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsServingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for serving ads in real-time.
|
||||
/// VI: API Controller serve quảng cáo theo thời gian thực.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/ads")]
|
||||
[Produces("application/json")]
|
||||
public class AdsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<AdsController> _logger;
|
||||
|
||||
public AdsController(IMediator mediator, ILogger<AdsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Serve an ad based on user context (< 100ms target).
|
||||
/// VI: Serve quảng cáo dựa trên ngữ cảnh người dùng (mục tiêu < 100ms).
|
||||
/// </summary>
|
||||
[HttpPost("serve")]
|
||||
[ProducesResponseType(typeof(ServedAdDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult<ServedAdDto>> ServeAd([FromBody] ServeAdRequest request)
|
||||
{
|
||||
var query = new ServeAdQuery
|
||||
{
|
||||
UserId = request.UserId,
|
||||
PlacementType = request.PlacementType,
|
||||
UserContext = request.UserContext ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var result = await _mediator.Send(query);
|
||||
|
||||
if (result == null)
|
||||
return NoContent();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Track ad impression (fire-and-forget).
|
||||
/// VI: Track impression quảng cáo (fire-and-forget).
|
||||
/// </summary>
|
||||
[HttpPost("events/impression")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public IActionResult TrackImpression([FromBody] ImpressionEvent impressionEvent)
|
||||
{
|
||||
_logger.LogInformation("Impression tracked for Ad {AdId}", impressionEvent.AdId);
|
||||
|
||||
// TODO: Publish to RabbitMQ for async processing
|
||||
// - ads-billing (charge advertiser)
|
||||
// - ads-tracking (attribution)
|
||||
// - ads-analytics (metrics)
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Track ad click (fire-and-forget).
|
||||
/// VI: Track click quảng cáo (fire-and-forget).
|
||||
/// </summary>
|
||||
[HttpPost("events/click")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public IActionResult TrackClick([FromBody] ClickEvent clickEvent)
|
||||
{
|
||||
_logger.LogInformation("Click tracked for Ad {AdId}", clickEvent.AdId);
|
||||
|
||||
// TODO: Publish to RabbitMQ for async processing
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
|
||||
public record ServeAdRequest
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public string PlacementType { get; init; } = "feed";
|
||||
public Dictionary<string, string>? UserContext { get; init; }
|
||||
}
|
||||
|
||||
public record ImpressionEvent
|
||||
{
|
||||
public Guid AdId { get; init; }
|
||||
public Guid UserId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public record ClickEvent
|
||||
{
|
||||
public Guid AdId { get; init; }
|
||||
public Guid UserId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdsServingService.API.Application.Commands;
|
||||
using AdsServingService.API.Application.Queries;
|
||||
|
||||
namespace AdsServingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Sample CRUD operations using CQRS pattern.
|
||||
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class SamplesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SamplesController> _logger;
|
||||
|
||||
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSamples()
|
||||
{
|
||||
var samples = await _mediator.Send(new GetSamplesQuery());
|
||||
return Ok(new { success = true, data = samples });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a sample by ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSample(Guid id)
|
||||
{
|
||||
var sample = await _mediator.Send(new GetSampleQuery(id));
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = sample });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new sample.
|
||||
/// VI: Tạo một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Create request / VI: Request tạo</param>
|
||||
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateSample([FromBody] CreateSampleRequest request)
|
||||
{
|
||||
var command = new CreateSampleCommand(request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSample),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
|
||||
{
|
||||
var command = new UpdateSampleCommand(id, request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSample(Guid id)
|
||||
{
|
||||
var command = new DeleteSampleCommand(id);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change sample status.
|
||||
/// VI: Thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
|
||||
{
|
||||
var command = new ChangeSampleStatusCommand(id, request.Status);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "STATUS_CHANGE_FAILED",
|
||||
message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for creating a sample.
|
||||
/// VI: Model request để tạo sample.
|
||||
/// </summary>
|
||||
public record CreateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating a sample.
|
||||
/// VI: Model request để cập nhật sample.
|
||||
/// </summary>
|
||||
public record UpdateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for changing sample status.
|
||||
/// VI: Model request để thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
public record ChangeStatusRequest(string Status);
|
||||
@@ -0,0 +1,140 @@
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Domain.AggregatesModel.AuctionAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Auction aggregate root - represents a single real-time bidding auction.
|
||||
/// VI: Auction aggregate root - đại diện cho một phiên đấu giá thời gian thực.
|
||||
/// </summary>
|
||||
public class Auction : Entity, IAggregateRoot
|
||||
{
|
||||
private List<Bid> _bids = new();
|
||||
private AuctionResult? _result;
|
||||
private DateTime _auctionTime;
|
||||
|
||||
/// <summary>
|
||||
/// EN: User context for this auction.
|
||||
/// VI: Ngữ cảnh người dùng cho phiên đấu giá này.
|
||||
/// </summary>
|
||||
public Guid UserId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ad placement type (feed, story, banner, etc.).
|
||||
/// VI: Loại vị trí quảng cáo (feed, story, banner, v.v.).
|
||||
/// </summary>
|
||||
public string PlacementType { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: All bids submitted for this auction.
|
||||
/// VI: Tất cả bid được submit cho phiên đấu giá này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Bid> Bids => _bids.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Auction result (winner and final price).
|
||||
/// VI: Kết quả đấu giá (người thắng và giá cuối).
|
||||
/// </summary>
|
||||
public AuctionResult? Result => _result;
|
||||
|
||||
public DateTime AuctionTime => _auctionTime;
|
||||
|
||||
protected Auction() { }
|
||||
|
||||
public Auction(Guid userId, string placementType)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
UserId = userId;
|
||||
PlacementType = placementType;
|
||||
_auctionTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a bid to the auction.
|
||||
/// VI: Thêm bid vào phiên đấu giá.
|
||||
/// </summary>
|
||||
public void AddBid(Guid adId, Guid campaignId, decimal bidAmount, decimal predictedCTR, decimal qualityScore)
|
||||
{
|
||||
var bid = new Bid(adId, campaignId, bidAmount, predictedCTR, qualityScore);
|
||||
_bids.Add(bid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Run the auction and determine the winner based on eCPM.
|
||||
/// VI: Chạy đấu giá và xác định người thắng dựa trên eCPM.
|
||||
/// </summary>
|
||||
public void RunAuction()
|
||||
{
|
||||
if (_bids.Count == 0)
|
||||
return;
|
||||
|
||||
// Sort bids by eCPM descending
|
||||
var sortedBids = _bids.OrderByDescending(b => b.eCPM).ToList();
|
||||
var winningBid = sortedBids[0];
|
||||
|
||||
// Second-price auction: winner pays the second-highest eCPM + $0.01
|
||||
var finalPrice = sortedBids.Count > 1
|
||||
? sortedBids[1].eCPM + 0.01m
|
||||
: winningBid.BidAmount;
|
||||
|
||||
_result = new AuctionResult(winningBid.AdId, winningBid.CampaignId, finalPrice, winningBid.eCPM);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Bid entity - represents a single bid in the auction.
|
||||
/// VI: Bid entity - đại diện cho một bid trong phiên đấu giá.
|
||||
/// </summary>
|
||||
public class Bid : Entity
|
||||
{
|
||||
public Guid AdId { get; private set; }
|
||||
public Guid CampaignId { get; private set; }
|
||||
public decimal BidAmount { get; private set; }
|
||||
public decimal PredictedCTR { get; private set; }
|
||||
public decimal QualityScore { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Effective CPM (eCPM) = Bid × Predicted CTR × Quality Score.
|
||||
/// VI: CPM hiệu quả (eCPM) = Bid × CTR dự đoán × Điểm chất lượng.
|
||||
/// </summary>
|
||||
public decimal eCPM => BidAmount * PredictedCTR * QualityScore;
|
||||
|
||||
protected Bid() { }
|
||||
|
||||
public Bid(Guid adId, Guid campaignId, decimal bidAmount, decimal predictedCTR, decimal qualityScore)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
AdId = adId;
|
||||
CampaignId = campaignId;
|
||||
BidAmount = bidAmount;
|
||||
PredictedCTR = predictedCTR;
|
||||
QualityScore = qualityScore;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Auction result value object.
|
||||
/// VI: Value object kết quả đấu giá.
|
||||
/// </summary>
|
||||
public class AuctionResult : ValueObject
|
||||
{
|
||||
public Guid WinningAdId { get; private set; }
|
||||
public Guid WinningCampaignId { get; private set; }
|
||||
public decimal FinalPrice { get; private set; }
|
||||
public decimal WinningeCPM { get; private set; }
|
||||
|
||||
protected AuctionResult() { }
|
||||
|
||||
public AuctionResult(Guid winningAdId, Guid winningCampaignId, decimal finalPrice, decimal winningeCPM)
|
||||
{
|
||||
WinningAdId = winningAdId;
|
||||
WinningCampaignId = winningCampaignId;
|
||||
FinalPrice = finalPrice;
|
||||
WinningeCPM = winningeCPM;
|
||||
}
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return WinningAdId;
|
||||
yield return FinalPrice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using AdsServingService.Domain.Exceptions;
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Domain.AggregatesModel.FrequencyAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Frequency cap - limits how often a user sees the same ad.
|
||||
/// VI: Frequency cap - giới hạn tần suất người dùng nhìn thấy cùng một quảng cáo.
|
||||
/// </summary>
|
||||
public class FrequencyCap : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _adId;
|
||||
private int _maxImpressionsPerUser;
|
||||
private FrequencyWindow _window;
|
||||
|
||||
public Guid AdId => _adId;
|
||||
public int MaxImpressionsPerUser => _maxImpressionsPerUser;
|
||||
public FrequencyWindow Window => _window;
|
||||
|
||||
protected FrequencyCap() { }
|
||||
|
||||
public FrequencyCap(Guid adId, int maxImpressionsPerUser, FrequencyWindow window)
|
||||
{
|
||||
if (maxImpressionsPerUser <= 0)
|
||||
throw new AdsServingDomainException("Max impressions must be greater than zero");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_adId = adId;
|
||||
_maxImpressionsPerUser = maxImpressionsPerUser;
|
||||
_window = window;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if user has reached frequency cap.
|
||||
/// VI: Kiểm tra xem người dùng đã đạt tới frequency cap chưa.
|
||||
/// </summary>
|
||||
public bool IsUserCapped(int currentImpressions)
|
||||
{
|
||||
return currentImpressions >= _maxImpressionsPerUser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a daily frequency cap (most common).
|
||||
/// VI: Tạo frequency cap hàng ngày (phổ biến nhất).
|
||||
/// </summary>
|
||||
public static FrequencyCap Daily(Guid adId, int maxImpressions) =>
|
||||
new(adId, maxImpressions, FrequencyWindow.Day);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Frequency window enumeration.
|
||||
/// VI: Enum cửa sổ tần suất.
|
||||
/// </summary>
|
||||
public enum FrequencyWindow
|
||||
{
|
||||
Hour = 1,
|
||||
Day = 2,
|
||||
Week = 3,
|
||||
Month = 4,
|
||||
Lifetime = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: User ad history - tracks impressions per user (stored in Redis).
|
||||
/// VI: Lịch sử quảng cáo người dùng - theo dõi impressions cho mỗi user (lưu trong Redis).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a lightweight entity meant to be cached in Redis.
|
||||
/// Key format: freq:{userId}:{date}
|
||||
/// Value: Hash of adId -> impressionCount
|
||||
/// </remarks>
|
||||
public class UserAdHistory : ValueObject
|
||||
{
|
||||
public Guid UserId { get; private set; }
|
||||
public Dictionary<Guid, int> AdImpressionCounts { get; private set; } = new();
|
||||
public DateTime Date { get; private set; }
|
||||
|
||||
protected UserAdHistory() { }
|
||||
|
||||
public UserAdHistory(Guid userId, DateTime date)
|
||||
{
|
||||
UserId = userId;
|
||||
Date = date.Date; // Normalize to start of day
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Increment impression count for an ad.
|
||||
/// VI: Tăng số lần impression cho một quảng cáo.
|
||||
/// </summary>
|
||||
public void RecordImpression(Guid adId)
|
||||
{
|
||||
if (AdImpressionCounts.ContainsKey(adId))
|
||||
AdImpressionCounts[adId]++;
|
||||
else
|
||||
AdImpressionCounts[adId] = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get impression count for an ad.
|
||||
/// VI: Lấy số lần impression cho một quảng cáo.
|
||||
/// </summary>
|
||||
public int GetImpressionCount(Guid adId)
|
||||
{
|
||||
return AdImpressionCounts.TryGetValue(adId, out var count) ? count : 0;
|
||||
}
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return UserId;
|
||||
yield return Date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using AdsServingService.Domain.Exceptions;
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Domain.AggregatesModel.PacingAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Budget pacer - controls ad spend rate to last throughout the day.
|
||||
/// VI: Budget pacer - điều khiển tốc độ chi tiêu quảng cáo để kéo dài cả ngày.
|
||||
/// </summary>
|
||||
public class BudgetPacer : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _campaignId;
|
||||
private decimal _dailyBudget;
|
||||
private decimal _spentToday;
|
||||
private PacingStrategy _strategy;
|
||||
private DateTime _lastUpdated;
|
||||
|
||||
public Guid CampaignId => _campaignId;
|
||||
public decimal DailyBudget => _dailyBudget;
|
||||
public decimal SpentToday => _spentToday;
|
||||
public PacingStrategy Strategy => _strategy;
|
||||
public DateTime LastUpdated => _lastUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remaining budget for today.
|
||||
/// VI: Ngân sách còn lại trong ngày.
|
||||
/// </summary>
|
||||
public decimal RemainingBudget => _dailyBudget - _spentToday;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Budget utilization percentage.
|
||||
/// VI: Tỷ lệ phần trăm sử dụng ngân sách.
|
||||
/// </summary>
|
||||
public decimal UtilizationPercent => _dailyBudget > 0 ? (_spentToday / _dailyBudget) * 100 : 0;
|
||||
|
||||
protected BudgetPacer() { }
|
||||
|
||||
public BudgetPacer(Guid campaignId, decimal dailyBudget, PacingStrategy strategy)
|
||||
{
|
||||
if (dailyBudget <= 0)
|
||||
throw new AdsServingDomainException("Daily budget must be greater than zero");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_campaignId = campaignId;
|
||||
_dailyBudget = dailyBudget;
|
||||
_strategy = strategy;
|
||||
_spentToday = 0;
|
||||
_lastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if campaign can serve an ad based on pacing strategy.
|
||||
/// VI: Kiểm tra xem chiến dịch có thể hiển thị quảng cáo dựa trên chiến lược pacing không.
|
||||
/// </summary>
|
||||
public bool CanServeAd(decimal estimatedCost)
|
||||
{
|
||||
if (_spentToday + estimatedCost > _dailyBudget)
|
||||
return false;
|
||||
|
||||
if (_strategy == PacingStrategy.Smooth)
|
||||
{
|
||||
// Smooth pacing: slow down as budget depletes
|
||||
var remainingHoursInDay = (24 - DateTime.UtcNow.Hour);
|
||||
var hourlyBudget = _dailyBudget / 24;
|
||||
var allowedSpendThisHour = hourlyBudget * 1.2m; // 20% buffer
|
||||
|
||||
return _spentToday + estimatedCost <= (DateTime.UtcNow.Hour * hourlyBudget + allowedSpendThisHour);
|
||||
}
|
||||
|
||||
// Accelerated: spend as fast as possible
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Record a spend transaction.
|
||||
/// VI: Ghi nhận một giao dịch chi tiêu.
|
||||
/// </summary>
|
||||
public void RecordSpend(decimal amount)
|
||||
{
|
||||
if (amount < 0)
|
||||
throw new AdsServingDomainException("Spend amount cannot be negative");
|
||||
|
||||
_spentToday += amount;
|
||||
_lastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reset daily spend (called at start of new day).
|
||||
/// VI: Reset chi tiêu hàng ngày (gọi vào đầu ngày mới).
|
||||
/// </summary>
|
||||
public void ResetDailySpend()
|
||||
{
|
||||
_spentToday = 0;
|
||||
_lastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pacing strategy enumeration.
|
||||
/// VI: Enum chiến lược pacing.
|
||||
/// </summary>
|
||||
public enum PacingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Smooth pacing - spread evenly throughout the day.
|
||||
/// VI: Pacing mượt - phân bổ đều trong ngày.
|
||||
/// </summary>
|
||||
Smooth = 1,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Accelerated - spend as fast as possible.
|
||||
/// VI: Tăng tốc - chi tiêu nhanh nhất có thể.
|
||||
/// </summary>
|
||||
Accelerated = 2
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Sample aggregate.
|
||||
/// VI: Interface repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Following repository pattern, this interface defines the contract
|
||||
/// for data access operations on Sample aggregate.
|
||||
/// VI: Theo pattern repository, interface này định nghĩa contract
|
||||
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
|
||||
/// </remarks>
|
||||
public interface ISampleRepository : IRepository<Sample>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a sample by its ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
|
||||
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
|
||||
Task<Sample?> GetAsync(Guid sampleId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
Task<IEnumerable<Sample>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new sample.
|
||||
/// VI: Thêm một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
|
||||
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
|
||||
Sample Add(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
|
||||
void Update(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
|
||||
void Delete(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get samples by status.
|
||||
/// VI: Lấy samples theo trạng thái.
|
||||
/// </summary>
|
||||
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
|
||||
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
|
||||
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using AdsServingService.Domain.Events;
|
||||
using AdsServingService.Domain.Exceptions;
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample aggregate root demonstrating DDD patterns.
|
||||
/// VI: Sample aggregate root minh họa các pattern DDD.
|
||||
/// </summary>
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private string _name = null!;
|
||||
private string? _description;
|
||||
private SampleStatus _status = null!;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample name (required).
|
||||
/// VI: Tên sample (bắt buộc).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Optional description.
|
||||
/// VI: Mô tả tùy chọn.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: ID trạng thái cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { 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: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Sample()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new Sample with required information.
|
||||
/// VI: Tạo một Sample mới với thông tin bắt buộc.
|
||||
/// </summary>
|
||||
/// <param name="name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public Sample(string name, string? description = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_name = name;
|
||||
_description = description;
|
||||
_status = SampleStatus.Draft;
|
||||
StatusId = SampleStatus.Draft.Id;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
// EN: Add domain event for creation
|
||||
// VI: Thêm domain event cho việc tạo
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update sample information.
|
||||
/// VI: Cập nhật thông tin sample.
|
||||
/// </summary>
|
||||
public void Update(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Cannot update a cancelled sample");
|
||||
|
||||
_name = name;
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the sample.
|
||||
/// VI: Kích hoạt sample.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Active;
|
||||
StatusId = SampleStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the sample.
|
||||
/// VI: Hoàn thành sample.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_status != SampleStatus.Active)
|
||||
throw new SampleDomainException("Only active samples can be completed");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Completed;
|
||||
StatusId = SampleStatus.Completed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the sample.
|
||||
/// VI: Hủy sample.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_status == SampleStatus.Completed)
|
||||
throw new SampleDomainException("Cannot cancel a completed sample");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Sample is already cancelled");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Cancelled;
|
||||
StatusId = SampleStatus.Cancelled.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample status enumeration following type-safe enum pattern.
|
||||
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
|
||||
/// </summary>
|
||||
public class SampleStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Draft status - initial state
|
||||
/// VI: Trạng thái nháp - trạng thái ban đầu
|
||||
/// </summary>
|
||||
public static SampleStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active status - ready for use
|
||||
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
|
||||
/// </summary>
|
||||
public static SampleStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Completed status - finished processing
|
||||
/// VI: Trạng thái hoàn thành - đã xử lý xong
|
||||
/// </summary>
|
||||
public static SampleStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancelled status - cancelled by user
|
||||
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
|
||||
/// </summary>
|
||||
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
|
||||
|
||||
public SampleStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available statuses.
|
||||
/// VI: Lấy tất cả các trạng thái có sẵn.
|
||||
/// </summary>
|
||||
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from name.
|
||||
/// VI: Parse trạng thái từ tên.
|
||||
/// </summary>
|
||||
public static SampleStatus FromName(string name)
|
||||
{
|
||||
var status = List().SingleOrDefault(s =>
|
||||
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from ID.
|
||||
/// VI: Parse trạng thái từ ID.
|
||||
/// </summary>
|
||||
public static SampleStatus From(int id)
|
||||
{
|
||||
var status = List().SingleOrDefault(s => s.Id == id);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new Sample is created.
|
||||
/// VI: Domain event được phát ra khi một Sample mới được tạo.
|
||||
/// </summary>
|
||||
public class SampleCreatedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The newly created sample.
|
||||
/// VI: Sample mới được tạo.
|
||||
/// </summary>
|
||||
public Sample Sample { get; }
|
||||
|
||||
public SampleCreatedDomainEvent(Sample sample)
|
||||
{
|
||||
Sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when Sample status changes.
|
||||
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
|
||||
/// </summary>
|
||||
public class SampleStatusChangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The sample ID.
|
||||
/// VI: ID của sample.
|
||||
/// </summary>
|
||||
public Guid SampleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous status before the change.
|
||||
/// VI: Trạng thái trước khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus PreviousStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New status after the change.
|
||||
/// VI: Trạng thái mới sau khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus NewStatus { get; }
|
||||
|
||||
public SampleStatusChangedDomainEvent(
|
||||
Guid sampleId,
|
||||
SampleStatus previousStatus,
|
||||
SampleStatus newStatus)
|
||||
{
|
||||
SampleId = sampleId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AdsServingService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base exception for all Ads Serving domain exceptions.
|
||||
/// VI: Exception cơ sở cho tất cả các exception của Ads Serving domain.
|
||||
/// </summary>
|
||||
public class AdsServingDomainException : Exception
|
||||
{
|
||||
public AdsServingDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public AdsServingDomainException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdsServingDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace AdsServingService.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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsServingService.Domain.AggregatesModel.AuctionAggregate;
|
||||
using AdsServingService.Domain.AggregatesModel.PacingAggregate;
|
||||
using AdsServingService.Domain.AggregatesModel.FrequencyAggregate;
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
using AdsServingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
namespace AdsServingService.Infrastructure;
|
||||
|
||||
@@ -16,22 +17,11 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<Auction> Auctions => Set<Auction>();
|
||||
public DbSet<BudgetPacer> BudgetPacers => Set<BudgetPacer>();
|
||||
public DbSet<FrequencyCap> FrequencyCaps => Set<FrequencyCap>();
|
||||
|
||||
/// <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 AdsServingServiceContext(DbContextOptions<AdsServingServiceContext> options) : base(options)
|
||||
@@ -42,56 +32,30 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork
|
||||
public AdsServingServiceContext(DbContextOptions<AdsServingServiceContext> options, IMediator mediator) : base(options)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
|
||||
System.Diagnostics.Debug.WriteLine("AdsServingServiceContext::ctor - " + GetHashCode());
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
// Entity configurations will be added here
|
||||
}
|
||||
|
||||
/// <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");
|
||||
|
||||
@@ -115,10 +79,6 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Rollback the current transaction.
|
||||
/// VI: Rollback transaction hiện tại.
|
||||
/// </summary>
|
||||
public void RollbackTransaction()
|
||||
{
|
||||
try
|
||||
@@ -135,10 +95,6 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -1,9 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsServingService.Infrastructure.Idempotency;
|
||||
using AdsServingService.Infrastructure.Repositories;
|
||||
|
||||
namespace AdsServingService.Infrastructure;
|
||||
|
||||
@@ -13,15 +11,11 @@ namespace AdsServingService.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
|
||||
// Add DbContext with PostgreSQL
|
||||
services.AddDbContext<AdsServingServiceContext>(options =>
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
@@ -37,8 +31,6 @@ public static class DependencyInjection
|
||||
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();
|
||||
@@ -46,10 +38,10 @@ public static class DependencyInjection
|
||||
}
|
||||
});
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<ISampleRepository, SampleRepository>();
|
||||
// Register repositories (when needed)
|
||||
// services.AddScoped<IAuctionRepository, AuctionRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
// Register idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Sample entity.
|
||||
/// VI: Cấu hình EF Core cho entity Sample.
|
||||
/// </summary>
|
||||
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Sample> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("samples");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// EN: Ignore domain events (not persisted)
|
||||
// VI: Bỏ qua domain events (không lưu)
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Status relationship / VI: Quan hệ với Status
|
||||
builder.Property(s => s.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex("_name");
|
||||
builder.HasIndex(s => s.StatusId);
|
||||
builder.HasIndex("_createdAt");
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsServingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for SampleStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
|
||||
/// </summary>
|
||||
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SampleStatus> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("sample_statuses");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
|
||||
builder.HasData(
|
||||
SampleStatus.Draft,
|
||||
SampleStatus.Active,
|
||||
SampleStatus.Completed,
|
||||
SampleStatus.Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsServingService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Sample aggregate.
|
||||
/// VI: Triển khai repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleRepository : ISampleRepository
|
||||
{
|
||||
private readonly AdsServingServiceContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of work for transaction management.
|
||||
/// VI: Unit of work cho quản lý transaction.
|
||||
/// </summary>
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public SampleRepository(AdsServingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Sample?> GetAsync(Guid sampleId)
|
||||
{
|
||||
var sample = await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.FirstOrDefaultAsync(s => s.Id == sampleId);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetAllAsync()
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Sample Add(Sample sample)
|
||||
{
|
||||
return _context.Samples.Add(sample).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Update(Sample sample)
|
||||
{
|
||||
_context.Entry(sample).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(Sample sample)
|
||||
{
|
||||
_context.Samples.Remove(sample);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.Where(s => s.StatusId == statusId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using AdsServingService.API.Application.Commands;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsServingService.Domain.SeedWork;
|
||||
using Xunit;
|
||||
|
||||
namespace AdsServingService.UnitTests.Application;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for CreateSampleCommandHandler.
|
||||
/// VI: Unit tests cho CreateSampleCommandHandler.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<ISampleRepository> _mockRepository;
|
||||
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
|
||||
private readonly CreateSampleCommandHandler _handler;
|
||||
|
||||
public CreateSampleCommandHandlerTests()
|
||||
{
|
||||
_mockRepository = new Mock<ISampleRepository>();
|
||||
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
|
||||
|
||||
var mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
|
||||
|
||||
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", "Test Description");
|
||||
|
||||
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
|
||||
.Returns((Sample s) => s);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBeEmpty();
|
||||
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", null);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using AdsServingService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsServingService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace AdsServingService.UnitTests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for Sample aggregate.
|
||||
/// VI: Unit tests cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
|
||||
{
|
||||
// Arrange
|
||||
var name = "Test Sample";
|
||||
var description = "Test Description";
|
||||
|
||||
// Act
|
||||
var sample = new Sample(name, description);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(name);
|
||||
sample.Description.Should().Be(description);
|
||||
sample.Status.Should().Be(SampleStatus.Draft);
|
||||
sample.Id.Should().NotBeEmpty();
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSample_WithEmptyName_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var name = "";
|
||||
|
||||
// Act
|
||||
var act = () => new Sample(name);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Sample name cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenDraft_ShouldChangeToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Activate();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Active);
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenNotDraft_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Activate();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Only draft samples can be activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_WhenActive_ShouldChangeToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Complete();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
|
||||
// Act
|
||||
sample.Cancel();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenCompleted_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.Complete();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Cancel();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot cancel a completed sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Original Name", "Original Description");
|
||||
var newName = "Updated Name";
|
||||
var newDescription = "Updated Description";
|
||||
|
||||
// Act
|
||||
sample.Update(newName, newDescription);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(newName);
|
||||
sample.Description.Should().Be(newDescription);
|
||||
sample.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenCancelled_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Cancel();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Update("New Name", null);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot update a cancelled sample");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using CatalogService.Domain.AggregatesModel.SampleAggregate;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
using CatalogService.Domain.SeedWork;
|
||||
using CatalogService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -11,16 +11,22 @@ namespace CatalogService.Infrastructure;
|
||||
/// EN: EF Core DbContext for CatalogService.
|
||||
/// VI: EF Core DbContext cho CatalogService.
|
||||
/// </summary>
|
||||
public class CatalogServiceContext : DbContext, IUnitOfWork
|
||||
public class CatalogContext : DbContext, IUnitOfWork
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// EN: Products table.
|
||||
/// VI: Bảng Products.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Categories table.
|
||||
/// VI: Bảng Categories.
|
||||
/// </summary>
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
@@ -34,24 +40,25 @@ public class CatalogServiceContext : DbContext, IUnitOfWork
|
||||
/// </summary>
|
||||
public bool HasActiveTransaction => _currentTransaction != null;
|
||||
|
||||
public CatalogServiceContext(DbContextOptions<CatalogServiceContext> options) : base(options)
|
||||
public CatalogContext(DbContextOptions<CatalogContext> options) : base(options)
|
||||
{
|
||||
_mediator = null!;
|
||||
}
|
||||
|
||||
public CatalogServiceContext(DbContextOptions<CatalogServiceContext> options, IMediator mediator) : base(options)
|
||||
public CatalogContext(DbContextOptions<CatalogContext> options, IMediator mediator) : base(options)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
|
||||
System.Diagnostics.Debug.WriteLine("CatalogServiceContext::ctor - " + GetHashCode());
|
||||
System.Diagnostics.Debug.WriteLine("CatalogContext::ctor - " + GetHashCode());
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ProductEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CategoryEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ProductTypeEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -0,0 +1,74 @@
|
||||
// EN: Category entity configuration.
|
||||
// VI: Cấu hình entity Category.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
|
||||
namespace CatalogService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Category entity.
|
||||
/// VI: Cấu hình EF Core cho Category entity.
|
||||
/// </summary>
|
||||
public class CategoryEntityTypeConfiguration : IEntityTypeConfiguration<Category>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Category> builder)
|
||||
{
|
||||
builder.ToTable("categories");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property<Guid>("_shopId")
|
||||
.HasColumnName("shop_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property<Guid?>("_parentId")
|
||||
.HasColumnName("parent_id");
|
||||
|
||||
builder.Property<int>("_displayOrder")
|
||||
.HasColumnName("display_order")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
builder.Property<bool>("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Indexes
|
||||
// VI: Indexes
|
||||
builder.HasIndex("_shopId").HasDatabaseName("ix_categories_shop_id");
|
||||
builder.HasIndex("_parentId").HasDatabaseName("ix_categories_parent_id");
|
||||
builder.HasIndex("_displayOrder").HasDatabaseName("ix_categories_display_order");
|
||||
|
||||
// EN: Ignore calculated properties
|
||||
// VI: Bỏ qua các properties được tính toán
|
||||
builder.Ignore(c => c.ShopId);
|
||||
builder.Ignore(c => c.Name);
|
||||
builder.Ignore(c => c.Description);
|
||||
builder.Ignore(c => c.ParentId);
|
||||
builder.Ignore(c => c.DisplayOrder);
|
||||
builder.Ignore(c => c.IsActive);
|
||||
builder.Ignore(c => c.CreatedAt);
|
||||
builder.Ignore(c => c.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// EN: Product entity configuration.
|
||||
// VI: Cấu hình entity Product.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CatalogService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Product entity.
|
||||
/// VI: Cấu hình EF Core cho Product entity.
|
||||
/// </summary>
|
||||
public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Product> builder)
|
||||
{
|
||||
builder.ToTable("products");
|
||||
|
||||
builder.HasKey(p => p.Id);
|
||||
|
||||
builder.Property(p => p.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property<Guid>("_shopId")
|
||||
.HasColumnName("shop_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(255)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property<decimal>("_price")
|
||||
.HasColumnName("price")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.TypeId)
|
||||
.HasColumnName("type_id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: JSONB configuration for polymorphic attributes
|
||||
// VI: Cấu hình JSONB cho attributes đa hình
|
||||
builder.Property<JsonDocument?>("_attributes")
|
||||
.HasColumnName("attributes")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
builder.Property<string?>("_imageUrl")
|
||||
.HasColumnName("image_url")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property<string?>("_sku")
|
||||
.HasColumnName("sku")
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property<bool>("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Indexes
|
||||
// VI: Indexes
|
||||
builder.HasIndex("_shopId").HasDatabaseName("ix_products_shop_id");
|
||||
builder.HasIndex(p => p.TypeId).HasDatabaseName("ix_products_type_id");
|
||||
builder.HasIndex("_sku").HasDatabaseName("ix_products_sku");
|
||||
builder.HasIndex("_isActive").HasDatabaseName("ix_products_is_active");
|
||||
|
||||
// EN: Ignore calculated properties
|
||||
// VI: Bỏ qua các properties được tính toán
|
||||
builder.Ignore(p => p.ShopId);
|
||||
builder.Ignore(p => p.Name);
|
||||
builder.Ignore(p => p.Description);
|
||||
builder.Ignore(p => p.Price);
|
||||
builder.Ignore(p => p.Type);
|
||||
builder.Ignore(p => p.Attributes);
|
||||
builder.Ignore(p => p.ImageUrl);
|
||||
builder.Ignore(p => p.Sku);
|
||||
builder.Ignore(p => p.IsActive);
|
||||
builder.Ignore(p => p.CreatedAt);
|
||||
builder.Ignore(p => p.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// EN: ProductType enumeration entity configuration.
|
||||
// VI: Cấu hình entity cho ProductType enumeration.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
|
||||
namespace CatalogService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for ProductType enumeration.
|
||||
/// VI: Cấu hình EF Core cho ProductType enumeration.
|
||||
/// </summary>
|
||||
public class ProductTypeEntityTypeConfiguration : IEntityTypeConfiguration<ProductType>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProductType> builder)
|
||||
{
|
||||
builder.ToTable("product_types");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
|
||||
builder.Property(t => t.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(t => t.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed data for ProductType
|
||||
// VI: Dữ liệu seed cho ProductType
|
||||
builder.HasData(
|
||||
ProductType.Physical,
|
||||
ProductType.Service,
|
||||
ProductType.PreparedFood
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// EN: Product repository implementation.
|
||||
// VI: Implementation repository Product.
|
||||
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
using CatalogService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CatalogService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Product aggregate.
|
||||
/// VI: Implementation repository cho Product aggregate.
|
||||
/// </summary>
|
||||
public class ProductRepository : IProductRepository
|
||||
{
|
||||
private readonly CatalogContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public ProductRepository(CatalogContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public Product Add(Product product)
|
||||
{
|
||||
return _context.Products.Add(product).Entity;
|
||||
}
|
||||
|
||||
public void Update(Product product)
|
||||
{
|
||||
_context.Entry(product).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Product>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.Where(p => p.ShopId == shopId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Product>> GetByTypeAsync(Guid shopId, ProductType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.Where(p => p.ShopId == shopId && p.TypeId == type.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user