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:
Ho Ngoc Hai
2026-01-18 00:33:04 +07:00
parent 5626c3495b
commit 6263ab4932
40 changed files with 946 additions and 1483 deletions

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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
);

View File

@@ -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
);
}
}

View File

@@ -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>>;

View File

@@ -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
));
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)
{
}
}

View File

@@ -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)
{
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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");
}
}

View File

@@ -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
);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
);
}
}

View File

@@ -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);
}
}