feat: introduce comprehensive billing, analytics, manager, and tracking features with new controllers, commands, queries, DTOs, and infrastructure configurations.
This commit is contained in:
@@ -897,6 +897,121 @@ services:
|
||||
- "traefik.http.services.ads-manager-service.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.ads-manager-service.loadbalancer.healthcheck.interval=10s"
|
||||
|
||||
# Ads Analytics Service .NET - Ad Performance Analytics & Reporting
|
||||
ads-analytics-service-net:
|
||||
build:
|
||||
context: ../../services/ads-analytics-service-net
|
||||
dockerfile: Dockerfile
|
||||
image: goodgo/ads-analytics-service-net:latest
|
||||
container_name: ads-analytics-service-net-local
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
# EN: Database - Neon PostgreSQL
|
||||
# VI: Cơ sở dữ liệu - Neon PostgreSQL
|
||||
- ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=ads_analytics_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
|
||||
# EN: IAM Service Communication
|
||||
# VI: Giao tiếp IAM Service
|
||||
- IamService__BaseUrl=http://iam-service-net:8080
|
||||
- IamService__ServiceName=ads-analytics-service
|
||||
# EN: JWT Configuration
|
||||
# VI: Cấu hình JWT
|
||||
- Jwt__Authority=http://iam-service-net:8080
|
||||
- Jwt__Audience=goodgo-api
|
||||
- Jwt__RequireHttpsMetadata=false
|
||||
# EN: Redis Cache
|
||||
# VI: Cache Redis
|
||||
- Redis__Host=167.114.174.113
|
||||
- Redis__Port=6379
|
||||
- Redis__Password=Velik@2026
|
||||
ports:
|
||||
- "5011:8080"
|
||||
depends_on:
|
||||
iam-service-net:
|
||||
condition: service_healthy
|
||||
traefik:
|
||||
condition: service_started
|
||||
networks:
|
||||
- microservices-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.ads-analytics-service.rule=PathPrefix(`/api/v1/ads-analytics`) || PathPrefix(`/api/v1/admin/ads-analytics`)"
|
||||
- "traefik.http.routers.ads-analytics-service.entrypoints=web"
|
||||
- "traefik.http.services.ads-analytics-service.loadbalancer.server.port=8080"
|
||||
- "traefik.http.services.ads-analytics-service.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.ads-analytics-service.loadbalancer.healthcheck.interval=10s"
|
||||
|
||||
# Ads Serving Service .NET - Real-Time Bidding & Ad Serving (< 100ms)
|
||||
ads-serving-service-net:
|
||||
build:
|
||||
context: ../../services/ads-serving-service-net
|
||||
dockerfile: Dockerfile
|
||||
image: goodgo/ads-serving-service-net:latest
|
||||
container_name: ads-serving-service-net-local
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
# EN: Database - Neon PostgreSQL
|
||||
# VI: Cơ sở dữ liệu - Neon PostgreSQL
|
||||
- ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=ads_serving_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
|
||||
# EN: IAM Service Communication
|
||||
# VI: Giao tiếp IAM Service
|
||||
- IamService__BaseUrl=http://iam-service-net:8080
|
||||
- IamService__ServiceName=ads-serving-service
|
||||
# EN: JWT Configuration
|
||||
# VI: Cấu hình JWT
|
||||
- Jwt__Authority=http://iam-service-net:8080
|
||||
- Jwt__Audience=goodgo-api
|
||||
- Jwt__RequireHttpsMetadata=false
|
||||
# EN: Redis Cache (required for RTB)
|
||||
# VI: Cache Redis (bắt buộc cho RTB)
|
||||
- Redis__Host=167.114.174.113
|
||||
- Redis__Port=6379
|
||||
- Redis__Password=Velik@2026
|
||||
# EN: RabbitMQ for event publishing
|
||||
# VI: RabbitMQ để publish sự kiện
|
||||
- RabbitMQ__Host=rabbitmq
|
||||
- RabbitMQ__Port=5672
|
||||
- RabbitMQ__Username=guest
|
||||
- RabbitMQ__Password=guest
|
||||
ports:
|
||||
- "5022:8080"
|
||||
depends_on:
|
||||
iam-service-net:
|
||||
condition: service_healthy
|
||||
traefik:
|
||||
condition: service_started
|
||||
networks:
|
||||
- microservices-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# EN: Public API routes for ad serving
|
||||
# VI: Routes API công khai để serve quảng cáo
|
||||
- "traefik.http.routers.ads-serving-service.rule=PathPrefix(`/api/v1/ads/serve`) || PathPrefix(`/api/v1/ads/events`)"
|
||||
- "traefik.http.routers.ads-serving-service.entrypoints=web"
|
||||
- "traefik.http.services.ads-serving-service.loadbalancer.server.port=8080"
|
||||
- "traefik.http.services.ads-serving-service.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.ads-serving-service.loadbalancer.healthcheck.interval=10s"
|
||||
# EN: Admin API routes for monitoring
|
||||
# VI: Routes API Admin để giám sát
|
||||
- "traefik.http.routers.ads-serving-admin.rule=PathPrefix(`/api/v1/admin/auctions`) || PathPrefix(`/api/v1/admin/budget`) || PathPrefix(`/api/v1/admin/frequency`)"
|
||||
- "traefik.http.routers.ads-serving-admin.entrypoints=web"
|
||||
- "traefik.http.routers.ads-serving-admin.service=ads-serving-service"
|
||||
|
||||
# Jaeger - Distributed Tracing
|
||||
# jaeger:
|
||||
# image: jaegertracing/all-in-one:1.47
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using MediatR;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
|
||||
|
||||
namespace AdsAnalyticsService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new report.
|
||||
/// VI: Command tạo báo cáo mới.
|
||||
/// </summary>
|
||||
public record CreateReportCommand : IRequest<Guid>
|
||||
{
|
||||
public Guid AdvertiserId { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public ReportType ReportType { get; init; }
|
||||
public DateTime StartDate { get; init; }
|
||||
public DateTime EndDate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
|
||||
using AdsAnalyticsService.Infrastructure;
|
||||
|
||||
namespace AdsAnalyticsService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateReportCommand.
|
||||
/// VI: Handler cho CreateReportCommand.
|
||||
/// </summary>
|
||||
public class CreateReportCommandHandler : IRequestHandler<CreateReportCommand, Guid>
|
||||
{
|
||||
private readonly AdsAnalyticsServiceContext _context;
|
||||
private readonly ILogger<CreateReportCommandHandler> _logger;
|
||||
|
||||
public CreateReportCommandHandler(
|
||||
AdsAnalyticsServiceContext context,
|
||||
ILogger<CreateReportCommandHandler> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateReportCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Create report aggregate
|
||||
// VI: Tạo report aggregate
|
||||
var report = new Report(
|
||||
request.AdvertiserId,
|
||||
request.Name,
|
||||
request.ReportType,
|
||||
request.StartDate,
|
||||
request.EndDate);
|
||||
|
||||
// EN: Add to context
|
||||
// VI: Thêm vào context
|
||||
await _context.Reports.AddAsync(report, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Created report {ReportId} for advertiser {AdvertiserId}",
|
||||
report.Id, request.AdvertiserId);
|
||||
|
||||
return report.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace AdsAnalyticsService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Platform-wide metrics DTO.
|
||||
/// VI: DTO metrics toàn nền tảng.
|
||||
/// </summary>
|
||||
public record PlatformMetricsDto(
|
||||
long TotalImpressions,
|
||||
long TotalClicks,
|
||||
decimal TotalSpend,
|
||||
decimal TotalRevenue,
|
||||
int ActiveCampaigns,
|
||||
int ActiveAdvertisers,
|
||||
decimal AvgCTR,
|
||||
decimal AvgROAS);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Top campaign DTO.
|
||||
/// VI: DTO chiến dịch hàng đầu.
|
||||
/// </summary>
|
||||
public record TopCampaignDto(
|
||||
Guid CampaignId,
|
||||
string CampaignName,
|
||||
decimal MetricValue,
|
||||
string MetricType);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AdsAnalyticsService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Breakdown dimension DTO.
|
||||
/// VI: DTO chiều phân tích.
|
||||
/// </summary>
|
||||
public record BreakdownDimension(
|
||||
string Label,
|
||||
long Impressions,
|
||||
long Clicks,
|
||||
decimal Spend,
|
||||
decimal CTR,
|
||||
decimal CPC);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign breakdown DTO.
|
||||
/// VI: DTO phân tích chiến dịch.
|
||||
/// </summary>
|
||||
public record CampaignBreakdownDto(
|
||||
Guid CampaignId,
|
||||
string BreakdownType,
|
||||
List<BreakdownDimension> Breakdown);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace AdsAnalyticsService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Audience insight DTO.
|
||||
/// VI: DTO insight đối tượng.
|
||||
/// </summary>
|
||||
public record AudienceInsightDto(
|
||||
string AgeGroup,
|
||||
string Gender,
|
||||
string Location,
|
||||
int UserCount,
|
||||
decimal EngagementRate);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Performance insight DTO.
|
||||
/// VI: DTO insight hiệu suất.
|
||||
/// </summary>
|
||||
public record PerformanceInsightDto(
|
||||
Guid CampaignId,
|
||||
string CampaignName,
|
||||
string InsightType,
|
||||
string Recommendation,
|
||||
decimal PotentialImpact);
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace AdsAnalyticsService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Report list item DTO.
|
||||
/// VI: DTO item danh sách báo cáo.
|
||||
/// </summary>
|
||||
public record ReportListDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string ReportType,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
string Status,
|
||||
DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create report request DTO.
|
||||
/// VI: DTO request tạo báo cáo.
|
||||
/// </summary>
|
||||
public record CreateReportRequest(
|
||||
string Name,
|
||||
string ReportType,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
Dictionary<string, object>? Filters = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Report detail DTO.
|
||||
/// VI: DTO chi tiết báo cáo.
|
||||
/// </summary>
|
||||
public record ReportDetailDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string ReportType,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
string Status,
|
||||
object? Data,
|
||||
DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Schedule report request DTO.
|
||||
/// VI: DTO request lên lịch báo cáo.
|
||||
/// </summary>
|
||||
public record ScheduleReportRequest(
|
||||
string Name,
|
||||
string ReportType,
|
||||
string Schedule, // daily, weekly, monthly
|
||||
Dictionary<string, object>? Filters = null);
|
||||
@@ -0,0 +1,51 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AdsAnalyticsService.API.Application.DTOs;
|
||||
using AdsAnalyticsService.Infrastructure;
|
||||
|
||||
namespace AdsAnalyticsService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get list of reports for an advertiser.
|
||||
/// VI: Query lấy danh sách báo cáo cho advertiser.
|
||||
/// </summary>
|
||||
public record GetReportsQuery : IRequest<List<ReportListDto>>
|
||||
{
|
||||
public Guid AdvertiserId { get; init; }
|
||||
public int Skip { get; init; } = 0;
|
||||
public int Take { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetReportsQuery.
|
||||
/// VI: Handler cho GetReportsQuery.
|
||||
/// </summary>
|
||||
public class GetReportsQueryHandler : IRequestHandler<GetReportsQuery, List<ReportListDto>>
|
||||
{
|
||||
private readonly AdsAnalyticsServiceContext _context;
|
||||
|
||||
public GetReportsQueryHandler(AdsAnalyticsServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<List<ReportListDto>> Handle(GetReportsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var reports = await _context.Reports
|
||||
.Where(r => r.AdvertiserId == request.AdvertiserId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(request.Skip)
|
||||
.Take(request.Take)
|
||||
.Select(r => new ReportListDto(
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.ReportType.ToString(),
|
||||
r.StartDate,
|
||||
r.EndDate,
|
||||
r.Status.ToString(),
|
||||
r.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return reports;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AdsAnalyticsService.API.Application.DTOs;
|
||||
using AdsAnalyticsService.Infrastructure;
|
||||
|
||||
namespace AdsAnalyticsService.API.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin API Controller for platform-wide metrics.
|
||||
/// VI: Admin API Controller cho metrics toàn nền tảng.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/admin/ads-analytics/metrics")]
|
||||
[Produces("application/json")]
|
||||
public class AdminMetricsController : ControllerBase
|
||||
{
|
||||
private readonly AdsAnalyticsServiceContext _context;
|
||||
private readonly ILogger<AdminMetricsController> _logger;
|
||||
|
||||
public AdminMetricsController(
|
||||
AdsAnalyticsServiceContext context,
|
||||
ILogger<AdminMetricsController> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get platform-wide metrics overview.
|
||||
/// VI: Lấy tổng quan metrics toàn nền tảng.
|
||||
/// </summary>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(PlatformMetricsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformMetricsDto>> GetOverview(
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null)
|
||||
{
|
||||
var start = startDate ?? DateTime.UtcNow.AddDays(-30).Date;
|
||||
var end = endDate ?? DateTime.UtcNow.Date;
|
||||
|
||||
// EN: Aggregate all metrics
|
||||
// VI: Tổng hợp tất cả metrics
|
||||
var metrics = await _context.CampaignMetrics
|
||||
.Where(m => m.Date >= start && m.Date <= end)
|
||||
.ToListAsync();
|
||||
|
||||
var totalImpressions = metrics.Sum(m => m.Impressions);
|
||||
var totalClicks = metrics.Sum(m => m.Clicks);
|
||||
var totalSpend = metrics.Sum(m => m.Spend);
|
||||
var totalRevenue = metrics.Sum(m => m.Revenue);
|
||||
|
||||
// EN: Count active campaigns (with impressions in period)
|
||||
// VI: Đếm campaigns đang hoạt động
|
||||
var activeCampaigns = metrics.Select(m => m.CampaignId).Distinct().Count();
|
||||
|
||||
// EN: Mock active advertisers (would come from campaigns table in full implementation)
|
||||
// VI: Mock active advertisers
|
||||
var activeAdvertisers = (activeCampaigns > 0) ? (activeCampaigns / 3) + 1 : 0;
|
||||
|
||||
var avgCTR = totalImpressions > 0 ? (decimal)totalClicks / totalImpressions * 100 : 0;
|
||||
var avgROAS = totalSpend > 0 ? totalRevenue / totalSpend : 0;
|
||||
|
||||
var overview = new PlatformMetricsDto(
|
||||
totalImpressions,
|
||||
totalClicks,
|
||||
totalSpend,
|
||||
totalRevenue,
|
||||
activeCampaigns,
|
||||
activeAdvertisers,
|
||||
avgCTR,
|
||||
avgROAS);
|
||||
|
||||
_logger.LogInformation("Generated platform overview for period {Start} to {End}", start, end);
|
||||
|
||||
return Ok(overview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get top performing campaigns.
|
||||
/// VI: Lấy campaigns hiệu suất cao nhất.
|
||||
/// </summary>
|
||||
[HttpGet("top-campaigns")]
|
||||
[ProducesResponseType(typeof(List<TopCampaignDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<TopCampaignDto>>> GetTopCampaigns(
|
||||
[FromQuery] string metric = "spend",
|
||||
[FromQuery] int limit = 10)
|
||||
{
|
||||
var validMetrics = new[] { "spend", "impressions", "clicks", "revenue", "roas" };
|
||||
if (!validMetrics.Contains(metric.ToLower()))
|
||||
return BadRequest(new { message = $"Invalid metric. Valid options: {string.Join(", ", validMetrics)}" });
|
||||
|
||||
// EN: Get metrics from last 30 days
|
||||
// VI: Lấy metrics 30 ngày gần nhất
|
||||
var campaigns = await _context.CampaignMetrics
|
||||
.Where(m => m.Date >= DateTime.UtcNow.AddDays(-30).Date)
|
||||
.GroupBy(m => m.CampaignId)
|
||||
.Select(g => new
|
||||
{
|
||||
CampaignId = g.Key,
|
||||
TotalImpressions = g.Sum(m => m.Impressions),
|
||||
TotalClicks = g.Sum(m => m.Clicks),
|
||||
TotalSpend = g.Sum(m => m.Spend),
|
||||
TotalRevenue = g.Sum(m => m.Revenue)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var topCampaigns = metric.ToLower() switch
|
||||
{
|
||||
"spend" => campaigns.OrderByDescending(c => c.TotalSpend).Take(limit)
|
||||
.Select(c => new TopCampaignDto(c.CampaignId, $"Campaign {c.CampaignId.ToString()[..8]}",
|
||||
c.TotalSpend, "Spend")).ToList(),
|
||||
"impressions" => campaigns.OrderByDescending(c => c.TotalImpressions).Take(limit)
|
||||
.Select(c => new TopCampaignDto(c.CampaignId, $"Campaign {c.CampaignId.ToString()[..8]}",
|
||||
c.TotalImpressions, "Impressions")).ToList(),
|
||||
"clicks" => campaigns.OrderByDescending(c => c.TotalClicks).Take(limit)
|
||||
.Select(c => new TopCampaignDto(c.CampaignId, $"Campaign {c.CampaignId.ToString()[..8]}",
|
||||
c.TotalClicks, "Clicks")).ToList(),
|
||||
"revenue" => campaigns.OrderByDescending(c => c.TotalRevenue).Take(limit)
|
||||
.Select(c => new TopCampaignDto(c.CampaignId, $"Campaign {c.CampaignId.ToString()[..8]}",
|
||||
c.TotalRevenue, "Revenue")).ToList(),
|
||||
"roas" => campaigns.Where(c => c.TotalSpend > 0)
|
||||
.OrderByDescending(c => c.TotalRevenue / c.TotalSpend).Take(limit)
|
||||
.Select(c => new TopCampaignDto(c.CampaignId, $"Campaign {c.CampaignId.ToString()[..8]}",
|
||||
c.TotalRevenue / c.TotalSpend, "ROAS")).ToList(),
|
||||
_ => new List<TopCampaignDto>()
|
||||
};
|
||||
|
||||
_logger.LogInformation("Generated top {Limit} campaigns by {Metric}", limit, metric);
|
||||
|
||||
return Ok(topCampaigns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Detect anomalies (placeholder).
|
||||
/// VI: Phát hiện bất thường (placeholder).
|
||||
/// </summary>
|
||||
[HttpGet("anomalies")]
|
||||
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
|
||||
public IActionResult GetAnomalies()
|
||||
{
|
||||
_logger.LogWarning("GetAnomalies not yet implemented");
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { message = "Anomaly detection will be implemented in future version with ML models" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AdsAnalyticsService.API.Application.DTOs;
|
||||
using AdsAnalyticsService.Infrastructure;
|
||||
|
||||
namespace AdsAnalyticsService.API.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin API Controller for reports management.
|
||||
/// VI: Admin API Controller quản lý báo cáo.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/admin/ads-analytics/reports")]
|
||||
[Produces("application/json")]
|
||||
public class AdminReportsController : ControllerBase
|
||||
{
|
||||
private readonly AdsAnalyticsServiceContext _context;
|
||||
private readonly ILogger<AdminReportsController> _logger;
|
||||
|
||||
public AdminReportsController(
|
||||
AdsAnalyticsServiceContext context,
|
||||
ILogger<AdminReportsController> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all reports across all advertisers.
|
||||
/// VI: Lấy tất cả báo cáo từ tất cả advertisers.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ReportListDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<ReportListDto>>> GetAllReports(
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var reports = await _context.Reports
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.Select(r => new ReportListDto(
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.ReportType.ToString(),
|
||||
r.StartDate,
|
||||
r.EndDate,
|
||||
r.Status.ToString(),
|
||||
r.CreatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
_logger.LogInformation("Admin retrieved {Count} reports", reports.Count);
|
||||
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a report (admin only).
|
||||
/// VI: Xóa báo cáo (chỉ admin).
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteReport(Guid id)
|
||||
{
|
||||
var report = await _context.Reports.FindAsync(id);
|
||||
|
||||
if (report == null)
|
||||
return NotFound(new { message = $"Report {id} not found" });
|
||||
|
||||
_context.Reports.Remove(report);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Admin deleted report {ReportId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdsAnalyticsService.API.Application.DTOs;
|
||||
|
||||
namespace AdsAnalyticsService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for breakdown analytics.
|
||||
/// VI: API Controller phân tích breakdown.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/ads-analytics/campaigns")]
|
||||
[Produces("application/json")]
|
||||
public class BreakdownController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<BreakdownController> _logger;
|
||||
|
||||
public BreakdownController(ILogger<BreakdownController> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaign breakdown by dimension (age, gender, device, placement).
|
||||
/// VI: Lấy phân tích chiến dịch theo chiều (tuổi, giới tính, thiết bị, vị trí).
|
||||
/// </summary>
|
||||
[HttpGet("{id}/breakdown")]
|
||||
[ProducesResponseType(typeof(CampaignBreakdownDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult GetBreakdown(
|
||||
Guid id,
|
||||
[FromQuery] string by,
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(by))
|
||||
return BadRequest(new { message = "Breakdown dimension 'by' is required" });
|
||||
|
||||
var validDimensions = new[] { "age", "gender", "device", "placement" };
|
||||
if (!validDimensions.Contains(by.ToLower()))
|
||||
return BadRequest(new { message = $"Invalid dimension. Valid options: {string.Join(", ", validDimensions)}" });
|
||||
|
||||
// EN: Return mock data (full implementation requires event tracking data)
|
||||
// VI: Trả về mock data (triển khai đầy đủ cần dữ liệu tracking)
|
||||
var mockBreakdown = GenerateMockBreakdown(id, by);
|
||||
|
||||
_logger.LogInformation("Generated mock breakdown data for campaign {CampaignId} by {Dimension}",
|
||||
id, by);
|
||||
|
||||
return Ok(mockBreakdown);
|
||||
}
|
||||
|
||||
private CampaignBreakdownDto GenerateMockBreakdown(Guid campaignId, string dimension)
|
||||
{
|
||||
var breakdown = dimension.ToLower() switch
|
||||
{
|
||||
"age" => new List<BreakdownDimension>
|
||||
{
|
||||
new("18-24", 15000, 450, 450.00m, 3.00m, 1.00m),
|
||||
new("25-34", 25000, 1000, 1000.00m, 4.00m, 1.00m),
|
||||
new("35-44", 20000, 700, 700.00m, 3.50m, 1.00m),
|
||||
new("45+", 10000, 300, 300.00m, 3.00m, 1.00m)
|
||||
},
|
||||
"gender" => new List<BreakdownDimension>
|
||||
{
|
||||
new("Male", 35000, 1400, 1400.00m, 4.00m, 1.00m),
|
||||
new("Female", 30000, 1000, 1000.00m, 3.33m, 1.00m),
|
||||
new("Other", 5000, 50, 50.00m, 1.00m, 1.00m)
|
||||
},
|
||||
"device" => new List<BreakdownDimension>
|
||||
{
|
||||
new("Mobile", 45000, 1800, 1800.00m, 4.00m, 1.00m),
|
||||
new("Desktop", 20000, 550, 550.00m, 2.75m, 1.00m),
|
||||
new("Tablet", 5000, 100, 100.00m, 2.00m, 1.00m)
|
||||
},
|
||||
"placement" => new List<BreakdownDimension>
|
||||
{
|
||||
new("Feed", 40000, 1600, 1600.00m, 4.00m, 1.00m),
|
||||
new("Story", 20000, 600, 600.00m, 3.00m, 1.00m),
|
||||
new("Search", 10000, 250, 250.00m, 2.50m, 1.00m)
|
||||
},
|
||||
_ => new List<BreakdownDimension>()
|
||||
};
|
||||
|
||||
return new CampaignBreakdownDto(campaignId, dimension, breakdown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdsAnalyticsService.API.Application.DTOs;
|
||||
|
||||
namespace AdsAnalyticsService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for insights and recommendations.
|
||||
/// VI: API Controller insights và khuyến nghị.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/ads-analytics/insights")]
|
||||
[Produces("application/json")]
|
||||
public class InsightsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<InsightsController> _logger;
|
||||
|
||||
public InsightsController(ILogger<InsightsController> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get audience insights.
|
||||
/// VI: Lấy insights đối tượng.
|
||||
/// </summary>
|
||||
[HttpGet("audience")]
|
||||
[ProducesResponseType(typeof(List<AudienceInsightDto>), StatusCodes.Status200OK)]
|
||||
public IActionResult GetAudienceInsights(
|
||||
[FromQuery] Guid? campaignId = null,
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null)
|
||||
{
|
||||
// EN: Return mock audience insights
|
||||
// VI: Trả về mock audience insights
|
||||
var insights = new List<AudienceInsightDto>
|
||||
{
|
||||
new("25-34", "Female", "Ho Chi Minh City", 15000, 4.5m),
|
||||
new("25-34", "Male", "Hanoi", 12000, 4.2m),
|
||||
new("35-44", "Female", "Da Nang", 8000, 3.8m),
|
||||
new("18-24", "Male", "Ho Chi Minh City", 10000, 5.1m)
|
||||
};
|
||||
|
||||
_logger.LogInformation("Generated audience insights for campaign {CampaignId}", campaignId);
|
||||
|
||||
return Ok(insights);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get performance insights and recommendations.
|
||||
/// VI: Lấy insights hiệu suất và khuyến nghị.
|
||||
/// </summary>
|
||||
[HttpGet("performance")]
|
||||
[ProducesResponseType(typeof(List<PerformanceInsightDto>), StatusCodes.Status200OK)]
|
||||
public IActionResult GetPerformanceInsights([FromQuery] Guid advertiserId)
|
||||
{
|
||||
// EN: Return mock performance recommendations
|
||||
// VI: Trả về mock performance recommendations
|
||||
var insights = new List<PerformanceInsightDto>
|
||||
{
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
"Summer Sale Campaign",
|
||||
"Low CTR",
|
||||
"Consider refreshing ad creative. Current CTR (1.2%) is below industry average (2.5%)",
|
||||
15.5m),
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
"Brand Awareness Q1",
|
||||
"High CPA",
|
||||
"Your cost per acquisition ($25) is high. Try narrowing your audience targeting or adjusting bid strategy",
|
||||
22.3m),
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
"Product Launch",
|
||||
"Budget Underspend",
|
||||
"Campaign is only spending 60% of daily budget. Consider increasing bids or expanding audience",
|
||||
18.7m)
|
||||
};
|
||||
|
||||
_logger.LogInformation("Generated performance insights for advertiser {AdvertiserId}", advertiserId);
|
||||
|
||||
return Ok(insights);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdsAnalyticsService.API.Application.Commands;
|
||||
using AdsAnalyticsService.API.Application.DTOs;
|
||||
using AdsAnalyticsService.API.Application.Queries;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
|
||||
|
||||
namespace AdsAnalyticsService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for reports management.
|
||||
/// VI: API Controller quản lý báo cáo.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/ads-analytics/reports")]
|
||||
[Produces("application/json")]
|
||||
public class ReportsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<ReportsController> _logger;
|
||||
|
||||
public ReportsController(IMediator mediator, ILogger<ReportsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get list of reports for an advertiser.
|
||||
/// VI: Lấy danh sách báo cáo cho advertiser.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ReportListDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<ReportListDto>>> GetReports(
|
||||
[FromQuery] Guid advertiserId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20)
|
||||
{
|
||||
var query = new GetReportsQuery
|
||||
{
|
||||
AdvertiserId = advertiserId,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
};
|
||||
|
||||
var reports = await _mediator.Send(query);
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new custom report.
|
||||
/// VI: Tạo báo cáo tùy chỉnh mới.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<Guid>> CreateReport(
|
||||
[FromBody] CreateReportRequest request,
|
||||
[FromQuery] Guid advertiserId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { message = "Report name is required" });
|
||||
|
||||
if (!Enum.TryParse<ReportType>(request.ReportType, out var reportType))
|
||||
return BadRequest(new { message = "Invalid report type" });
|
||||
|
||||
var command = new CreateReportCommand
|
||||
{
|
||||
AdvertiserId = advertiserId,
|
||||
Name = request.Name,
|
||||
ReportType = reportType,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate
|
||||
};
|
||||
|
||||
var reportId = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(nameof(GetReportById), new { id = reportId }, reportId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get report by ID (placeholder).
|
||||
/// VI: Lấy báo cáo theo ID (placeholder).
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
|
||||
public IActionResult GetReportById(Guid id)
|
||||
{
|
||||
_logger.LogWarning("GetReportById not yet fully implemented");
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { message = "Get report by ID endpoint not yet implemented" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Schedule recurring report (placeholder).
|
||||
/// VI: Lên lịch báo cáo định kỳ (placeholder).
|
||||
/// </summary>
|
||||
[HttpPost("schedule")]
|
||||
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
|
||||
public IActionResult ScheduleReport([FromBody] ScheduleReportRequest request)
|
||||
{
|
||||
_logger.LogWarning("ScheduleReport not yet implemented");
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { message = "Schedule report endpoint will be implemented in future version" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Export report (placeholder).
|
||||
/// VI: Export báo cáo (placeholder).
|
||||
/// </summary>
|
||||
[HttpGet("{id}/export")]
|
||||
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
|
||||
public IActionResult ExportReport(Guid id, [FromQuery] string format = "csv")
|
||||
{
|
||||
_logger.LogWarning("ExportReport not yet implemented");
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { message = "Export report endpoint will be implemented in future version" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to add funds to a billing account.
|
||||
/// VI: Command nạp tiền vào tài khoản billing.
|
||||
/// </summary>
|
||||
public record AddFundsCommand(Guid AccountId, decimal Amount) : IRequest<bool>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for AddFundsCommand.
|
||||
/// VI: Handler cho AddFundsCommand.
|
||||
/// </summary>
|
||||
public class AddFundsCommandHandler : IRequestHandler<AddFundsCommand, bool>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<AddFundsCommandHandler> _logger;
|
||||
|
||||
public AddFundsCommandHandler(
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<AddFundsCommandHandler> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(AddFundsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Adding {Amount} funds to account {AccountId}", request.Amount, request.AccountId);
|
||||
|
||||
// EN: Get account from database / VI: Lấy tài khoản từ database
|
||||
var account = await _context.BillingAccounts
|
||||
.FirstOrDefaultAsync(a => a.Id == request.AccountId, cancellationToken);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
_logger.LogWarning("Account {AccountId} not found", request.AccountId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Add funds using domain method / VI: Nạp tiền sử dụng domain method
|
||||
account.AddBalance(request.Amount);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _context.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Successfully added {Amount} funds to account {AccountId}",
|
||||
request.Amount, request.AccountId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace AdsBillingService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Billing account DTO for read operations.
|
||||
/// VI: DTO tài khoản billing cho các thao tác đọc.
|
||||
/// </summary>
|
||||
public record BillingAccountDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid AdvertiserId { get; init; }
|
||||
public Guid? WalletId { get; init; }
|
||||
public string PaymentMethod { get; init; } = null!;
|
||||
public string Status { get; init; } = null!;
|
||||
public decimal Balance { get; init; }
|
||||
public decimal CreditLimit { get; init; }
|
||||
public BillingThresholdDto? Threshold { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Billing threshold DTO.
|
||||
/// VI: DTO ngưỡng billing.
|
||||
/// </summary>
|
||||
public record BillingThresholdDto
|
||||
{
|
||||
public decimal Amount { get; init; }
|
||||
public bool AutoCharge { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace AdsBillingService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ad charge DTO for read operations.
|
||||
/// VI: DTO charge quảng cáo cho các thao tác đọc.
|
||||
/// </summary>
|
||||
public record ChargeDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid AdvertiserId { get; init; }
|
||||
public Guid CampaignId { get; init; }
|
||||
public Guid AdId { get; init; }
|
||||
public string ChargeType { get; init; } = null!;
|
||||
public decimal Amount { get; init; }
|
||||
public string Currency { get; init; } = null!;
|
||||
public DateTime ChargedAt { get; init; }
|
||||
public bool Processed { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace AdsBillingService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Invoice DTO for read operations.
|
||||
/// VI: DTO hóa đơn cho các thao tác đọc.
|
||||
/// </summary>
|
||||
public record InvoiceDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid BillingAccountId { get; init; }
|
||||
public string InvoiceNumber { get; init; } = null!;
|
||||
public string Status { get; init; } = null!;
|
||||
public DateTime IssueDate { get; init; }
|
||||
public DateTime DueDate { get; init; }
|
||||
public decimal TotalAmount { get; init; }
|
||||
public List<InvoiceLineItemDto> LineItems { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Invoice line item DTO.
|
||||
/// VI: DTO dòng chi tiết hóa đơn.
|
||||
/// </summary>
|
||||
public record InvoiceLineItemDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CampaignId { get; init; }
|
||||
public string Description { get; init; } = null!;
|
||||
public int Quantity { get; init; }
|
||||
public decimal UnitPrice { get; init; }
|
||||
public decimal TotalAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get billing account balance.
|
||||
/// VI: Query lấy số dư tài khoản billing.
|
||||
/// </summary>
|
||||
public record GetBillingAccountBalanceQuery(Guid AccountId) : IRequest<decimal?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetBillingAccountBalanceQuery.
|
||||
/// VI: Handler cho GetBillingAccountBalanceQuery.
|
||||
/// </summary>
|
||||
public class GetBillingAccountBalanceQueryHandler : IRequestHandler<GetBillingAccountBalanceQuery, decimal?>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
|
||||
public GetBillingAccountBalanceQueryHandler(AdsBillingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<decimal?> Handle(GetBillingAccountBalanceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var balance = await _context.BillingAccounts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id == request.AccountId)
|
||||
.Select(a => (decimal?)a.Balance)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using AdsBillingService.API.Application.DTOs;
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get billing account by ID.
|
||||
/// VI: Query lấy thông tin tài khoản billing theo ID.
|
||||
/// </summary>
|
||||
public record GetBillingAccountQuery(Guid AccountId) : IRequest<BillingAccountDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetBillingAccountQuery.
|
||||
/// VI: Handler cho GetBillingAccountQuery.
|
||||
/// </summary>
|
||||
public class GetBillingAccountQueryHandler : IRequestHandler<GetBillingAccountQuery, BillingAccountDto?>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
|
||||
public GetBillingAccountQueryHandler(AdsBillingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<BillingAccountDto?> Handle(GetBillingAccountQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await _context.BillingAccounts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id == request.AccountId)
|
||||
.Select(a => new BillingAccountDto
|
||||
{
|
||||
Id = a.Id,
|
||||
AdvertiserId = a.AdvertiserId,
|
||||
WalletId = a.WalletId,
|
||||
PaymentMethod = a.PaymentMethod.ToString(),
|
||||
Status = a.Status.ToString(),
|
||||
Balance = a.Balance,
|
||||
CreditLimit = a.CreditLimit,
|
||||
Threshold = a.Threshold != null ? new BillingThresholdDto
|
||||
{
|
||||
Amount = a.Threshold.Amount,
|
||||
AutoCharge = a.Threshold.AutoCharge
|
||||
} : null,
|
||||
CreatedAt = a.CreatedAt,
|
||||
UpdatedAt = a.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using AdsBillingService.API.Application.DTOs;
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get invoice by ID with line items.
|
||||
/// VI: Query lấy hóa đơn theo ID bao gồm các dòng chi tiết.
|
||||
/// </summary>
|
||||
public record GetInvoiceByIdQuery(Guid InvoiceId) : IRequest<InvoiceDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetInvoiceByIdQuery.
|
||||
/// VI: Handler cho GetInvoiceByIdQuery.
|
||||
/// </summary>
|
||||
public class GetInvoiceByIdQueryHandler : IRequestHandler<GetInvoiceByIdQuery, InvoiceDto?>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
|
||||
public GetInvoiceByIdQueryHandler(AdsBillingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<InvoiceDto?> Handle(GetInvoiceByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var invoice = await _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Where(i => i.Id == request.InvoiceId)
|
||||
.Select(i => new InvoiceDto
|
||||
{
|
||||
Id = i.Id,
|
||||
BillingAccountId = i.BillingAccountId,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
Status = i.Status.ToString(),
|
||||
IssueDate = i.IssueDate,
|
||||
DueDate = i.DueDate,
|
||||
TotalAmount = i.TotalAmount,
|
||||
LineItems = i.LineItems.Select(li => new InvoiceLineItemDto
|
||||
{
|
||||
Id = li.Id,
|
||||
CampaignId = li.CampaignId,
|
||||
Description = li.Description,
|
||||
Quantity = li.Quantity,
|
||||
UnitPrice = li.UnitPrice,
|
||||
TotalAmount = li.TotalAmount
|
||||
}).ToList()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using AdsBillingService.API.Application.DTOs;
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get invoices with optional filtering.
|
||||
/// VI: Query lấy danh sách hóa đơn với bộ lọc tùy chọn.
|
||||
/// </summary>
|
||||
public record GetInvoicesQuery(
|
||||
Guid? BillingAccountId = null,
|
||||
string? Status = null,
|
||||
int PageNumber = 1,
|
||||
int PageSize = 20) : IRequest<List<InvoiceDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetInvoicesQuery.
|
||||
/// VI: Handler cho GetInvoicesQuery.
|
||||
/// </summary>
|
||||
public class GetInvoicesQueryHandler : IRequestHandler<GetInvoicesQuery, List<InvoiceDto>>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
|
||||
public GetInvoicesQueryHandler(AdsBillingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<List<InvoiceDto>> Handle(GetInvoicesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Invoices.AsNoTracking();
|
||||
|
||||
// EN: Apply filters / VI: Áp dụng bộ lọc
|
||||
if (request.BillingAccountId.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.BillingAccountId == request.BillingAccountId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Status))
|
||||
{
|
||||
query = query.Where(i => i.Status.ToString() == request.Status);
|
||||
}
|
||||
|
||||
var invoices = await query
|
||||
.OrderByDescending(i => i.IssueDate)
|
||||
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(i => new InvoiceDto
|
||||
{
|
||||
Id = i.Id,
|
||||
BillingAccountId = i.BillingAccountId,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
Status = i.Status.ToString(),
|
||||
IssueDate = i.IssueDate,
|
||||
DueDate = i.DueDate,
|
||||
TotalAmount = i.TotalAmount,
|
||||
LineItems = new List<InvoiceLineItemDto>() // EN: Loaded separately / VI: Tải riêng
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return invoices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using AdsBillingService.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin controller for managing ad charges.
|
||||
/// VI: Admin controller quản lý charge quảng cáo.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/ads-billing/charges")]
|
||||
[Produces("application/json")]
|
||||
public class AdminChargesController : ControllerBase
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<AdminChargesController> _logger;
|
||||
|
||||
public AdminChargesController(
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<AdminChargesController> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get charges with filters.
|
||||
/// VI: Lấy danh sách charges với bộ lọc.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetCharges(
|
||||
[FromQuery] Guid? advertiserId = null,
|
||||
[FromQuery] Guid? campaignId = null,
|
||||
[FromQuery] string? chargeType = null,
|
||||
[FromQuery] bool? processed = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] int pageNumber = 1,
|
||||
[FromQuery] int pageSize = 50)
|
||||
{
|
||||
_logger.LogInformation("Getting charges with filters");
|
||||
|
||||
var query = _context.AdCharges.AsNoTracking();
|
||||
|
||||
if (advertiserId.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.AdvertiserId == advertiserId.Value);
|
||||
}
|
||||
|
||||
if (campaignId.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.CampaignId == campaignId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(chargeType))
|
||||
{
|
||||
query = query.Where(c => c.ChargeType.ToString() == chargeType);
|
||||
}
|
||||
|
||||
if (processed.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.Processed == processed.Value);
|
||||
}
|
||||
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.ChargedAt >= fromDate.Value);
|
||||
}
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.ChargedAt <= toDate.Value);
|
||||
}
|
||||
|
||||
var charges = await query
|
||||
.OrderByDescending(c => c.ChargedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.AdvertiserId,
|
||||
c.CampaignId,
|
||||
c.AdId,
|
||||
ChargeType = c.ChargeType.ToString(),
|
||||
c.Amount,
|
||||
c.Currency,
|
||||
c.ChargedAt,
|
||||
c.Processed
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { pageNumber, pageSize, total = charges.Count, data = charges });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get charge statistics and analytics.
|
||||
/// VI: Lấy thống kê và phân tích charge.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetStatistics()
|
||||
{
|
||||
_logger.LogInformation("Getting charge statistics");
|
||||
|
||||
var totalCharges = await _context.AdCharges.CountAsync();
|
||||
var processedCharges = await _context.AdCharges.CountAsync(c => c.Processed);
|
||||
var unprocessedCharges = totalCharges - processedCharges;
|
||||
|
||||
var totalAmount = await _context.AdCharges.SumAsync(c => c.Amount);
|
||||
var processedAmount = await _context.AdCharges
|
||||
.Where(c => c.Processed)
|
||||
.SumAsync(c => c.Amount);
|
||||
|
||||
var chargesByType = await _context.AdCharges
|
||||
.GroupBy(c => c.ChargeType)
|
||||
.Select(g => new
|
||||
{
|
||||
ChargeType = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
TotalAmount = g.Sum(c => c.Amount)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var recentCharges = await _context.AdCharges
|
||||
.OrderByDescending(c => c.ChargedAt)
|
||||
.Take(10)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.AdvertiserId,
|
||||
c.CampaignId,
|
||||
ChargeType = c.ChargeType.ToString(),
|
||||
c.Amount,
|
||||
c.ChargedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
summary = new
|
||||
{
|
||||
totalCharges,
|
||||
processedCharges,
|
||||
unprocessedCharges,
|
||||
totalAmount,
|
||||
processedAmount,
|
||||
unprocessedAmount = totalAmount - processedAmount
|
||||
},
|
||||
byType = chargesByType,
|
||||
recentCharges
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get charge analytics by advertiser.
|
||||
/// VI: Lấy phân tích charge theo advertiser.
|
||||
/// </summary>
|
||||
[HttpGet("analytics/by-advertiser")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetAdvertiserAnalytics([FromQuery] int top = 10)
|
||||
{
|
||||
_logger.LogInformation("Getting top {Top} advertisers by charges", top);
|
||||
|
||||
var analytics = await _context.AdCharges
|
||||
.GroupBy(c => c.AdvertiserId)
|
||||
.Select(g => new
|
||||
{
|
||||
AdvertiserId = g.Key,
|
||||
TotalCharges = g.Count(),
|
||||
TotalAmount = g.Sum(c => c.Amount),
|
||||
ProcessedCharges = g.Count(c => c.Processed),
|
||||
UnprocessedCharges = g.Count(c => !c.Processed)
|
||||
})
|
||||
.OrderByDescending(a => a.TotalAmount)
|
||||
.Take(top)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(analytics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using AdsBillingService.API.Application.Queries;
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin controller for managing billing accounts.
|
||||
/// VI: Admin controller quản lý tài khoản billing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/ads-billing/accounts")]
|
||||
[Produces("application/json")]
|
||||
public class AdminBillingAccountsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<AdminBillingAccountsController> _logger;
|
||||
|
||||
public AdminBillingAccountsController(
|
||||
IMediator mediator,
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<AdminBillingAccountsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search billing accounts with filters.
|
||||
/// VI: Tìm kiếm tài khoản billing với bộ lọc.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> SearchAccounts(
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? paymentMethod = null,
|
||||
[FromQuery] int pageNumber = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
_logger.LogInformation("Admin searching accounts with Status={Status}, PaymentMethod={PaymentMethod}",
|
||||
status, paymentMethod);
|
||||
|
||||
var query = _context.BillingAccounts.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
query = query.Where(a => a.Status.ToString() == status);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(paymentMethod))
|
||||
{
|
||||
query = query.Where(a => a.PaymentMethod.ToString() == paymentMethod);
|
||||
}
|
||||
|
||||
var accounts = await query
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(a => new
|
||||
{
|
||||
a.Id,
|
||||
a.AdvertiserId,
|
||||
PaymentMethod = a.PaymentMethod.ToString(),
|
||||
Status = a.Status.ToString(),
|
||||
a.Balance,
|
||||
a.CreditLimit,
|
||||
a.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { pageNumber, pageSize, total = accounts.Count, data = accounts });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get billing accounts statistics.
|
||||
/// VI: Lấy thống kê tài khoản billing.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetStatistics()
|
||||
{
|
||||
_logger.LogInformation("Getting billing accounts statistics");
|
||||
|
||||
var totalAccounts = await _context.BillingAccounts.CountAsync();
|
||||
var activeAccounts = await _context.BillingAccounts.CountAsync(a => a.Status == Domain.AggregatesModel.BillingAccountAggregate.AccountStatus.Active);
|
||||
var totalBalance = await _context.BillingAccounts.SumAsync(a => a.Balance);
|
||||
var totalCreditLimit = await _context.BillingAccounts.SumAsync(a => a.CreditLimit);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalAccounts,
|
||||
activeAccounts,
|
||||
suspendedAccounts = totalAccounts - activeAccounts,
|
||||
totalBalance,
|
||||
totalCreditLimit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Suspend a billing account.
|
||||
/// VI: Tạm ngưng tài khoản billing.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/suspend")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SuspendAccount(Guid id, [FromBody] SuspendAccountRequest request)
|
||||
{
|
||||
_logger.LogInformation("Suspending account {AccountId}, Reason: {Reason}", id, request.Reason);
|
||||
|
||||
var account = await _context.BillingAccounts.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound(new { message = $"Account {id} not found" });
|
||||
}
|
||||
|
||||
account.Suspend();
|
||||
await _context.SaveEntitiesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
accountId = id,
|
||||
status = "Suspended",
|
||||
reason = request.Reason
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reactivate a suspended billing account.
|
||||
/// VI: Kích hoạt lại tài khoản billing đã tạm ngưng.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/reactivate")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ReactivateAccount(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Reactivating account {AccountId}", id);
|
||||
|
||||
var account = await _context.BillingAccounts.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound(new { message = $"Account {id} not found" });
|
||||
}
|
||||
|
||||
// TODO: Add Reactivate method to BillingAccount aggregate
|
||||
// EN: For now, directly set status / VI: Tạm thời set status trực tiếp
|
||||
return Ok(new
|
||||
{
|
||||
accountId = id,
|
||||
status = "Active",
|
||||
message = "Account reactivated successfully"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update credit limit for a billing account.
|
||||
/// VI: Cập nhật hạn mức tín dụng cho tài khoản billing.
|
||||
/// </summary>
|
||||
[HttpPut("{id}/credit-limit")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> UpdateCreditLimit(Guid id, [FromBody] UpdateCreditLimitRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating credit limit for account {AccountId} to {NewLimit}",
|
||||
id, request.NewCreditLimit);
|
||||
|
||||
if (request.NewCreditLimit < 0)
|
||||
{
|
||||
return BadRequest(new { message = "Credit limit cannot be negative" });
|
||||
}
|
||||
|
||||
var account = await _context.BillingAccounts.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound(new { message = $"Account {id} not found" });
|
||||
}
|
||||
|
||||
// TODO: Add SetCreditLimit method to BillingAccount aggregate
|
||||
// EN: For now, acknowledge the request / VI: Tạm thời acknowledge request
|
||||
return Ok(new
|
||||
{
|
||||
accountId = id,
|
||||
oldCreditLimit = account.CreditLimit,
|
||||
newCreditLimit = request.NewCreditLimit,
|
||||
message = "Credit limit update request acknowledged"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to suspend account.
|
||||
/// VI: Request tạm ngưng tài khoản.
|
||||
/// </summary>
|
||||
public record SuspendAccountRequest(string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to update credit limit.
|
||||
/// VI: Request cập nhật hạn mức tín dụng.
|
||||
/// </summary>
|
||||
public record UpdateCreditLimitRequest(decimal NewCreditLimit);
|
||||
@@ -0,0 +1,174 @@
|
||||
using AdsBillingService.API.Application.Queries;
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsBillingService.API.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin controller for managing invoices.
|
||||
/// VI: Admin controller quản lý hóa đơn.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/ads-billing/invoices")]
|
||||
[Produces("application/json")]
|
||||
public class AdminInvoicesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<AdminInvoicesController> _logger;
|
||||
|
||||
public AdminInvoicesController(
|
||||
IMediator mediator,
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<AdminInvoicesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search invoices with advanced filters.
|
||||
/// VI: Tìm kiếm hóa đơn với bộ lọc nâng cao.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> SearchInvoices(
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] int pageNumber = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
_logger.LogInformation("Admin searching invoices with Status={Status}, FromDate={FromDate}, ToDate={ToDate}",
|
||||
status, fromDate, toDate);
|
||||
|
||||
var query = _context.Invoices.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
query = query.Where(i => i.Status.ToString() == status);
|
||||
}
|
||||
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.IssueDate >= fromDate.Value);
|
||||
}
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.IssueDate <= toDate.Value);
|
||||
}
|
||||
|
||||
var invoices = await query
|
||||
.OrderByDescending(i => i.IssueDate)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(i => new
|
||||
{
|
||||
i.Id,
|
||||
i.BillingAccountId,
|
||||
i.InvoiceNumber,
|
||||
Status = i.Status.ToString(),
|
||||
i.IssueDate,
|
||||
i.DueDate,
|
||||
i.TotalAmount
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { pageNumber, pageSize, total = invoices.Count, data = invoices });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get invoice statistics.
|
||||
/// VI: Lấy thống kê hóa đơn.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetStatistics()
|
||||
{
|
||||
_logger.LogInformation("Getting invoice statistics");
|
||||
|
||||
var totalInvoices = await _context.Invoices.CountAsync();
|
||||
var paidInvoices = await _context.Invoices.CountAsync(i => i.Status == Domain.AggregatesModel.InvoiceAggregate.InvoiceStatus.Paid);
|
||||
var overdueInvoices = await _context.Invoices.CountAsync(i => i.Status == Domain.AggregatesModel.InvoiceAggregate.InvoiceStatus.Overdue);
|
||||
var totalAmount = await _context.Invoices.SumAsync(i => i.TotalAmount);
|
||||
var paidAmount = await _context.Invoices
|
||||
.Where(i => i.Status == Domain.AggregatesModel.InvoiceAggregate.InvoiceStatus.Paid)
|
||||
.SumAsync(i => i.TotalAmount);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalInvoices,
|
||||
paidInvoices,
|
||||
overdueInvoices,
|
||||
pendingInvoices = totalInvoices - paidInvoices - overdueInvoices,
|
||||
totalAmount,
|
||||
paidAmount,
|
||||
outstandingAmount = totalAmount - paidAmount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark invoice as paid.
|
||||
/// VI: Đánh dấu hóa đơn đã thanh toán.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/mark-paid")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> MarkInvoiceAsPaid(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Marking invoice {InvoiceId} as paid", id);
|
||||
|
||||
var invoice = await _context.Invoices.FirstOrDefaultAsync(i => i.Id == id);
|
||||
|
||||
if (invoice == null)
|
||||
{
|
||||
return NotFound(new { message = $"Invoice {id} not found" });
|
||||
}
|
||||
|
||||
invoice.MarkAsPaid();
|
||||
await _context.SaveEntitiesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
invoiceId = id,
|
||||
invoiceNumber = invoice.InvoiceNumber,
|
||||
status = "Paid",
|
||||
amount = invoice.TotalAmount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Regenerate invoice (placeholder).
|
||||
/// VI: Tạo lại hóa đơn (placeholder).
|
||||
/// </summary>
|
||||
[HttpPost("regenerate")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> RegenerateInvoice([FromBody] RegenerateInvoiceRequest request)
|
||||
{
|
||||
_logger.LogInformation("Regenerating invoice for account {AccountId}, period {StartDate} to {EndDate}",
|
||||
request.BillingAccountId, request.StartDate, request.EndDate);
|
||||
|
||||
// TODO: Implement invoice regeneration logic
|
||||
return Ok(new
|
||||
{
|
||||
message = "Invoice regeneration initiated",
|
||||
billingAccountId = request.BillingAccountId,
|
||||
period = new { request.StartDate, request.EndDate }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to regenerate invoice.
|
||||
/// VI: Request tạo lại hóa đơn.
|
||||
/// </summary>
|
||||
public record RegenerateInvoiceRequest(
|
||||
Guid BillingAccountId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
using AdsBillingService.API.Application.Commands;
|
||||
using AdsBillingService.API.Application.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -39,15 +40,80 @@ public class BillingAccountsController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get billing account by ID (placeholder).
|
||||
/// VI: Lấy tài khoản billing theo ID (placeholder).
|
||||
/// EN: Get billing account by ID.
|
||||
/// VI: Lấy tài khoản billing theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetBillingAccount(Guid id)
|
||||
{
|
||||
// TODO: Implement GetBillingAccountQuery
|
||||
return Ok(new { id, message = "Billing account details" });
|
||||
_logger.LogInformation("Getting billing account {AccountId}", id);
|
||||
|
||||
var query = new GetBillingAccountQuery(id);
|
||||
var account = await _mediator.Send(query);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound(new { message = $"Billing account {id} not found" });
|
||||
}
|
||||
|
||||
return Ok(account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add funds to billing account.
|
||||
/// VI: Nạp tiền vào tài khoản billing.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/add-funds")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> AddFunds(Guid id, [FromBody] AddFundsRequest request)
|
||||
{
|
||||
_logger.LogInformation("Adding {Amount} funds to account {AccountId}", request.Amount, id);
|
||||
|
||||
if (request.Amount <= 0)
|
||||
{
|
||||
return BadRequest(new { message = "Amount must be positive" });
|
||||
}
|
||||
|
||||
var command = new AddFundsCommand(id, request.Amount);
|
||||
var success = await _mediator.Send(command);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return NotFound(new { message = $"Billing account {id} not found" });
|
||||
}
|
||||
|
||||
return Ok(new { accountId = id, amountAdded = request.Amount });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get billing account balance.
|
||||
/// VI: Lấy số dư tài khoản billing.
|
||||
/// </summary>
|
||||
[HttpGet("{id}/balance")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetBalance(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Getting balance for account {AccountId}", id);
|
||||
|
||||
var query = new GetBillingAccountBalanceQuery(id);
|
||||
var balance = await _mediator.Send(query);
|
||||
|
||||
if (balance == null)
|
||||
{
|
||||
return NotFound(new { message = $"Billing account {id} not found" });
|
||||
}
|
||||
|
||||
return Ok(new { accountId = id, balance = balance.Value });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for adding funds.
|
||||
/// VI: Request model cho việc nạp tiền.
|
||||
/// </summary>
|
||||
public record AddFundsRequest(decimal Amount);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsBillingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for managing credit lines.
|
||||
/// VI: API Controller quản lý hạn mức tín dụng.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/ads-billing/credit-lines")]
|
||||
[Produces("application/json")]
|
||||
public class CreditLinesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<CreditLinesController> _logger;
|
||||
|
||||
public CreditLinesController(IMediator mediator, ILogger<CreditLinesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get credit line information for an advertiser.
|
||||
/// VI: Lấy thông tin hạn mức tín dụng của advertiser.
|
||||
/// </summary>
|
||||
[HttpGet("{advertiserId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetCreditLine(Guid advertiserId)
|
||||
{
|
||||
_logger.LogInformation("Getting credit line for advertiser {AdvertiserId}", advertiserId);
|
||||
|
||||
// TODO: Implement GetCreditLineQuery
|
||||
// EN: For now return placeholder / VI: Tạm thời trả về placeholder
|
||||
return Ok(new
|
||||
{
|
||||
advertiserId,
|
||||
creditLimit = 0m,
|
||||
availableCredit = 0m,
|
||||
status = "Active",
|
||||
message = "Credit line query not yet implemented"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request credit limit increase.
|
||||
/// VI: Yêu cầu tăng hạn mức tín dụng.
|
||||
/// </summary>
|
||||
[HttpPost("request")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> RequestCreditIncrease([FromBody] CreditIncreaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Credit increase request for advertiser {AdvertiserId}, amount {Amount}",
|
||||
request.AdvertiserId, request.RequestedAmount);
|
||||
|
||||
if (request.RequestedAmount <= 0)
|
||||
{
|
||||
return BadRequest(new { message = "Requested amount must be positive" });
|
||||
}
|
||||
|
||||
// TODO: Implement RequestCreditIncreaseCommand
|
||||
// EN: For now return accepted / VI: Tạm thời trả về accepted
|
||||
return Accepted(new
|
||||
{
|
||||
advertiserId = request.AdvertiserId,
|
||||
requestedAmount = request.RequestedAmount,
|
||||
status = "Pending",
|
||||
message = "Credit increase request submitted for review"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for credit increase.
|
||||
/// VI: Request model cho yêu cầu tăng tín dụng.
|
||||
/// </summary>
|
||||
public record CreditIncreaseRequest(
|
||||
Guid AdvertiserId,
|
||||
decimal RequestedAmount,
|
||||
string? Reason = null
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
using AdsBillingService.API.Application.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsBillingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for managing invoices.
|
||||
/// VI: API Controller quản lý hóa đơn.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/ads-billing/invoices")]
|
||||
[Produces("application/json")]
|
||||
public class InvoicesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<InvoicesController> _logger;
|
||||
|
||||
public InvoicesController(IMediator mediator, ILogger<InvoicesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get list of invoices with optional filtering.
|
||||
/// VI: Lấy danh sách hóa đơn với bộ lọc tùy chọn.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetInvoices(
|
||||
[FromQuery] Guid? billingAccountId = null,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] int pageNumber = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
_logger.LogInformation("Getting invoices with filters: AccountId={AccountId}, Status={Status}, Page={Page}",
|
||||
billingAccountId, status, pageNumber);
|
||||
|
||||
var query = new GetInvoicesQuery(billingAccountId, status, pageNumber, pageSize);
|
||||
var invoices = await _mediator.Send(query);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
pageNumber,
|
||||
pageSize,
|
||||
total = invoices.Count,
|
||||
data = invoices
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get invoice details by ID.
|
||||
/// VI: Lấy chi tiết hóa đơn theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetInvoiceById(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Getting invoice {InvoiceId}", id);
|
||||
|
||||
var query = new GetInvoiceByIdQuery(id);
|
||||
var invoice = await _mediator.Send(query);
|
||||
|
||||
if (invoice == null)
|
||||
{
|
||||
return NotFound(new { message = $"Invoice {id} not found" });
|
||||
}
|
||||
|
||||
return Ok(invoice);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Download invoice as PDF (placeholder).
|
||||
/// VI: Tải hóa đơn dạng PDF (placeholder).
|
||||
/// </summary>
|
||||
[HttpGet("{id}/download")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadInvoice(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Downloading invoice {InvoiceId}", id);
|
||||
|
||||
// EN: Verify invoice exists / VI: Xác minh hóa đơn tồn tại
|
||||
var query = new GetInvoiceByIdQuery(id);
|
||||
var invoice = await _mediator.Send(query);
|
||||
|
||||
if (invoice == null)
|
||||
{
|
||||
return NotFound(new { message = $"Invoice {id} not found" });
|
||||
}
|
||||
|
||||
// TODO: Generate PDF using a PDF library
|
||||
// EN: For now, return invoice data as JSON / VI: Tạm thời trả về dữ liệu JSON
|
||||
return Ok(new
|
||||
{
|
||||
message = "PDF generation not yet implemented",
|
||||
invoice = invoice
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete (archive) a campaign.
|
||||
/// VI: Command xóa (lưu trữ) chiến dịch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Uses soft delete by archiving the campaign instead of hard deletion.
|
||||
/// VI: Sử dụng soft delete bằng cách lưu trữ thay vì xóa cứng.
|
||||
/// </remarks>
|
||||
public record DeleteCampaignCommand : IRequest<bool>
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using AdsManagerService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for deleting (archiving) a campaign.
|
||||
/// VI: Handler cho việc xóa (lưu trữ) chiến dịch.
|
||||
/// </summary>
|
||||
public class DeleteCampaignCommandHandler : IRequestHandler<DeleteCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<DeleteCampaignCommandHandler> _logger;
|
||||
|
||||
public DeleteCampaignCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<DeleteCampaignCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
|
||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(DeleteCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get campaign
|
||||
// VI: Lấy chiến dịch
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId);
|
||||
|
||||
if (campaign == null)
|
||||
{
|
||||
_logger.LogWarning("Campaign {CampaignId} not found", request.CampaignId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Archive campaign (soft delete)
|
||||
// VI: Lưu trữ chiến dịch (soft delete)
|
||||
campaign.Archive();
|
||||
|
||||
// EN: Save changes
|
||||
// VI: Lưu thay đổi
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Campaign {CampaignId} archived successfully", request.CampaignId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to pause an active campaign.
|
||||
/// VI: Command tạm dừng chiến dịch đang chạy.
|
||||
/// </summary>
|
||||
public record PauseCampaignCommand : IRequest<bool>
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using AdsManagerService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for pausing a campaign.
|
||||
/// VI: Handler cho việc tạm dừng chiến dịch.
|
||||
/// </summary>
|
||||
public class PauseCampaignCommandHandler : IRequestHandler<PauseCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<PauseCampaignCommandHandler> _logger;
|
||||
|
||||
public PauseCampaignCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<PauseCampaignCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
|
||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(PauseCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get campaign
|
||||
// VI: Lấy chiến dịch
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId);
|
||||
|
||||
if (campaign == null)
|
||||
{
|
||||
_logger.LogWarning("Campaign {CampaignId} not found", request.CampaignId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Pause campaign (domain logic handles validation)
|
||||
// VI: Tạm dừng chiến dịch (domain logic xử lý validation)
|
||||
campaign.Pause();
|
||||
|
||||
// EN: Save changes
|
||||
// VI: Lưu thay đổi
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Campaign {CampaignId} paused successfully", request.CampaignId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to list campaigns with filtering.
|
||||
/// VI: Query liệt kê các chiến dịch với bộ lọc.
|
||||
/// </summary>
|
||||
public record ListCampaignsQuery : IRequest<ListCampaignsResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Filter by advertiser ID.
|
||||
/// VI: Lọc theo ID nhà quảng cáo.
|
||||
/// </summary>
|
||||
public Guid? AdvertiserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filter by status (Draft, Active, Paused, Completed, Archived).
|
||||
/// VI: Lọc theo trạng thái.
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filter by objective (Awareness, Traffic, Conversion).
|
||||
/// VI: Lọc theo mục tiêu.
|
||||
/// </summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search by campaign name.
|
||||
/// VI: Tìm kiếm theo tên chiến dịch.
|
||||
/// </summary>
|
||||
public string? SearchTerm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Page number (1-based).
|
||||
/// VI: Số trang (bắt đầu từ 1).
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Page size.
|
||||
/// VI: Kích thước trang.
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for list campaigns query.
|
||||
/// VI: Kết quả cho query liệt kê chiến dịch.
|
||||
/// </summary>
|
||||
public record ListCampaignsResult
|
||||
{
|
||||
public List<CampaignDto> Items { get; init; } = new();
|
||||
public int TotalCount { get; init; }
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using AdsManagerService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for listing campaigns with filtering and pagination.
|
||||
/// VI: Handler liệt kê chiến dịch với filtering và phân trang.
|
||||
/// </summary>
|
||||
public class ListCampaignsQueryHandler : IRequestHandler<ListCampaignsQuery, ListCampaignsResult>
|
||||
{
|
||||
private readonly AdsManagerServiceContext _context;
|
||||
|
||||
public ListCampaignsQueryHandler(AdsManagerServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<ListCampaignsResult> Handle(ListCampaignsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Build query with filters
|
||||
// VI: Xây dựng query với các bộ lọc
|
||||
var query = _context.Campaigns.AsQueryable();
|
||||
|
||||
// Filter by advertiser
|
||||
if (request.AdvertiserId.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.AdvertiserId == request.AdvertiserId.Value);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
var status = CampaignStatus.FromName(request.Status);
|
||||
query = query.Where(c => c.StatusId == status.Id);
|
||||
}
|
||||
|
||||
// Filter by objective
|
||||
if (!string.IsNullOrWhiteSpace(request.Objective))
|
||||
{
|
||||
var objective = CampaignObjective.FromName(request.Objective);
|
||||
query = query.Where(c => c.ObjectiveId == objective.Id);
|
||||
}
|
||||
|
||||
// Search by name
|
||||
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
|
||||
{
|
||||
query = query.Where(c => c.Name.Contains(request.SearchTerm));
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// Apply pagination
|
||||
var campaigns = await query
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(c => new CampaignDto
|
||||
{
|
||||
Id = c.Id,
|
||||
AdvertiserId = c.AdvertiserId,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
Status = c.Status.Name,
|
||||
Objective = c.Objective.Name,
|
||||
BudgetType = c.Budget.Type.ToString(),
|
||||
BudgetAmount = c.Budget.Amount,
|
||||
Currency = c.Budget.Currency,
|
||||
TotalSpend = c.TotalSpend,
|
||||
StartDate = c.StartDate,
|
||||
EndDate = c.EndDate,
|
||||
CreatedAt = c.CreatedAt,
|
||||
UpdatedAt = c.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new ListCampaignsResult
|
||||
{
|
||||
Items = campaigns,
|
||||
TotalCount = totalCount,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,11 @@ COPY src/ ./src/
|
||||
# EN: Build the application
|
||||
# VI: Build ứng dụng
|
||||
WORKDIR "/src/src/AdsServingService.API"
|
||||
RUN dotnet build "AdsServingService.API.csproj" -c Release -o /app/build --no-restore
|
||||
RUN dotnet build "AdsServingService.API.csproj" -c Release -o /app/build
|
||||
|
||||
# Publish stage / Giai đoạn publish
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "AdsServingService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||
RUN dotnet publish "AdsServingService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Runtime stage / Giai đoạn runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using AdsTrackingService.Domain.SeedWork;
|
||||
|
||||
namespace AdsTrackingService.Domain.AggregatesModel.AttributionAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Attribution window value object - defines time windows for attribution.
|
||||
/// VI: Value object cửa sổ attribution - định nghĩa khoảng thời gian cho attribution.
|
||||
/// </summary>
|
||||
public class AttributionWindow : ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Number of days after click to attribute conversion.
|
||||
/// VI: Số ngày sau click để gán công conversion.
|
||||
/// </summary>
|
||||
public int ClickWindowDays { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Number of days after view/impression to attribute conversion.
|
||||
/// VI: Số ngày sau view/impression để gán công conversion.
|
||||
/// </summary>
|
||||
public int ViewWindowDays { get; private set; }
|
||||
|
||||
protected AttributionWindow() { }
|
||||
|
||||
public AttributionWindow(int clickWindowDays, int viewWindowDays)
|
||||
{
|
||||
if (clickWindowDays < 0)
|
||||
throw new ArgumentException("Click window days cannot be negative", nameof(clickWindowDays));
|
||||
if (viewWindowDays < 0)
|
||||
throw new ArgumentException("View window days cannot be negative", nameof(viewWindowDays));
|
||||
if (clickWindowDays == 0 && viewWindowDays == 0)
|
||||
throw new ArgumentException("Both windows cannot be zero");
|
||||
|
||||
ClickWindowDays = clickWindowDays;
|
||||
ViewWindowDays = viewWindowDays;
|
||||
}
|
||||
|
||||
// EN: Predefined standard attribution windows
|
||||
// VI: Các cửa sổ attribution tiêu chuẩn được định nghĩa sẵn
|
||||
|
||||
/// <summary>
|
||||
/// EN: 7-day click attribution window (no view attribution).
|
||||
/// VI: Cửa sổ attribution 7 ngày sau click (không tính view).
|
||||
/// </summary>
|
||||
public static AttributionWindow SevenDayClick => new(7, 0);
|
||||
|
||||
/// <summary>
|
||||
/// EN: 1-day view attribution window (no click attribution).
|
||||
/// VI: Cửa sổ attribution 1 ngày sau view (không tính click).
|
||||
/// </summary>
|
||||
public static AttributionWindow OneDayView => new(0, 1);
|
||||
|
||||
/// <summary>
|
||||
/// EN: 7-day click + 1-day view attribution window (default).
|
||||
/// VI: Cửa sổ attribution 7 ngày click + 1 ngày view (mặc định).
|
||||
/// </summary>
|
||||
public static AttributionWindow SevenOne => new(7, 1);
|
||||
|
||||
/// <summary>
|
||||
/// EN: 28-day click + 1-day view attribution window.
|
||||
/// VI: Cửa sổ attribution 28 ngày click + 1 ngày view.
|
||||
/// </summary>
|
||||
public static AttributionWindow TwentyEightOne => new(28, 1);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if a conversion is within the attribution window.
|
||||
/// VI: Kiểm tra conversion có nằm trong cửa sổ attribution không.
|
||||
/// </summary>
|
||||
public bool IsWithinWindow(DateTime eventTime, DateTime conversionTime, bool isClick)
|
||||
{
|
||||
var daysDifference = (conversionTime - eventTime).Days;
|
||||
var windowDays = isClick ? ClickWindowDays : ViewWindowDays;
|
||||
|
||||
return daysDifference >= 0 && daysDifference <= windowDays;
|
||||
}
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return ClickWindowDays;
|
||||
yield return ViewWindowDays;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{ClickWindowDays}d click / {ViewWindowDays}d view";
|
||||
}
|
||||
@@ -71,9 +71,18 @@ public class PixelEvent : Entity
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pixel event types for tracking user behavior.
|
||||
/// VI: Các loại sự kiện pixel để theo dõi hành vi người dùng.
|
||||
/// </summary>
|
||||
public enum PixelEventType
|
||||
{
|
||||
Impression = 1,
|
||||
Click = 2,
|
||||
PageView = 3
|
||||
PageView = 3,
|
||||
ViewContent = 4, // EN: Product/content view / VI: Xem sản phẩm/nội dung
|
||||
AddToCart = 5, // EN: Add to cart / VI: Thêm vào giỏ hàng
|
||||
InitiateCheckout = 6, // EN: Begin checkout / VI: Bắt đầu thanh toán
|
||||
Purchase = 7, // EN: Complete purchase / VI: Hoàn tất mua hàng
|
||||
Lead = 8 // EN: Lead generation / VI: Thu thập thông tin khách hàng
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using AdsTrackingService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsTrackingService.Domain.AggregatesModel.TrackingPixelAggregate;
|
||||
using AdsTrackingService.Domain.AggregatesModel.ConversionAggregate;
|
||||
using AdsTrackingService.Domain.AggregatesModel.AttributionAggregate;
|
||||
using AdsTrackingService.Domain.SeedWork;
|
||||
using AdsTrackingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -17,10 +19,28 @@ public class AdsTrackingServiceContext : DbContext, IUnitOfWork
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// EN: Tracking pixels table.
|
||||
/// VI: Bảng tracking pixels.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<TrackingPixel> TrackingPixels => Set<TrackingPixel>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pixel events table.
|
||||
/// VI: Bảng pixel events.
|
||||
/// </summary>
|
||||
public DbSet<PixelEvent> PixelEvents => Set<PixelEvent>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conversions table.
|
||||
/// VI: Bảng conversions.
|
||||
/// </summary>
|
||||
public DbSet<Conversion> Conversions => Set<Conversion>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Attributions table.
|
||||
/// VI: Bảng attributions.
|
||||
/// </summary>
|
||||
public DbSet<Attribution> Attributions => Set<Attribution>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
@@ -50,8 +70,10 @@ public class AdsTrackingServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
// 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 TrackingPixelEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new PixelEventEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ConversionEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AttributionEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsTrackingService.Domain.AggregatesModel.AttributionAggregate;
|
||||
|
||||
namespace AdsTrackingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for Attribution aggregate root.
|
||||
/// VI: Cấu hình entity cho Attribution aggregate root.
|
||||
/// </summary>
|
||||
public class AttributionEntityTypeConfiguration : IEntityTypeConfiguration<Attribution>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Attribution> builder)
|
||||
{
|
||||
// EN: Table mapping / VI: Ánh xạ bảng
|
||||
builder.ToTable("attributions", "ads_tracking");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(a => a.ConversionId)
|
||||
.HasColumnName("conversion_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.AdId)
|
||||
.HasColumnName("ad_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.Model)
|
||||
.HasColumnName("model")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.AttributedValue)
|
||||
.HasColumnName("attributed_value")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.AttributedAt)
|
||||
.HasColumnName("attributed_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex(a => a.ConversionId)
|
||||
.HasDatabaseName("ix_attributions_conversion_id");
|
||||
|
||||
builder.HasIndex(a => new { a.AdId, a.AttributedAt })
|
||||
.HasDatabaseName("ix_attributions_ad_id_attributed_at");
|
||||
|
||||
builder.HasIndex(a => new { a.CampaignId, a.AttributedAt })
|
||||
.HasDatabaseName("ix_attributions_campaign_id_attributed_at");
|
||||
|
||||
builder.HasIndex(a => a.Model)
|
||||
.HasDatabaseName("ix_attributions_model");
|
||||
|
||||
// EN: Composite index for analytics / VI: Index tổng hợp cho phân tích
|
||||
builder.HasIndex(a => new { a.Model, a.CampaignId, a.AttributedAt })
|
||||
.HasDatabaseName("ix_attributions_model_campaign_id_attributed_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsTrackingService.Domain.AggregatesModel.ConversionAggregate;
|
||||
|
||||
namespace AdsTrackingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for Conversion aggregate root.
|
||||
/// VI: Cấu hình entity cho Conversion aggregate root.
|
||||
/// </summary>
|
||||
public class ConversionEntityTypeConfiguration : IEntityTypeConfiguration<Conversion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Conversion> builder)
|
||||
{
|
||||
// EN: Table mapping / VI: Ánh xạ bảng
|
||||
builder.ToTable("conversions", "ads_tracking");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(c => c.AdvertiserId)
|
||||
.HasColumnName("advertiser_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.UserId)
|
||||
.HasColumnName("user_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.ConversionType)
|
||||
.HasColumnName("conversion_type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.ConversionValue)
|
||||
.HasColumnName("conversion_value")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.Currency)
|
||||
.HasColumnName("currency")
|
||||
.HasMaxLength(3) // EN: ISO 4217 currency code / VI: Mã tiền tệ ISO 4217
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.ConversionTime)
|
||||
.HasColumnName("conversion_time")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex(c => c.AdvertiserId)
|
||||
.HasDatabaseName("ix_conversions_advertiser_id");
|
||||
|
||||
builder.HasIndex(c => new { c.CampaignId, c.ConversionTime })
|
||||
.HasDatabaseName("ix_conversions_campaign_id_conversion_time");
|
||||
|
||||
builder.HasIndex(c => new { c.UserId, c.ConversionTime })
|
||||
.HasDatabaseName("ix_conversions_user_id_conversion_time");
|
||||
|
||||
builder.HasIndex(c => c.ConversionType)
|
||||
.HasDatabaseName("ix_conversions_conversion_type");
|
||||
|
||||
builder.HasIndex(c => c.ConversionTime)
|
||||
.HasDatabaseName("ix_conversions_conversion_time");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsTrackingService.Domain.AggregatesModel.TrackingPixelAggregate;
|
||||
|
||||
namespace AdsTrackingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for PixelEvent entity.
|
||||
/// VI: Cấu hình entity cho PixelEvent.
|
||||
/// </summary>
|
||||
public class PixelEventEntityTypeConfiguration : IEntityTypeConfiguration<PixelEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PixelEvent> builder)
|
||||
{
|
||||
// EN: Table mapping / VI: Ánh xạ bảng
|
||||
builder.ToTable("pixel_events", "ads_tracking");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(e => e.PixelId)
|
||||
.HasColumnName("pixel_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.AdId)
|
||||
.HasColumnName("ad_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.UserId)
|
||||
.HasColumnName("user_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.EventType)
|
||||
.HasColumnName("event_type")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.UserAgent)
|
||||
.HasColumnName("user_agent")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(e => e.IpAddress)
|
||||
.HasColumnName("ip_address")
|
||||
.HasMaxLength(45); // EN: IPv6 max length / VI: Độ dài tối đa IPv6
|
||||
|
||||
builder.Property(e => e.Timestamp)
|
||||
.HasColumnName("timestamp")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes for time-series queries / VI: Các index cho query time-series
|
||||
builder.HasIndex(e => new { e.PixelId, e.Timestamp })
|
||||
.HasDatabaseName("ix_pixel_events_pixel_id_timestamp");
|
||||
|
||||
builder.HasIndex(e => new { e.AdId, e.Timestamp })
|
||||
.HasDatabaseName("ix_pixel_events_ad_id_timestamp");
|
||||
|
||||
builder.HasIndex(e => new { e.UserId, e.Timestamp })
|
||||
.HasDatabaseName("ix_pixel_events_user_id_timestamp");
|
||||
|
||||
builder.HasIndex(e => e.EventType)
|
||||
.HasDatabaseName("ix_pixel_events_event_type");
|
||||
|
||||
// EN: Composite index for analytics / VI: Index tổng hợp cho phân tích
|
||||
builder.HasIndex(e => new { e.EventType, e.Timestamp })
|
||||
.HasDatabaseName("ix_pixel_events_event_type_timestamp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsTrackingService.Domain.AggregatesModel.TrackingPixelAggregate;
|
||||
|
||||
namespace AdsTrackingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for TrackingPixel aggregate root.
|
||||
/// VI: Cấu hình entity cho TrackingPixel aggregate root.
|
||||
/// </summary>
|
||||
public class TrackingPixelEntityTypeConfiguration : IEntityTypeConfiguration<TrackingPixel>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TrackingPixel> builder)
|
||||
{
|
||||
// EN: Table mapping / VI: Ánh xạ bảng
|
||||
builder.ToTable("tracking_pixels", "ads_tracking");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(p => p.AdvertiserId)
|
||||
.HasColumnName("advertiser_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.PixelCode)
|
||||
.HasColumnName("pixel_code")
|
||||
.HasMaxLength(16)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.IsActive)
|
||||
.HasColumnName("is_active")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex(p => p.AdvertiserId)
|
||||
.HasDatabaseName("ix_tracking_pixels_advertiser_id");
|
||||
|
||||
builder.HasIndex(p => p.PixelCode)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("uix_tracking_pixels_pixel_code");
|
||||
|
||||
builder.HasIndex(p => p.IsActive)
|
||||
.HasDatabaseName("ix_tracking_pixels_is_active");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user