Initial import into Gitea
This commit is contained in:
@@ -11,6 +11,7 @@ public sealed class ExecutionService
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ImageStorageService _imageStorage;
|
||||
private readonly ILogger<ExecutionService> _logger;
|
||||
|
||||
// Operations that don't require a linked account (health checks, etc.)
|
||||
@@ -19,17 +20,31 @@ public sealed class ExecutionService
|
||||
"Ping", "TestPing", "ListAccessibleCustomers"
|
||||
};
|
||||
|
||||
// Providers that require Google Ads account validation
|
||||
private static readonly HashSet<string> GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"google"
|
||||
};
|
||||
|
||||
// Creative operations that return images and need blob storage processing
|
||||
private static readonly HashSet<string> CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CreateDraft", "GetImages"
|
||||
};
|
||||
|
||||
public ExecutionService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ClientContext client,
|
||||
ImageStorageService imageStorage,
|
||||
ILogger<ExecutionService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_client = client;
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,33 +61,55 @@ public sealed class ExecutionService
|
||||
var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system";
|
||||
var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping";
|
||||
|
||||
// Legacy support: if "operation" is provided, use it as action
|
||||
string? operation = action;
|
||||
// Operation: explicit "operation" field takes priority, then falls back to "action"
|
||||
string operation = action;
|
||||
if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String)
|
||||
operation = opProp.GetString();
|
||||
operation = opProp.GetString() ?? action;
|
||||
|
||||
// TenantId priority: 1) request body, 2) ClientContext, 3) null
|
||||
// TenantId priority: 1) request body, 2) ClientContext (header), 3) default MCC, 4) null
|
||||
string? tenantId = null;
|
||||
if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String)
|
||||
tenantId = tid.GetString();
|
||||
tenantId ??= _client.TenantId;
|
||||
|
||||
// Agency model fallback: use default MCC customer ID if no tenant specified
|
||||
// This ensures real API calls work even before per-client subaccounts exist
|
||||
bool tenantIsSystemDefault = false;
|
||||
if (string.IsNullOrWhiteSpace(tenantId) && GoogleAccountProviders.Contains(provider))
|
||||
{
|
||||
tenantId = _cfg["GoogleAds:DefaultLoginCustomerId"]
|
||||
?? _cfg["GOOGLE_DEFAULT_CUSTOMER_ID"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_DEFAULT_CUSTOMER_ID");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantIsSystemDefault = true;
|
||||
_logger.LogInformation("[Execution] Using default MCC customer ID as tenantId | RequestId={RequestId}", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass);
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Operation={Operation} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, operation, _client.IsDevBypass);
|
||||
|
||||
// ================================================================
|
||||
// AGENCY MODEL: Validate account and get loginCustomerId
|
||||
// AGENCY MODEL: Validate Google account (only for Google provider)
|
||||
// Skip validation if tenantId is the system-configured MCC default
|
||||
// (admin pre-configured, not user-supplied)
|
||||
// ================================================================
|
||||
string? loginCustomerId = null;
|
||||
string? validatedClientName = null;
|
||||
|
||||
// Only validate if operation requires a linked account
|
||||
bool requiresAccount = !string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId);
|
||||
// Only validate if provider requires it AND operation requires a linked account
|
||||
// AND tenantId is user-provided (not the system MCC default)
|
||||
bool requiresGoogleAccount =
|
||||
GoogleAccountProviders.Contains(provider) &&
|
||||
!string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId) &&
|
||||
!tenantIsSystemDefault;
|
||||
|
||||
if (requiresAccount)
|
||||
if (requiresGoogleAccount)
|
||||
{
|
||||
var validation = await ValidateGoogleAccountAsync(tenantId!, ct);
|
||||
|
||||
@@ -106,7 +143,7 @@ public sealed class ExecutionService
|
||||
requestId, tenantId, loginCustomerId, validatedClientName);
|
||||
}
|
||||
|
||||
// Log start (now includes clientId and routing info)
|
||||
// Log start (includes routing info)
|
||||
int? logId = null;
|
||||
var startRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
@@ -116,7 +153,7 @@ public sealed class ExecutionService
|
||||
tenantId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
loginCustomerId,
|
||||
sessionId = _client.SessionId,
|
||||
userId = _client.UserId,
|
||||
@@ -131,8 +168,8 @@ public sealed class ExecutionService
|
||||
logId = e.GetInt32();
|
||||
}
|
||||
|
||||
// Inject/override fields in request before forwarding to provider
|
||||
var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId);
|
||||
// Build enriched request for provider
|
||||
var enrichedRequest = BuildProviderRequest(reqJson, requestId, operation, tenantId, loginCustomerId);
|
||||
|
||||
// Forward to provider (URL based on provider type)
|
||||
var sw = Stopwatch.StartNew();
|
||||
@@ -143,6 +180,11 @@ public sealed class ExecutionService
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var key = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"No provider URL configured for '{provider}'. Check environment variables.");
|
||||
}
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
@@ -155,17 +197,45 @@ public sealed class ExecutionService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId);
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId} Provider={Provider}", requestId, provider);
|
||||
providerStatus = 500;
|
||||
providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message });
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, providerStatus, sw.ElapsedMilliseconds);
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Provider={Provider} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, provider, providerStatus, sw.ElapsedMilliseconds);
|
||||
|
||||
// Log finish (includes clientId and routing info for correlation)
|
||||
// ================================================================
|
||||
// CREATIVE IMAGE PROCESSING: Store images in blob storage
|
||||
// ================================================================
|
||||
if (provider.Equals("creative", StringComparison.OrdinalIgnoreCase) &&
|
||||
CreativeImageOperations.Contains(operation) &&
|
||||
providerStatus >= 200 && providerStatus < 300 &&
|
||||
_imageStorage.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[Execution] Processing Creative images | RequestId={RequestId} ClientId={ClientId}",
|
||||
requestId, clientId);
|
||||
|
||||
providerResp = await _imageStorage.ProcessCreativeDraftAsync(
|
||||
clientId ?? "unknown",
|
||||
providerResp,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[Execution] Image storage failed, returning original response | RequestId={RequestId}",
|
||||
requestId);
|
||||
// Continue with original response - non-fatal error
|
||||
}
|
||||
}
|
||||
|
||||
// Log finish (includes routing info for correlation)
|
||||
var finishRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "finish",
|
||||
@@ -174,10 +244,10 @@ public sealed class ExecutionService
|
||||
clientId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
providerStatus,
|
||||
elapsedMs = sw.ElapsedMilliseconds,
|
||||
resp = JsonDocument.Parse(providerResp).RootElement
|
||||
resp = SafeParseJson(providerResp)
|
||||
});
|
||||
|
||||
_ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct);
|
||||
@@ -187,6 +257,52 @@ public sealed class ExecutionService
|
||||
return wrappedResponse;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider request building
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Build a clean request object for the provider container.
|
||||
/// Ensures "operation" is always set explicitly so providers can dispatch on it.
|
||||
/// Includes session context so providers know who initiated the request.
|
||||
/// </summary>
|
||||
private string BuildProviderRequest(JsonElement original, string requestId, string operation,
|
||||
string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
var request = new Dictionary<string, object?>
|
||||
{
|
||||
["requestId"] = requestId,
|
||||
["operation"] = operation,
|
||||
["tenantId"] = tenantId,
|
||||
["loginCustomerId"] = loginCustomerId,
|
||||
["session"] = new
|
||||
{
|
||||
sessionId = _client.SessionId,
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
isDevBypass = _client.IsDevBypass
|
||||
}
|
||||
};
|
||||
|
||||
// Copy payload if present (provider-specific data)
|
||||
if (original.TryGetProperty("payload", out var payload))
|
||||
{
|
||||
request["payload"] = payload;
|
||||
}
|
||||
|
||||
// Copy service/action for providers that use them
|
||||
if (original.TryGetProperty("service", out var svc))
|
||||
request["service"] = svc.GetString();
|
||||
if (original.TryGetProperty("action", out var act))
|
||||
request["action"] = act.GetString();
|
||||
|
||||
return JsonSerializer.Serialize(request);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Account validation (Google-specific)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a Google Ads customer ID is linked in the database.
|
||||
/// Returns loginCustomerId if account is found.
|
||||
@@ -258,36 +374,19 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
|
||||
/// </summary>
|
||||
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(original.GetRawText());
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
|
||||
?? new Dictionary<string, JsonElement>();
|
||||
|
||||
// Add/override requestId
|
||||
dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement;
|
||||
|
||||
// Add tenantId if we have one
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement;
|
||||
}
|
||||
|
||||
// Add loginCustomerId (manager account) if we have one
|
||||
if (!string.IsNullOrWhiteSpace(loginCustomerId))
|
||||
{
|
||||
dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
// ================================================================
|
||||
// Response wrapping
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Wrap provider response with Gateway metadata.
|
||||
/// </summary>
|
||||
private static object SafeParseJson(string raw)
|
||||
{
|
||||
try { return JsonDocument.Parse(raw).RootElement; }
|
||||
catch { return raw[..Math.Min(raw.Length, 500)]; }
|
||||
}
|
||||
|
||||
private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId)
|
||||
{
|
||||
try
|
||||
@@ -319,9 +418,10 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of account validation.
|
||||
/// </summary>
|
||||
// ================================================================
|
||||
// Validation result
|
||||
// ================================================================
|
||||
|
||||
private sealed class AccountValidation
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
@@ -345,6 +445,10 @@ public sealed class ExecutionService
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider routing
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Get provider URL based on provider type.
|
||||
/// </summary>
|
||||
@@ -353,9 +457,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"creative" => _cfg["CREATIVE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_API_URL"]?.TrimEnd('/') ?? "",
|
||||
"msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""
|
||||
_ => "" // No default fallback ? unknown providers fail explicitly
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,9 +474,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"creative" => _cfg["CREATIVE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_INTERNAL_KEY"] ?? "",
|
||||
"msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "",
|
||||
_ => _cfg["GOOGLE_INTERNAL_KEY"] ?? ""
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user