feat: introduce comprehensive billing, analytics, manager, and tracking features with new controllers, commands, queries, DTOs, and infrastructure configurations.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 01:26:29 +07:00
parent 4c9e12e99c
commit 48c757282a
41 changed files with 2696 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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