383 lines
15 KiB
C#
383 lines
15 KiB
C#
using GoogleApi.Configuration;
|
|
using GoogleApi.Models;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using Google.Ads.GoogleAds;
|
|
using Google.Ads.GoogleAds.Lib;
|
|
using Google.Ads.GoogleAds.V22.Errors;
|
|
using Google.Ads.GoogleAds.V22.Services;
|
|
|
|
namespace GoogleApi.Services;
|
|
|
|
using GAdsServices = global::Google.Ads.GoogleAds.Services;
|
|
|
|
/// <summary>
|
|
/// Reporting service for pulling campaign performance metrics from Google Ads.
|
|
/// Supports both real API calls and emulated responses for development.
|
|
///
|
|
/// Operations:
|
|
/// - GetCampaignReport: Daily metrics for a specific campaign
|
|
/// - GetAccountReport: Daily metrics across all campaigns in an account
|
|
/// - GetCampaignList: Campaign status/budget summary (lightweight)
|
|
/// </summary>
|
|
public sealed class ReportingService
|
|
{
|
|
private readonly GoogleAdsConfig _config;
|
|
private readonly GoogleAdsClientFactory _clientFactory;
|
|
private readonly ILogger<ReportingService> _logger;
|
|
|
|
public ReportingService(
|
|
IOptions<GoogleAdsConfig> config,
|
|
GoogleAdsClientFactory clientFactory,
|
|
ILogger<ReportingService> logger)
|
|
{
|
|
_config = config.Value;
|
|
_clientFactory = clientFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get daily campaign performance report.
|
|
/// Returns impressions, clicks, spend, conversions, conversion value per day.
|
|
/// </summary>
|
|
public async Task<ProviderResponse> GetCampaignReportAsync(
|
|
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
|
{
|
|
var payload = request.GetPayload<ReportingPayload>();
|
|
|
|
if (string.IsNullOrWhiteSpace(payload.CampaignId))
|
|
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
|
|
|
|
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd");
|
|
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
|
|
|
|
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
|
|
return await GetCampaignReportRealAsync(payload.CampaignId, startDate, endDate, context, requestId, ct);
|
|
|
|
return GetCampaignReportEmulated(payload.CampaignId, startDate, endDate, requestId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get daily account-level performance report across all campaigns.
|
|
/// Used for syncing metrics into tbPerformanceMetric.
|
|
/// </summary>
|
|
public async Task<ProviderResponse> GetAccountReportAsync(
|
|
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
|
{
|
|
var payload = request.GetPayload<ReportingPayload>();
|
|
|
|
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd");
|
|
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
|
|
|
|
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
|
|
return await GetAccountReportRealAsync(startDate, endDate, context, requestId, ct);
|
|
|
|
return GetAccountReportEmulated(startDate, endDate, requestId);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════
|
|
// Real API Implementation
|
|
// ════════════════════════════════════════════════
|
|
|
|
private async Task<ProviderResponse> GetCampaignReportRealAsync(
|
|
string campaignId, string startDate, string endDate,
|
|
GoogleAdsContext context, string requestId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var client = _clientFactory.CreateClient(context);
|
|
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
|
|
|
|
// Extract numeric campaign ID from resource name if needed
|
|
var numericId = campaignId.Contains('/') ? campaignId.Split('/').Last() : campaignId;
|
|
|
|
var query = $@"
|
|
SELECT
|
|
campaign.id,
|
|
campaign.name,
|
|
campaign.status,
|
|
segments.date,
|
|
metrics.impressions,
|
|
metrics.clicks,
|
|
metrics.cost_micros,
|
|
metrics.conversions,
|
|
metrics.conversions_value,
|
|
metrics.all_conversions,
|
|
metrics.ctr,
|
|
metrics.average_cpc
|
|
FROM campaign
|
|
WHERE campaign.id = {numericId}
|
|
AND segments.date BETWEEN '{startDate}' AND '{endDate}'
|
|
ORDER BY segments.date";
|
|
|
|
var rows = new List<object>();
|
|
|
|
var searchRequest = new SearchGoogleAdsRequest
|
|
{
|
|
CustomerId = context.CustomerId,
|
|
Query = query
|
|
};
|
|
|
|
var response = gaService.Search(searchRequest);
|
|
|
|
foreach (var row in response)
|
|
{
|
|
rows.Add(new
|
|
{
|
|
date = row.Segments.Date,
|
|
campaignId = row.Campaign.Id,
|
|
campaignName = row.Campaign.Name,
|
|
campaignStatus = row.Campaign.Status.ToString(),
|
|
impressions = row.Metrics.Impressions,
|
|
clicks = row.Metrics.Clicks,
|
|
costMicros = row.Metrics.CostMicros,
|
|
spend = row.Metrics.CostMicros / 1_000_000.0,
|
|
conversions = row.Metrics.Conversions,
|
|
conversionValue = row.Metrics.ConversionsValue,
|
|
allConversions = row.Metrics.AllConversions,
|
|
ctr = row.Metrics.Ctr,
|
|
averageCpcMicros = row.Metrics.AverageCpc
|
|
});
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"[Reporting] Retrieved {Count} rows for campaign {CampaignId} | RequestId={RequestId}",
|
|
rows.Count, campaignId, requestId);
|
|
|
|
return ProviderResponse.Success(requestId, new
|
|
{
|
|
campaignId,
|
|
dateRange = new { start = startDate, end = endDate },
|
|
rows,
|
|
rowCount = rows.Count,
|
|
emulated = false
|
|
});
|
|
}
|
|
catch (GoogleAdsException gex)
|
|
{
|
|
_logger.LogError(gex, "[Reporting] Google Ads error for campaign {CampaignId}", campaignId);
|
|
return HandleGoogleAdsException(gex, requestId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[Reporting] Error fetching campaign report");
|
|
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
|
}
|
|
}
|
|
|
|
private async Task<ProviderResponse> GetAccountReportRealAsync(
|
|
string startDate, string endDate,
|
|
GoogleAdsContext context, string requestId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var client = _clientFactory.CreateClient(context);
|
|
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
|
|
|
|
var query = $@"
|
|
SELECT
|
|
campaign.id,
|
|
campaign.name,
|
|
campaign.status,
|
|
campaign.advertising_channel_type,
|
|
segments.date,
|
|
metrics.impressions,
|
|
metrics.clicks,
|
|
metrics.cost_micros,
|
|
metrics.conversions,
|
|
metrics.conversions_value,
|
|
metrics.all_conversions
|
|
FROM campaign
|
|
WHERE segments.date BETWEEN '{startDate}' AND '{endDate}'
|
|
AND campaign.status != 'REMOVED'
|
|
ORDER BY segments.date, campaign.id";
|
|
|
|
var rows = new List<object>();
|
|
|
|
var searchRequest = new SearchGoogleAdsRequest
|
|
{
|
|
CustomerId = context.CustomerId,
|
|
Query = query
|
|
};
|
|
|
|
var response = gaService.Search(searchRequest);
|
|
|
|
foreach (var row in response)
|
|
{
|
|
rows.Add(new
|
|
{
|
|
date = row.Segments.Date,
|
|
campaignId = row.Campaign.Id,
|
|
campaignName = row.Campaign.Name,
|
|
campaignStatus = row.Campaign.Status.ToString(),
|
|
channelType = row.Campaign.AdvertisingChannelType.ToString(),
|
|
impressions = row.Metrics.Impressions,
|
|
clicks = row.Metrics.Clicks,
|
|
costMicros = row.Metrics.CostMicros,
|
|
spend = row.Metrics.CostMicros / 1_000_000.0,
|
|
conversions = row.Metrics.Conversions,
|
|
conversionValue = row.Metrics.ConversionsValue,
|
|
allConversions = row.Metrics.AllConversions
|
|
});
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"[Reporting] Retrieved {Count} rows for account {CustomerId} | RequestId={RequestId}",
|
|
rows.Count, context.CustomerId, requestId);
|
|
|
|
return ProviderResponse.Success(requestId, new
|
|
{
|
|
customerId = context.CustomerId,
|
|
dateRange = new { start = startDate, end = endDate },
|
|
rows,
|
|
rowCount = rows.Count,
|
|
emulated = false
|
|
});
|
|
}
|
|
catch (GoogleAdsException gex)
|
|
{
|
|
_logger.LogError(gex, "[Reporting] Google Ads error for account {CustomerId}", context.CustomerId);
|
|
return HandleGoogleAdsException(gex, requestId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[Reporting] Error fetching account report");
|
|
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════
|
|
// Emulated Responses
|
|
// ════════════════════════════════════════════════
|
|
|
|
private ProviderResponse GetCampaignReportEmulated(
|
|
string campaignId, string startDate, string endDate, string requestId)
|
|
{
|
|
_logger.LogInformation("[Reporting] EMULATED: Campaign report for {CampaignId}", campaignId);
|
|
|
|
var rows = GenerateEmulatedDailyRows(startDate, endDate);
|
|
|
|
return ProviderResponse.Success(requestId, new
|
|
{
|
|
campaignId,
|
|
dateRange = new { start = startDate, end = endDate },
|
|
rows,
|
|
rowCount = rows.Count,
|
|
emulated = true
|
|
});
|
|
}
|
|
|
|
private ProviderResponse GetAccountReportEmulated(
|
|
string startDate, string endDate, string requestId)
|
|
{
|
|
_logger.LogInformation("[Reporting] EMULATED: Account report");
|
|
|
|
// Generate rows for 3 emulated campaigns
|
|
var allRows = new List<object>();
|
|
var campaigns = new[]
|
|
{
|
|
new { id = "emu_camp_001", name = "Search - Brand Terms", channel = "SEARCH" },
|
|
new { id = "emu_camp_002", name = "Display - Awareness", channel = "DISPLAY" },
|
|
new { id = "emu_camp_003", name = "PMax - Conversions", channel = "PERFORMANCE_MAX" }
|
|
};
|
|
|
|
foreach (var camp in campaigns)
|
|
{
|
|
var rows = GenerateEmulatedDailyRows(startDate, endDate);
|
|
foreach (var row in rows)
|
|
{
|
|
// Merge campaign info into each row
|
|
var dict = new Dictionary<string, object?>
|
|
{
|
|
["campaignId"] = camp.id,
|
|
["campaignName"] = camp.name,
|
|
["campaignStatus"] = "ENABLED",
|
|
["channelType"] = camp.channel
|
|
};
|
|
|
|
// Merge the row properties
|
|
var rowJson = System.Text.Json.JsonSerializer.Serialize(row);
|
|
var rowDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(rowJson);
|
|
if (rowDict != null)
|
|
foreach (var kv in rowDict) dict[kv.Key] = kv.Value;
|
|
|
|
allRows.Add(dict);
|
|
}
|
|
}
|
|
|
|
return ProviderResponse.Success(requestId, new
|
|
{
|
|
customerId = "emulated",
|
|
dateRange = new { start = startDate, end = endDate },
|
|
rows = allRows,
|
|
rowCount = allRows.Count,
|
|
emulated = true
|
|
});
|
|
}
|
|
|
|
private static List<object> GenerateEmulatedDailyRows(string startDate, string endDate)
|
|
{
|
|
var rows = new List<object>();
|
|
var rng = new Random(42); // deterministic seed for consistent emulation
|
|
|
|
if (!DateTime.TryParse(startDate, out var start)) start = DateTime.UtcNow.AddDays(-30);
|
|
if (!DateTime.TryParse(endDate, out var end)) end = DateTime.UtcNow.AddDays(-1);
|
|
|
|
for (var date = start; date <= end; date = date.AddDays(1))
|
|
{
|
|
var impressions = rng.Next(800, 3500);
|
|
var clicks = (int)(impressions * (0.015 + rng.NextDouble() * 0.04));
|
|
var costMicros = (long)(clicks * (450_000 + rng.Next(0, 300_000)));
|
|
var conversions = Math.Round(clicks * (0.03 + rng.NextDouble() * 0.05), 2);
|
|
var conversionValue = Math.Round(conversions * (25 + rng.NextDouble() * 75), 2);
|
|
|
|
rows.Add(new
|
|
{
|
|
date = date.ToString("yyyy-MM-dd"),
|
|
impressions,
|
|
clicks,
|
|
costMicros,
|
|
spend = Math.Round(costMicros / 1_000_000.0, 2),
|
|
conversions,
|
|
conversionValue,
|
|
allConversions = Math.Round(conversions * 1.15, 2),
|
|
ctr = impressions > 0 ? Math.Round((double)clicks / impressions, 4) : 0.0,
|
|
averageCpcMicros = clicks > 0 ? costMicros / clicks : 0L
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════
|
|
// Error Handling
|
|
// ════════════════════════════════════════════════
|
|
|
|
private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId)
|
|
{
|
|
var errorDetails = gex.Failure?.Errors?.Select(e => new
|
|
{
|
|
errorCode = e.ErrorCode?.ToString(),
|
|
message = e.Message,
|
|
trigger = e.Trigger?.StringValue,
|
|
location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray()
|
|
}).ToList();
|
|
|
|
return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new
|
|
{
|
|
googleRequestId = gex.RequestId,
|
|
errors = errorDetails
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Payload for reporting operations.
|
|
/// </summary>
|
|
public sealed class ReportingPayload
|
|
{
|
|
public string? CampaignId { get; set; }
|
|
public string? StartDate { get; set; }
|
|
public string? EndDate { get; set; }
|
|
}
|