feat: implement ads serving statistics and event pipeline
Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
namespace AdsServingService.API.Application.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event payload for served-ad event.
|
||||
/// VI: Payload event cho sự kiện quảng cáo đã được phục vụ.
|
||||
/// </summary>
|
||||
public record AdServedEvent
|
||||
{
|
||||
public Guid AuctionId { get; init; }
|
||||
public Guid AdId { get; init; }
|
||||
public Guid CampaignId { get; init; }
|
||||
public Guid UserId { get; init; }
|
||||
public string PlacementType { get; init; } = string.Empty;
|
||||
public decimal FinalPrice { get; init; }
|
||||
public decimal WinningEcpm { get; init; }
|
||||
public DateTime ServedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event payload for impression.
|
||||
/// VI: Payload event cho impression.
|
||||
/// </summary>
|
||||
public record AdImpressionTrackedEvent
|
||||
{
|
||||
public Guid AdId { get; init; }
|
||||
public Guid UserId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event payload for click.
|
||||
/// VI: Payload event cho click.
|
||||
/// </summary>
|
||||
public record AdClickTrackedEvent
|
||||
{
|
||||
public Guid AdId { get; init; }
|
||||
public Guid UserId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Abstraction for publishing ads-serving events asynchronously.
|
||||
/// VI: Abstraction để publish bất đồng bộ các sự kiện ads-serving.
|
||||
/// </summary>
|
||||
public interface IAdServingEventPublisher
|
||||
{
|
||||
Task PublishAdServedAsync(AdServedEvent payload, CancellationToken cancellationToken);
|
||||
Task PublishImpressionTrackedAsync(AdImpressionTrackedEvent payload, CancellationToken cancellationToken);
|
||||
Task PublishClickTrackedAsync(AdClickTrackedEvent payload, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Logging-based publisher used as default implementation.
|
||||
/// VI: Publisher dựa trên logging dùng làm implementation mặc định.
|
||||
/// </summary>
|
||||
public class LoggingAdServingEventPublisher : IAdServingEventPublisher
|
||||
{
|
||||
private readonly ILogger<LoggingAdServingEventPublisher> _logger;
|
||||
|
||||
public LoggingAdServingEventPublisher(ILogger<LoggingAdServingEventPublisher> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishAdServedAsync(AdServedEvent payload, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ad.served.v1 event published: AuctionId={AuctionId}, AdId={AdId}, CampaignId={CampaignId}, UserId={UserId}, PlacementType={PlacementType}, FinalPrice={FinalPrice}, WinningEcpm={WinningEcpm}, ServedAt={ServedAt}",
|
||||
payload.AuctionId,
|
||||
payload.AdId,
|
||||
payload.CampaignId,
|
||||
payload.UserId,
|
||||
payload.PlacementType,
|
||||
payload.FinalPrice,
|
||||
payload.WinningEcpm,
|
||||
payload.ServedAt);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishImpressionTrackedAsync(AdImpressionTrackedEvent payload, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ads.impression.tracked.v1 event published: AdId={AdId}, UserId={UserId}, Timestamp={Timestamp}",
|
||||
payload.AdId,
|
||||
payload.UserId,
|
||||
payload.Timestamp);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishClickTrackedAsync(AdClickTrackedEvent payload, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ads.click.tracked.v1 event published: AdId={AdId}, UserId={UserId}, Timestamp={Timestamp}",
|
||||
payload.AdId,
|
||||
payload.UserId,
|
||||
payload.Timestamp);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to retrieve aggregate auction statistics.
|
||||
/// VI: Query để lấy thống kê tổng hợp của auction.
|
||||
/// </summary>
|
||||
public record GetAuctionStatisticsQuery : IRequest<AuctionStatisticsDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Aggregate auction statistics DTO.
|
||||
/// VI: DTO thống kê tổng hợp auction.
|
||||
/// </summary>
|
||||
public record AuctionStatisticsDto
|
||||
{
|
||||
public int TotalAuctions { get; init; }
|
||||
public decimal AverageWinRate { get; init; }
|
||||
public decimal AverageeCPM { get; init; }
|
||||
public long TotalBidsPlaced { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Text.Json;
|
||||
using AdsServingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetAuctionStatisticsQuery.
|
||||
/// VI: Handler cho GetAuctionStatisticsQuery.
|
||||
/// </summary>
|
||||
public class GetAuctionStatisticsQueryHandler
|
||||
: IRequestHandler<GetAuctionStatisticsQuery, AuctionStatisticsDto>
|
||||
{
|
||||
private readonly AdsServingServiceContext _context;
|
||||
|
||||
public GetAuctionStatisticsQueryHandler(AdsServingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<AuctionStatisticsDto> Handle(
|
||||
GetAuctionStatisticsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var auctionSnapshots = await _context.Auctions
|
||||
.AsNoTracking()
|
||||
.Select(auction => new
|
||||
{
|
||||
HasWinner = auction.Result != null,
|
||||
WinningEcpm = auction.Result != null ? auction.Result.WinningeCPM : 0m,
|
||||
BidsJson = EF.Property<string>(auction, "_bidsJson"),
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var totalAuctions = auctionSnapshots.Count;
|
||||
var totalWinners = auctionSnapshots.Count(snapshot => snapshot.HasWinner);
|
||||
var averageWinRate = totalAuctions == 0
|
||||
? 0m
|
||||
: Math.Round((decimal)totalWinners / totalAuctions * 100m, 2);
|
||||
|
||||
var winningEcpmValues = auctionSnapshots
|
||||
.Where(snapshot => snapshot.HasWinner)
|
||||
.Select(snapshot => snapshot.WinningEcpm)
|
||||
.ToList();
|
||||
|
||||
var averageEcpm = winningEcpmValues.Count == 0
|
||||
? 0m
|
||||
: Math.Round(winningEcpmValues.Average(), 4);
|
||||
|
||||
var totalBidsPlaced = auctionSnapshots
|
||||
.Sum(snapshot => CountBids(snapshot.BidsJson));
|
||||
|
||||
return new AuctionStatisticsDto
|
||||
{
|
||||
TotalAuctions = totalAuctions,
|
||||
AverageWinRate = averageWinRate,
|
||||
AverageeCPM = averageEcpm,
|
||||
TotalBidsPlaced = totalBidsPlaced,
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountBids(string? bidsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bidsJson))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bidsJson);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Array
|
||||
? document.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using AdsServingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
@@ -42,25 +43,59 @@ public class GetAuctionsQueryHandler : IRequestHandler<GetAuctionsQuery, PagedRe
|
||||
.OrderByDescending(a => EF.Property<DateTime>(a, "_auctionTime"))
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(a => new AuctionDto
|
||||
.Select(a => new
|
||||
{
|
||||
Id = a.Id,
|
||||
UserId = a.UserId,
|
||||
PlacementType = a.PlacementType,
|
||||
AuctionTime = EF.Property<DateTime>(a, "_auctionTime"),
|
||||
BidCount = a.Bids.Count,
|
||||
BidsJson = EF.Property<string>(a, "_bidsJson"),
|
||||
WinningAdId = a.Result != null ? a.Result.WinningAdId : null,
|
||||
FinalPrice = a.Result != null ? a.Result.FinalPrice : null,
|
||||
WinningeCPM = a.Result != null ? a.Result.WinningeCPM : null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = auctions
|
||||
.Select(a => new AuctionDto
|
||||
{
|
||||
Id = a.Id,
|
||||
UserId = a.UserId,
|
||||
PlacementType = a.PlacementType,
|
||||
AuctionTime = a.AuctionTime,
|
||||
BidCount = CountBids(a.BidsJson),
|
||||
WinningAdId = a.WinningAdId,
|
||||
FinalPrice = a.FinalPrice,
|
||||
WinningeCPM = a.WinningeCPM,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<AuctionDto>
|
||||
{
|
||||
Items = auctions,
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountBids(string? bidsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bidsJson))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bidsJson);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Array
|
||||
? document.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using AdsServingService.Domain.AggregatesModel.AuctionAggregate;
|
||||
using AdsServingService.API.Application.Events;
|
||||
using AdsServingService.API.Application.Services;
|
||||
using AdsServingService.Infrastructure;
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
@@ -9,10 +13,23 @@ namespace AdsServingService.API.Application.Queries;
|
||||
/// </summary>
|
||||
public class ServeAdQueryHandler : IRequestHandler<ServeAdQuery, ServedAdDto?>
|
||||
{
|
||||
private readonly AdsServingServiceContext _context;
|
||||
private readonly IEligibleAdsProvider _eligibleAdsProvider;
|
||||
private readonly IAuctionScoringService _auctionScoringService;
|
||||
private readonly IAdServingEventPublisher _eventPublisher;
|
||||
private readonly ILogger<ServeAdQueryHandler> _logger;
|
||||
|
||||
public ServeAdQueryHandler(ILogger<ServeAdQueryHandler> logger)
|
||||
public ServeAdQueryHandler(
|
||||
AdsServingServiceContext context,
|
||||
IEligibleAdsProvider eligibleAdsProvider,
|
||||
IAuctionScoringService auctionScoringService,
|
||||
IAdServingEventPublisher eventPublisher,
|
||||
ILogger<ServeAdQueryHandler> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_eligibleAdsProvider = eligibleAdsProvider ?? throw new ArgumentNullException(nameof(eligibleAdsProvider));
|
||||
_auctionScoringService = auctionScoringService ?? throw new ArgumentNullException(nameof(auctionScoringService));
|
||||
_eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -22,9 +39,11 @@ public class ServeAdQueryHandler : IRequestHandler<ServeAdQuery, ServedAdDto?>
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Step 1 - Fetch eligible ads from cache/database
|
||||
// For now, mock data for demonstration
|
||||
var eligibleAds = GetMockEligibleAds(request.PlacementType);
|
||||
var eligibleAds = await _eligibleAdsProvider.GetEligibleAdsAsync(
|
||||
request.UserId,
|
||||
request.PlacementType,
|
||||
request.UserContext,
|
||||
cancellationToken);
|
||||
|
||||
if (!eligibleAds.Any())
|
||||
{
|
||||
@@ -38,12 +57,17 @@ public class ServeAdQueryHandler : IRequestHandler<ServeAdQuery, ServedAdDto?>
|
||||
// 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)
|
||||
var signals = _auctionScoringService.Score(
|
||||
ad,
|
||||
request.PlacementType,
|
||||
request.UserContext);
|
||||
|
||||
auction.AddBid(ad.AdId, ad.CampaignId, bidAmount, predictedCTR, qualityScore);
|
||||
auction.AddBid(
|
||||
ad.AdId,
|
||||
ad.CampaignId,
|
||||
ad.BidAmount,
|
||||
signals.PredictedCtr,
|
||||
signals.QualityScore);
|
||||
}
|
||||
|
||||
// Step 4 - Run auction
|
||||
@@ -58,8 +82,33 @@ public class ServeAdQueryHandler : IRequestHandler<ServeAdQuery, ServedAdDto?>
|
||||
// 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)
|
||||
|
||||
_context.Auctions.Add(auction);
|
||||
_context.Entry(auction).Property("_bidsJson").CurrentValue = JsonSerializer.Serialize(
|
||||
auction.Bids.Select(bid => new
|
||||
{
|
||||
bid.AdId,
|
||||
bid.CampaignId,
|
||||
bid.BidAmount,
|
||||
bid.PredictedCTR,
|
||||
bid.QualityScore,
|
||||
bid.eCPM,
|
||||
}));
|
||||
await _context.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
await _eventPublisher.PublishAdServedAsync(
|
||||
new AdServedEvent
|
||||
{
|
||||
AuctionId = auction.Id,
|
||||
AdId = winningAd.AdId,
|
||||
CampaignId = winningAd.CampaignId,
|
||||
UserId = request.UserId,
|
||||
PlacementType = request.PlacementType,
|
||||
FinalPrice = auction.Result.FinalPrice,
|
||||
WinningEcpm = auction.Result.WinningeCPM,
|
||||
ServedAt = DateTime.UtcNow,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
_logger.LogInformation(
|
||||
"Ad served in {Elapsed}ms - AdId: {AdId}, eCPM: {eCPM}, Price: {Price}",
|
||||
@@ -85,49 +134,4 @@ public class ServeAdQueryHandler : IRequestHandler<ServeAdQuery, ServedAdDto?>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace AdsServingService.API.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Scoring signal tuple used to place bids in auction.
|
||||
/// VI: Bộ tín hiệu scoring dùng để đặt bid trong auction.
|
||||
/// </summary>
|
||||
public record AuctionBidSignals(decimal PredictedCtr, decimal QualityScore);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Abstraction for scoring hooks in ad serving auction.
|
||||
/// VI: Abstraction cho scoring hook trong auction phục vụ quảng cáo.
|
||||
/// </summary>
|
||||
public interface IAuctionScoringService
|
||||
{
|
||||
AuctionBidSignals Score(
|
||||
EligibleAdCandidate candidate,
|
||||
string placementType,
|
||||
IReadOnlyDictionary<string, string> userContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Default heuristic scoring strategy.
|
||||
/// VI: Chiến lược scoring heuristic mặc định.
|
||||
/// </summary>
|
||||
public class DefaultAuctionScoringService : IAuctionScoringService
|
||||
{
|
||||
public AuctionBidSignals Score(
|
||||
EligibleAdCandidate candidate,
|
||||
string placementType,
|
||||
IReadOnlyDictionary<string, string> userContext)
|
||||
{
|
||||
var baseCtr = placementType.ToLowerInvariant() switch
|
||||
{
|
||||
"story" => 0.018m,
|
||||
"banner" => 0.012m,
|
||||
_ => 0.020m,
|
||||
};
|
||||
|
||||
var segmentBoost = 1.0m;
|
||||
if (userContext.TryGetValue("segment", out var segmentValue) &&
|
||||
segmentValue.Equals("high-intent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
segmentBoost += 0.25m;
|
||||
}
|
||||
|
||||
var recencyBoost = 1.0m;
|
||||
if (userContext.TryGetValue("recentPurchaseDays", out var recencyRaw) &&
|
||||
int.TryParse(recencyRaw, out var recencyDays) &&
|
||||
recencyDays <= 14)
|
||||
{
|
||||
recencyBoost += 0.10m;
|
||||
}
|
||||
|
||||
var predictedCtr = Math.Round(baseCtr * segmentBoost * recencyBoost, 4);
|
||||
|
||||
var qualityScore = candidate.Format.ToLowerInvariant() switch
|
||||
{
|
||||
"single_image" => 1.0m,
|
||||
"video" => 1.1m,
|
||||
_ => 0.95m,
|
||||
};
|
||||
|
||||
return new AuctionBidSignals(predictedCtr, qualityScore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace AdsServingService.API.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Candidate ad model used in serving pipeline.
|
||||
/// VI: Model quảng cáo ứng viên dùng trong pipeline phục vụ quảng cáo.
|
||||
/// </summary>
|
||||
public record EligibleAdCandidate
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Service abstraction to load eligible ads for a serve request.
|
||||
/// VI: Abstraction service để tải danh sách quảng cáo đủ điều kiện cho request serve.
|
||||
/// </summary>
|
||||
public interface IEligibleAdsProvider
|
||||
{
|
||||
Task<IReadOnlyList<EligibleAdCandidate>> GetEligibleAdsAsync(
|
||||
Guid userId,
|
||||
string placementType,
|
||||
IReadOnlyDictionary<string, string> userContext,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: In-memory eligible ads provider used as default fallback.
|
||||
/// VI: Provider quảng cáo đủ điều kiện dạng in-memory dùng như fallback mặc định.
|
||||
/// </summary>
|
||||
public class InMemoryEligibleAdsProvider : IEligibleAdsProvider
|
||||
{
|
||||
public Task<IReadOnlyList<EligibleAdCandidate>> GetEligibleAdsAsync(
|
||||
Guid userId,
|
||||
string placementType,
|
||||
IReadOnlyDictionary<string, string> userContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Keep deterministic IDs per request/user so placement is reproducible.
|
||||
// VI: Giữ ID xác định theo request/user để kết quả placement có thể lặp lại.
|
||||
var hashSeed = Math.Abs(HashCode.Combine(userId, placementType.ToLowerInvariant()));
|
||||
var primaryCampaignId = DeterministicGuid($"{placementType}:{hashSeed}:campaign:primary");
|
||||
var secondaryCampaignId = DeterministicGuid($"{placementType}:{hashSeed}:campaign:secondary");
|
||||
|
||||
IReadOnlyList<EligibleAdCandidate> candidates =
|
||||
[
|
||||
new EligibleAdCandidate
|
||||
{
|
||||
AdId = DeterministicGuid($"{placementType}:{hashSeed}:ad:primary"),
|
||||
CampaignId = primaryCampaignId,
|
||||
BidAmount = 5000m,
|
||||
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 EligibleAdCandidate
|
||||
{
|
||||
AdId = DeterministicGuid($"{placementType}:{hashSeed}:ad:secondary"),
|
||||
CampaignId = secondaryCampaignId,
|
||||
BidAmount = 4500m,
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
return Task.FromResult(candidates);
|
||||
}
|
||||
|
||||
private static Guid DeterministicGuid(string input)
|
||||
{
|
||||
var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
var guidBytes = new byte[16];
|
||||
Array.Copy(bytes, guidBytes, guidBytes.Length);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
}
|
||||
@@ -56,25 +56,10 @@ public class AdminAuctionsController : ControllerBase
|
||||
/// </summary>
|
||||
[HttpGet("statistics")]
|
||||
[ProducesResponseType(typeof(AuctionStatisticsDto), StatusCodes.Status200OK)]
|
||||
public ActionResult<AuctionStatisticsDto> GetStatistics()
|
||||
public async Task<ActionResult<AuctionStatisticsDto>> GetStatistics()
|
||||
{
|
||||
// TODO: Implement statistics query handler
|
||||
_logger.LogInformation("Auction statistics requested");
|
||||
|
||||
return Ok(new AuctionStatisticsDto
|
||||
{
|
||||
TotalAuctions = 0,
|
||||
AverageWinRate = 0,
|
||||
AverageeCPM = 0,
|
||||
TotalBidsPlaced = 0
|
||||
});
|
||||
var statistics = await _mediator.Send(new GetAuctionStatisticsQuery());
|
||||
return Ok(statistics);
|
||||
}
|
||||
}
|
||||
|
||||
public record AuctionStatisticsDto
|
||||
{
|
||||
public int TotalAuctions { get; init; }
|
||||
public decimal AverageWinRate { get; init; }
|
||||
public decimal AverageeCPM { get; init; }
|
||||
public long TotalBidsPlaced { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AdsServingService.API.Application.Queries;
|
||||
using AdsServingService.API.Application.Events;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -14,11 +15,16 @@ namespace AdsServingService.API.Controllers;
|
||||
public class AdsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IAdServingEventPublisher _eventPublisher;
|
||||
private readonly ILogger<AdsController> _logger;
|
||||
|
||||
public AdsController(IMediator mediator, ILogger<AdsController> logger)
|
||||
public AdsController(
|
||||
IMediator mediator,
|
||||
IAdServingEventPublisher eventPublisher,
|
||||
ILogger<AdsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -52,14 +58,18 @@ public class AdsController : ControllerBase
|
||||
/// </summary>
|
||||
[HttpPost("events/impression")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public IActionResult TrackImpression([FromBody] ImpressionEvent impressionEvent)
|
||||
public async Task<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)
|
||||
|
||||
await _eventPublisher.PublishImpressionTrackedAsync(
|
||||
new AdImpressionTrackedEvent
|
||||
{
|
||||
AdId = impressionEvent.AdId,
|
||||
UserId = impressionEvent.UserId,
|
||||
Timestamp = impressionEvent.Timestamp,
|
||||
},
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
@@ -70,11 +80,18 @@ public class AdsController : ControllerBase
|
||||
/// </summary>
|
||||
[HttpPost("events/click")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public IActionResult TrackClick([FromBody] ClickEvent clickEvent)
|
||||
public async Task<IActionResult> TrackClick([FromBody] ClickEvent clickEvent)
|
||||
{
|
||||
_logger.LogInformation("Click tracked for Ad {AdId}", clickEvent.AdId);
|
||||
|
||||
// TODO: Publish to RabbitMQ for async processing
|
||||
|
||||
await _eventPublisher.PublishClickTrackedAsync(
|
||||
new AdClickTrackedEvent
|
||||
{
|
||||
AdId = clickEvent.AdId,
|
||||
UserId = clickEvent.UserId,
|
||||
Timestamp = clickEvent.Timestamp,
|
||||
},
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Asp.Versioning;
|
||||
using AdsServingService.API.Application.Events;
|
||||
using AdsServingService.API.Application.Services;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using AdsServingService.API.Application.Behaviors;
|
||||
@@ -25,6 +27,9 @@ try
|
||||
|
||||
// EN: Add Infrastructure services / VI: Thêm Infrastructure services
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddScoped<IEligibleAdsProvider, InMemoryEligibleAdsProvider>();
|
||||
builder.Services.AddScoped<IAuctionScoringService, DefaultAuctionScoringService>();
|
||||
builder.Services.AddScoped<IAdServingEventPublisher, LoggingAdServingEventPublisher>();
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
|
||||
Reference in New Issue
Block a user