Files
AdPlatform-Server/Gateway/Services/MetricSyncService.cs
2026-03-14 13:50:09 -07:00

340 lines
14 KiB
C#

using Gateway.Data;
using Gateway.Security;
using System.Text.Json;
namespace Gateway.Services;
/// <summary>
/// Orchestrates pulling campaign performance metrics from providers
/// and writing them into the database via spPerformanceMetric.
///
/// Flow:
/// 1. Get active channel campaigns (from spChannelCampaign listByClient)
/// 2. For each channel campaign with an external campaign ID:
/// - Call the appropriate provider's reporting endpoint
/// - Transform provider response into standard metric format
/// - Upsert into tbPerformanceMetric via spPerformanceMetric.upsertBatch
/// 3. After metrics are synced, trigger recommendation evaluation
///
/// Called by:
/// - Admin endpoint (manual trigger)
/// - Background polling (future: Azure Functions timer trigger)
/// </summary>
public sealed class MetricSyncService
{
private readonly SqlService _sql;
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
private readonly ILogger<MetricSyncService> _logger;
public MetricSyncService(
SqlService sql,
IHttpClientFactory http,
IConfiguration cfg,
ILogger<MetricSyncService> logger)
{
_sql = sql;
_http = http;
_cfg = cfg;
_logger = logger;
}
/// <summary>
/// Sync metrics for a specific client's active campaigns.
/// </summary>
public async Task<SyncResult> SyncClientMetricsAsync(
string clientId, string? startDate, string? endDate, CancellationToken ct)
{
var result = new SyncResult { ClientId = clientId };
try
{
// 1. Get active channel campaigns for this client
var listResp = await _sql.ExecProcAsync(
SqlNames.Procs.ChannelCampaign, "listByClient",
JsonSerializer.Serialize(new { clientId }), ct: ct);
if (string.IsNullOrWhiteSpace(listResp))
{
result.Error = "Failed to retrieve channel campaigns";
return result;
}
using var doc = JsonDocument.Parse(listResp);
var root = doc.RootElement;
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
{
result.Error = "Channel campaign query returned error";
return result;
}
// Extract campaigns array
JsonElement campaigns;
if (root.TryGetProperty("channelCampaigns", out campaigns) ||
root.TryGetProperty("channels", out campaigns))
{
// ok
}
else
{
result.Error = "No channel campaigns found in response";
return result;
}
if (campaigns.ValueKind != JsonValueKind.Array)
{
result.Error = "Channel campaigns is not an array";
return result;
}
// 2. Sync each active channel campaign
foreach (var cc in campaigns.EnumerateArray())
{
var chcId = cc.TryGetProperty("channelCampaignId", out var chcIdProp) ? chcIdProp.GetInt64() :
cc.TryGetProperty("chcId", out var chcProp) ? chcProp.GetInt64() : 0;
var channelType = cc.TryGetProperty("channelType", out var ctProp) ? ctProp.GetString() :
cc.TryGetProperty("chcChannelType", out var chcCtProp) ? chcCtProp.GetString() : null;
var status = cc.TryGetProperty("status", out var stProp) ? stProp.GetString() :
cc.TryGetProperty("chcStatus", out var chcStProp) ? chcStProp.GetString() : null;
if (chcId == 0 || string.IsNullOrWhiteSpace(channelType)) continue;
if (status != "active") continue;
result.CampaignsProcessed++;
try
{
var provider = MapChannelToProvider(channelType);
var providerUrl = GetProviderUrl(provider);
var providerKey = GetProviderKey(provider);
if (string.IsNullOrWhiteSpace(providerUrl))
{
_logger.LogWarning("[MetricSync] No URL for provider {Provider}, skipping chcId={ChcId}",
provider, chcId);
result.Skipped++;
continue;
}
// Get external campaign ID
// providerPayload from the channel campaign contains the external mapping
var externalCampaignId = cc.TryGetProperty("externalCampaignId", out var extIdProp)
? extIdProp.GetString() : null;
if (string.IsNullOrWhiteSpace(externalCampaignId))
{
// Try to extract from providerPayload JSON
if (cc.TryGetProperty("providerPayload", out var ppProp) &&
ppProp.ValueKind == JsonValueKind.String)
{
try
{
using var ppDoc = JsonDocument.Parse(ppProp.GetString()!);
externalCampaignId = ppDoc.RootElement.TryGetProperty("externalId", out var eidProp)
? eidProp.GetString() : null;
}
catch { /* ignore parse errors */ }
}
}
if (string.IsNullOrWhiteSpace(externalCampaignId))
{
_logger.LogDebug("[MetricSync] No externalCampaignId for chcId={ChcId}, skipping", chcId);
result.Skipped++;
continue;
}
// Call provider reporting endpoint
var reportPayload = new
{
operation = "GetCampaignReport",
tenantId = GetTenantId(cc),
requestId = Guid.NewGuid().ToString("N"),
payload = new
{
campaignId = externalCampaignId,
startDate = startDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"),
endDate = endDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd")
}
};
var httpClient = _http.CreateClient();
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
msg.Headers.Add("X-Internal-Key", providerKey);
msg.Content = new StringContent(
JsonSerializer.Serialize(reportPayload),
System.Text.Encoding.UTF8, "application/json");
using var resp = await httpClient.SendAsync(msg, ct);
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("[MetricSync] Provider returned {Status} for chcId={ChcId}",
resp.StatusCode, chcId);
result.Errors++;
continue;
}
// Parse provider response and extract daily rows
using var respDoc = JsonDocument.Parse(respBody);
var respRoot = respDoc.RootElement;
JsonElement data;
if (respRoot.TryGetProperty("data", out data) ||
respRoot.TryGetProperty("Data", out data))
{
// ok
}
else
{
data = respRoot;
}
if (!data.TryGetProperty("rows", out var rowsEl) ||
rowsEl.ValueKind != JsonValueKind.Array)
{
_logger.LogWarning("[MetricSync] No rows in provider response for chcId={ChcId}", chcId);
result.Errors++;
continue;
}
// Transform rows into upsertBatch format
var metrics = new List<object>();
foreach (var row in rowsEl.EnumerateArray())
{
var metricDate = row.TryGetProperty("date", out var dProp) ? dProp.GetString() : null;
if (string.IsNullOrWhiteSpace(metricDate)) continue;
metrics.Add(new
{
channelCampaignId = chcId,
metricDate,
impressions = GetLong(row, "impressions"),
clicks = GetLong(row, "clicks"),
spend = GetDecimal(row, "spend") ?? (GetLong(row, "costMicros") / 1_000_000.0m),
conversions = GetDecimal(row, "conversions") ?? 0,
conversionValue = GetDecimal(row, "conversionValue") ?? 0,
sourceAttribution = "provider"
});
}
if (metrics.Count == 0) continue;
// Upsert into database
var upsertResp = await _sql.ExecProcAsync(
SqlNames.Procs.PerformanceMetric, "upsertBatch",
JsonSerializer.Serialize(new { metrics }), ct: ct);
_logger.LogInformation(
"[MetricSync] Synced {Count} rows for chcId={ChcId} channel={Channel}",
metrics.Count, chcId, channelType);
result.MetricsWritten += metrics.Count;
}
catch (Exception ex)
{
_logger.LogError(ex, "[MetricSync] Error syncing chcId={ChcId}", chcId);
result.Errors++;
}
}
// 3. Trigger recommendation evaluation for this client
if (result.MetricsWritten > 0)
{
try
{
var evalResp = await _sql.ExecProcAsync(
SqlNames.Procs.Recommendation, "evaluate",
JsonSerializer.Serialize(new { clientId }), ct: ct);
if (!string.IsNullOrWhiteSpace(evalResp))
{
using var evalDoc = JsonDocument.Parse(evalResp);
if (evalDoc.RootElement.TryGetProperty("generated", out var genProp))
result.RecommendationsGenerated = genProp.GetInt32();
}
_logger.LogInformation(
"[MetricSync] Evaluation complete for client {ClientId} | Recommendations={Recommendations}",
clientId, result.RecommendationsGenerated);
}
catch (Exception ex)
{
_logger.LogError(ex, "[MetricSync] Evaluation failed for client {ClientId}", clientId);
}
}
result.Success = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "[MetricSync] Sync failed for client {ClientId}", clientId);
result.Error = ex.Message;
}
return result;
}
// ════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════
private static string MapChannelToProvider(string channelType) =>
channelType.ToLowerInvariant() switch
{
"google_ads" or "google" => "google",
"meta" or "facebook" => "meta",
"tiktok" => "tiktok",
_ => channelType
};
private string GetProviderUrl(string provider) =>
provider.ToLowerInvariant() switch
{
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
_ => ""
};
private string GetProviderKey(string provider) =>
provider.ToLowerInvariant() switch
{
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
_ => ""
};
private static string? GetTenantId(JsonElement cc)
{
if (cc.TryGetProperty("externalAccountId", out var eaProp)) return eaProp.GetString();
if (cc.TryGetProperty("chcExternalAccountId", out var chcEaProp)) return chcEaProp.GetString();
return null;
}
private static long GetLong(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
private static decimal? GetDecimal(JsonElement el, string prop)
{
if (!el.TryGetProperty(prop, out var p)) return null;
return p.ValueKind == JsonValueKind.Number ? p.GetDecimal() : null;
}
}
/// <summary>Result of a metric sync operation.</summary>
public sealed class SyncResult
{
public string? ClientId { get; set; }
public bool Success { get; set; }
public int CampaignsProcessed { get; set; }
public int MetricsWritten { get; set; }
public int RecommendationsGenerated { get; set; }
public int Skipped { get; set; }
public int Errors { get; set; }
public string? Error { get; set; }
}