Initial import into Gitea
This commit is contained in:
@@ -1,259 +0,0 @@
|
||||
# Google Ads API Configuration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how to configure the GoogleApi service to connect to the real Google Ads API. The service supports both **emulated mode** (for testing without Google credentials) and **real API mode**.
|
||||
|
||||
## Configuration Levels
|
||||
|
||||
| Level | Storage | Examples |
|
||||
|-------|---------|----------|
|
||||
| **Platform secrets** | Azure Key Vault | Developer token, OAuth client secret |
|
||||
| **Platform config** | App Settings / appsettings.json | API version, timeouts |
|
||||
| **Per-account credentials** | Database (tbGoogleCredential) | Refresh tokens per linked account |
|
||||
|
||||
## Quick Start (Test Account)
|
||||
|
||||
1. Create a Google Ads test manager account
|
||||
2. Get a developer token (works immediately for test accounts)
|
||||
3. Set up OAuth credentials in Google Cloud Console
|
||||
4. Configure the environment variables below
|
||||
|
||||
## Environment Variables for Azure Container Apps
|
||||
> **Note:** This service runs **server-to-server**. There is **no interactive OAuth UI** at runtime.
|
||||
> Generate the refresh token once (out-of-band) and store it securely (Key Vault / secrets).
|
||||
|
||||
|
||||
### GoogleApi Service
|
||||
|
||||
```bash
|
||||
# ==========================================
|
||||
# Core Settings
|
||||
# ==========================================
|
||||
|
||||
# Enable real Google Ads API calls (default: false)
|
||||
GoogleAds__EnableRealApi=true
|
||||
|
||||
# API version (default: v22)
|
||||
GoogleAds__ApiVersion=v22
|
||||
|
||||
# ==========================================
|
||||
# Authentication - Developer Token
|
||||
# Required for all API calls
|
||||
# ==========================================
|
||||
|
||||
# Your developer token from Google Ads API Center
|
||||
# Format: 22-character alphanumeric string
|
||||
# Get from: https://ads.google.com/aw/apicenter
|
||||
GoogleAds__DeveloperToken=YOUR_DEVELOPER_TOKEN_HERE
|
||||
|
||||
# ==========================================
|
||||
# Authentication - OAuth 2.0
|
||||
# Required for authenticating API requests
|
||||
# ==========================================
|
||||
|
||||
# OAuth Client ID from Google Cloud Console
|
||||
GoogleAds__OAuth__ClientId=YOUR_CLIENT_ID.apps.googleusercontent.com
|
||||
|
||||
# OAuth Client Secret from Google Cloud Console
|
||||
# SENSITIVE - Use Key Vault reference in production
|
||||
GoogleAds__OAuth__ClientSecret=YOUR_CLIENT_SECRET
|
||||
|
||||
# Refresh token for platform-level access
|
||||
# Generated via OAuth flow or gcloud CLI
|
||||
# SENSITIVE - Use Key Vault reference in production
|
||||
GoogleAds__OAuth__RefreshToken=YOUR_REFRESH_TOKEN
|
||||
|
||||
# ==========================================
|
||||
# Manager Account (Optional)
|
||||
# Required if accessing client accounts under a manager
|
||||
# ==========================================
|
||||
|
||||
# Default login customer ID (manager account)
|
||||
# Format: 1234567890 (no dashes)
|
||||
GoogleAds__DefaultLoginCustomerId=1234567890
|
||||
|
||||
# ==========================================
|
||||
# Internal Authentication
|
||||
# For Gateway -> GoogleApi communication
|
||||
# ==========================================
|
||||
|
||||
# Shared secret for internal API authentication
|
||||
# SENSITIVE - Use Key Vault reference
|
||||
GOOGLE_INTERNAL_KEY=your-secure-internal-key
|
||||
|
||||
# ==========================================
|
||||
# Optional Settings
|
||||
# ==========================================
|
||||
|
||||
# HTTP timeout in seconds (default: 60)
|
||||
GoogleAds__TimeoutSeconds=60
|
||||
|
||||
# Max retry attempts (default: 3)
|
||||
GoogleAds__MaxRetries=3
|
||||
```
|
||||
|
||||
### Azure Key Vault References
|
||||
|
||||
For sensitive values, use Key Vault references in Azure Container Apps:
|
||||
|
||||
```bash
|
||||
# Instead of plain values:
|
||||
GoogleAds__DeveloperToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsDeveloperToken/)
|
||||
GoogleAds__OAuth__ClientSecret=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsClientSecret/)
|
||||
GoogleAds__OAuth__RefreshToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsRefreshToken/)
|
||||
GOOGLE_INTERNAL_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleInternalKey/)
|
||||
```
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Create Google Ads Manager Account
|
||||
|
||||
1. Go to https://ads.google.com/aw/apicenter
|
||||
2. Sign in with a Google account NOT linked to production ads
|
||||
3. Create a new manager account
|
||||
4. For test accounts, click "Create a test manager account" link
|
||||
|
||||
### 2. Get Developer Token
|
||||
|
||||
1. In your manager account, go to **Tools & Settings > API Center**
|
||||
2. Your developer token will be displayed
|
||||
3. For test accounts: Token works immediately
|
||||
4. For production: Apply for Basic Access (takes a few days)
|
||||
|
||||
### 3. Create Google Cloud Project
|
||||
|
||||
1. Go to https://console.cloud.google.com
|
||||
2. Create a new project (or use existing)
|
||||
3. Enable the **Google Ads API**:
|
||||
- Go to APIs & Services > Library
|
||||
- Search "Google Ads API"
|
||||
- Click Enable
|
||||
|
||||
### 4. Create OAuth Credentials
|
||||
|
||||
1. Go to APIs & Services > Credentials
|
||||
2. Click **Create Credentials > OAuth client ID**
|
||||
3. Application type: **Desktop app** (for initial testing)
|
||||
4. Download the JSON file
|
||||
5. Note the Client ID and Client Secret
|
||||
|
||||
### 5. Generate Refresh Token
|
||||
|
||||
Option A: Using gcloud CLI
|
||||
```bash
|
||||
# Install gcloud CLI if not installed
|
||||
gcloud auth login --cred-file=path/to/client_secret.json
|
||||
|
||||
gcloud auth print-access-token \
|
||||
--scopes='https://www.googleapis.com/auth/adwords'
|
||||
```
|
||||
|
||||
Option B: Using OAuth Playground
|
||||
1. Go to https://developers.google.com/oauthplayground/
|
||||
2. Click gear icon > Use your own credentials
|
||||
3. Enter your Client ID and Secret
|
||||
4. Select Google Ads API scope: `https://www.googleapis.com/auth/adwords`
|
||||
5. Click Authorize APIs, sign in
|
||||
6. Click "Exchange authorization code for tokens"
|
||||
7. Copy the Refresh Token
|
||||
|
||||
### 6. Create Test Client Account
|
||||
|
||||
1. In your test manager account
|
||||
2. Click Accounts > + > Create new account
|
||||
3. This creates a test client account under your manager
|
||||
4. Note the Customer ID (format: XXX-XXX-XXXX)
|
||||
|
||||
### 7. Configure Azure Container App
|
||||
|
||||
In Azure Portal > Container Apps > Your GoogleApi App > Settings > Environment Variables:
|
||||
|
||||
Add each variable from the list above, using Key Vault references for sensitive values.
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
### Check Health Endpoint
|
||||
|
||||
```bash
|
||||
curl https://your-googleapi-url/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"service": "GoogleApi",
|
||||
"status": "healthy",
|
||||
"config": {
|
||||
"realApiEnabled": true,
|
||||
"apiVersion": "v18",
|
||||
"developerTokenSet": true,
|
||||
"oauthConfigured": true,
|
||||
"defaultLoginCustomerId": "1234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test API Call (via Gateway)
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-gateway-url/api/execution/request \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Dev-ClientId: test-client" \
|
||||
-H "X-Dev-TenantId: 1234567890" \
|
||||
-d '{
|
||||
"operation": "ListAccessibleCustomers",
|
||||
"payload": {}
|
||||
}'
|
||||
```
|
||||
|
||||
## Credential Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Gateway │────▶│ GoogleApi │────▶│ Google Ads │
|
||||
└─────────────┘ └─────────────┘ │ API │
|
||||
│ └─────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌────▼────┐
|
||||
│ Config │ │ Database │
|
||||
│(env vars) │ │(per-acct)│
|
||||
└───────────┘ └──────────┘
|
||||
|
||||
Config provides: Database provides:
|
||||
- Developer Token - Per-account refresh tokens
|
||||
- OAuth Client ID/Secret - Account-specific credentials
|
||||
- Default refresh token - Linked customer IDs
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "UNAUTHENTICATED" Error
|
||||
- Check developer token is correct
|
||||
- Verify OAuth credentials
|
||||
- Ensure refresh token hasn't expired
|
||||
|
||||
### "PERMISSION_DENIED" Error
|
||||
- Developer token may not be approved for production
|
||||
- Verify account access permissions
|
||||
- Check login-customer-id is correct
|
||||
|
||||
### "INVALID_CUSTOMER_ID" Error
|
||||
- Customer ID format should be 10 digits, no dashes
|
||||
- Verify account exists and is accessible
|
||||
|
||||
### Token Exchange Fails
|
||||
- Client ID/Secret mismatch
|
||||
- Refresh token was revoked
|
||||
- OAuth consent was withdrawn
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit secrets** to source control
|
||||
2. **Use Azure Key Vault** for all sensitive values
|
||||
3. **Rotate refresh tokens** periodically
|
||||
4. **Audit API access** via tbAdpApiLog
|
||||
5. **Limit developer token access** - one token per application
|
||||
6. **Use test accounts** for development and testing
|
||||
@@ -12,8 +12,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Google.Ads.GoogleAds" Version="24.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageReference Include="Google.Ads.GoogleAds" Version="25.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
58
GoogleApi/Models/AudienceModels.cs
Normal file
58
GoogleApi/Models/AudienceModels.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace GoogleApi.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audience segment from Google Ads (affinity, in-market, life events, etc.)
|
||||
/// </summary>
|
||||
public class AudienceSegment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty; // AFFINITY, IN_MARKET, LIFE_EVENT, DETAILED_DEMOGRAPHIC
|
||||
public string? ParentName { get; set; }
|
||||
public int? ParentId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing all available audience segments
|
||||
/// </summary>
|
||||
public class AudienceSegmentsResponse
|
||||
{
|
||||
public List<AudienceSegment> Affinity { get; set; } = new();
|
||||
public List<AudienceSegment> InMarket { get; set; } = new();
|
||||
public List<AudienceSegment> LifeEvents { get; set; } = new();
|
||||
public List<AudienceSegment> DetailedDemographics { get; set; } = new();
|
||||
public int TotalCount => Affinity.Count + InMarket.Count + LifeEvents.Count + DetailedDemographics.Count;
|
||||
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Geo target constant for location targeting
|
||||
/// </summary>
|
||||
public class GeoTarget
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string CanonicalName { get; set; } = string.Empty;
|
||||
public string TargetType { get; set; } = string.Empty; // City, State, Country, etc.
|
||||
public string? CountryCode { get; set; }
|
||||
public string? ParentGeoTarget { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for geo target search
|
||||
/// </summary>
|
||||
public class GeoTargetSearchResponse
|
||||
{
|
||||
public List<GeoTarget> Results { get; set; } = new();
|
||||
public string Query { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for searching geo targets
|
||||
/// </summary>
|
||||
public class GeoTargetSearchPayload
|
||||
{
|
||||
public string Query { get; set; } = string.Empty;
|
||||
public string? CountryCode { get; set; }
|
||||
public int MaxResults { get; set; } = 20;
|
||||
}
|
||||
59
GoogleApi/Models/ForecastModels.cs
Normal file
59
GoogleApi/Models/ForecastModels.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace GoogleApi.Models;
|
||||
|
||||
#region Forecast Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Payload for KeywordForecast operation.
|
||||
/// Gateway sends targeting + budget; we translate to GenerateKeywordForecastMetrics.
|
||||
/// </summary>
|
||||
public sealed class KeywordForecastPayload
|
||||
{
|
||||
/// <summary>Keywords to forecast (from wizard Step 1 URL analysis)</summary>
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
/// <summary>Geo target IDs (Google Ads geo constants)</summary>
|
||||
public List<long> GeoTargetIds { get; set; } = new();
|
||||
|
||||
/// <summary>Monthly budget in whole currency units allocated to this channel</summary>
|
||||
public decimal MonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Currency code</summary>
|
||||
public string CurrencyCode { get; set; } = "USD";
|
||||
|
||||
/// <summary>Forecast period in days (default 30)</summary>
|
||||
public int ForecastDays { get; set; } = 30;
|
||||
|
||||
/// <summary>Campaign type for bid simulation</summary>
|
||||
public CampaignType CampaignType { get; set; } = CampaignType.Search;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from keyword forecast — monthly estimated metrics.
|
||||
/// </summary>
|
||||
public sealed class KeywordForecastResponse
|
||||
{
|
||||
public string Provider { get; set; } = "google";
|
||||
public ForecastEstimates Monthly { get; set; } = new();
|
||||
public ForecastMetrics Metrics { get; set; } = new();
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "emulated";
|
||||
}
|
||||
|
||||
public sealed class ForecastEstimates
|
||||
{
|
||||
public double Impressions { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public decimal Cost { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastMetrics
|
||||
{
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -36,6 +36,25 @@ public sealed class ListCampaignsPayload
|
||||
|
||||
#endregion
|
||||
|
||||
#region Account Payloads
|
||||
|
||||
public sealed class CreateCustomerClientPayload
|
||||
{
|
||||
/// <summary>Display name for the new sub-account (used for billing reconciliation)</summary>
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Currency code (e.g. "USD")</summary>
|
||||
public string CurrencyCode { get; set; } = "USD";
|
||||
|
||||
/// <summary>Time zone (e.g. "America/Los_Angeles")</summary>
|
||||
public string TimeZone { get; set; } = "America/Los_Angeles";
|
||||
|
||||
/// <summary>Optional descriptive name visible in MCC (defaults to AccountName)</summary>
|
||||
public string? DescriptiveName { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reporting Payloads
|
||||
|
||||
public sealed class CampaignStatsPayload
|
||||
|
||||
@@ -47,6 +47,9 @@ builder.Services.AddSwaggerGen(c =>
|
||||
|
||||
// Core services
|
||||
builder.Services.AddSingleton<GoogleAdsClientFactory>();
|
||||
builder.Services.AddSingleton<AudienceService>();
|
||||
builder.Services.AddSingleton<ReportingService>();
|
||||
builder.Services.AddSingleton<KeywordForecastService>();
|
||||
builder.Services.AddSingleton<GoogleAdsService>();
|
||||
|
||||
// Auth filter for internal calls from Gateway
|
||||
|
||||
318
GoogleApi/Services/AudienceService.cs
Normal file
318
GoogleApi/Services/AudienceService.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
using Google.Ads.GoogleAds.Lib;
|
||||
using Google.Ads.GoogleAds.V22.Services;
|
||||
using Google.Ads.GoogleAds.V22.Resources;
|
||||
using Google.Ads.GoogleAds.V22.Enums;
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// Alias to avoid namespace conflicts
|
||||
using GAdsServices = global::Google.Ads.GoogleAds.Services;
|
||||
|
||||
namespace GoogleApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying Google Ads audience segments and geo targets.
|
||||
/// </summary>
|
||||
public sealed class AudienceService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly ILogger<AudienceService> _logger;
|
||||
|
||||
public AudienceService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
ILogger<AudienceService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all available audience segments (affinity, in-market, life events, detailed demographics)
|
||||
/// </summary>
|
||||
public async Task<ProviderResponse> GetAudienceSegmentsAsync(
|
||||
GoogleAdsContext context,
|
||||
string requestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Fetching audience segments | RequestId={RequestId}", requestId);
|
||||
|
||||
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
|
||||
{
|
||||
return GetEmulatedAudienceSegments(requestId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var googleAdsService = client.GetService(GAdsServices.V22.GoogleAdsService);
|
||||
var customerId = context.CustomerId;
|
||||
|
||||
var response = new AudienceSegmentsResponse();
|
||||
|
||||
// Query user interests (Affinity + In-Market)
|
||||
var userInterestQuery = @"
|
||||
SELECT
|
||||
user_interest.user_interest_id,
|
||||
user_interest.name,
|
||||
user_interest.taxonomy_type,
|
||||
user_interest.availabilities
|
||||
FROM user_interest
|
||||
WHERE user_interest.taxonomy_type IN ('AFFINITY', 'IN_MARKET')";
|
||||
|
||||
var userInterestResults = googleAdsService.Search(customerId, userInterestQuery);
|
||||
|
||||
foreach (var row in userInterestResults)
|
||||
{
|
||||
var ui = row.UserInterest;
|
||||
var segment = new AudienceSegment
|
||||
{
|
||||
Id = ui.UserInterestId,
|
||||
Name = ui.Name,
|
||||
Type = ui.TaxonomyType.ToString()
|
||||
};
|
||||
|
||||
if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.Affinity)
|
||||
response.Affinity.Add(segment);
|
||||
else if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.InMarket)
|
||||
response.InMarket.Add(segment);
|
||||
}
|
||||
|
||||
// Query life events
|
||||
var lifeEventQuery = @"
|
||||
SELECT
|
||||
life_event.id,
|
||||
life_event.name
|
||||
FROM life_event";
|
||||
|
||||
var lifeEventResults = googleAdsService.Search(customerId, lifeEventQuery);
|
||||
|
||||
foreach (var row in lifeEventResults)
|
||||
{
|
||||
var le = row.LifeEvent;
|
||||
response.LifeEvents.Add(new AudienceSegment
|
||||
{
|
||||
Id = le.Id,
|
||||
Name = le.Name,
|
||||
Type = "LIFE_EVENT"
|
||||
});
|
||||
}
|
||||
|
||||
// Query detailed demographics
|
||||
var detailedDemoQuery = @"
|
||||
SELECT
|
||||
detailed_demographic.id,
|
||||
detailed_demographic.name
|
||||
FROM detailed_demographic";
|
||||
|
||||
var detailedDemoResults = googleAdsService.Search(customerId, detailedDemoQuery);
|
||||
|
||||
foreach (var row in detailedDemoResults)
|
||||
{
|
||||
var dd = row.DetailedDemographic;
|
||||
response.DetailedDemographics.Add(new AudienceSegment
|
||||
{
|
||||
Id = dd.Id,
|
||||
Name = dd.Name,
|
||||
Type = "DETAILED_DEMOGRAPHIC"
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Audience] Retrieved {Total} segments (Affinity={Affinity}, InMarket={InMarket}, LifeEvents={Life}, Demographics={Demo}) | RequestId={RequestId}",
|
||||
response.TotalCount, response.Affinity.Count, response.InMarket.Count,
|
||||
response.LifeEvents.Count, response.DetailedDemographics.Count, requestId);
|
||||
|
||||
return ProviderResponse.Success(requestId, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Audience] Failed to fetch segments | RequestId={RequestId}", requestId);
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search for geo target constants by name
|
||||
/// </summary>
|
||||
public async Task<ProviderResponse> SearchGeoTargetsAsync(
|
||||
GeoTargetSearchPayload payload,
|
||||
GoogleAdsContext context,
|
||||
string requestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Searching geo targets: {Query} | RequestId={RequestId}",
|
||||
payload.Query, requestId);
|
||||
|
||||
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
|
||||
{
|
||||
return GetEmulatedGeoTargets(payload.Query, requestId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var geoService = client.GetService(GAdsServices.V22.GeoTargetConstantService);
|
||||
|
||||
var request = new SuggestGeoTargetConstantsRequest
|
||||
{
|
||||
Locale = "en",
|
||||
CountryCode = payload.CountryCode ?? "US",
|
||||
LocationNames = new SuggestGeoTargetConstantsRequest.Types.LocationNames()
|
||||
};
|
||||
request.LocationNames.Names.Add(payload.Query);
|
||||
|
||||
var response = await geoService.SuggestGeoTargetConstantsAsync(request);
|
||||
|
||||
var results = response.GeoTargetConstantSuggestions
|
||||
.Take(payload.MaxResults)
|
||||
.Select(s => new GeoTarget
|
||||
{
|
||||
Id = s.GeoTargetConstant.Id,
|
||||
Name = s.GeoTargetConstant.Name,
|
||||
CanonicalName = s.GeoTargetConstant.CanonicalName,
|
||||
TargetType = s.GeoTargetConstant.TargetType,
|
||||
CountryCode = s.GeoTargetConstant.CountryCode,
|
||||
ParentGeoTarget = s.GeoTargetConstant.ParentGeoTarget
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("[Audience] Found {Count} geo targets for '{Query}' | RequestId={RequestId}",
|
||||
results.Count, payload.Query, requestId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
|
||||
{
|
||||
Query = payload.Query,
|
||||
Results = results
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Audience] Failed to search geo targets | RequestId={RequestId}", requestId);
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
#region Emulated Responses
|
||||
|
||||
private ProviderResponse GetEmulatedAudienceSegments(string requestId)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Returning emulated audience segments | RequestId={RequestId}", requestId);
|
||||
|
||||
var response = new AudienceSegmentsResponse
|
||||
{
|
||||
Affinity = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 80001, Name = "Sports & Fitness/Sports Fans", Type = "AFFINITY" },
|
||||
new() { Id = 80002, Name = "Sports & Fitness/Health & Fitness Buffs", Type = "AFFINITY" },
|
||||
new() { Id = 80003, Name = "Technology/Technophiles", Type = "AFFINITY" },
|
||||
new() { Id = 80004, Name = "Travel/Travel Buffs", Type = "AFFINITY" },
|
||||
new() { Id = 80005, Name = "Food & Dining/Foodies", Type = "AFFINITY" },
|
||||
new() { Id = 80006, Name = "Home & Garden/Home Decor Enthusiasts", Type = "AFFINITY" },
|
||||
new() { Id = 80007, Name = "Media & Entertainment/Movie Lovers", Type = "AFFINITY" },
|
||||
new() { Id = 80008, Name = "Media & Entertainment/Music Lovers", Type = "AFFINITY" },
|
||||
new() { Id = 80009, Name = "Shoppers/Value Shoppers", Type = "AFFINITY" },
|
||||
new() { Id = 80010, Name = "Shoppers/Luxury Shoppers", Type = "AFFINITY" },
|
||||
new() { Id = 80011, Name = "Lifestyles & Hobbies/Pet Lovers", Type = "AFFINITY" },
|
||||
new() { Id = 80012, Name = "Lifestyles & Hobbies/Outdoor Enthusiasts", Type = "AFFINITY" },
|
||||
new() { Id = 80013, Name = "News & Politics/Avid News Readers", Type = "AFFINITY" },
|
||||
new() { Id = 80014, Name = "Beauty & Wellness/Beauty Mavens", Type = "AFFINITY" },
|
||||
new() { Id = 80015, Name = "Vehicles & Transportation/Auto Enthusiasts", Type = "AFFINITY" },
|
||||
},
|
||||
InMarket = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 90001, Name = "Apparel & Accessories/Athletic Apparel", Type = "IN_MARKET" },
|
||||
new() { Id = 90002, Name = "Autos & Vehicles/Motor Vehicles (New)", Type = "IN_MARKET" },
|
||||
new() { Id = 90003, Name = "Autos & Vehicles/Motor Vehicles (Used)", Type = "IN_MARKET" },
|
||||
new() { Id = 90004, Name = "Business Services/Advertising & Marketing Services", Type = "IN_MARKET" },
|
||||
new() { Id = 90005, Name = "Consumer Electronics/Computers & Peripherals", Type = "IN_MARKET" },
|
||||
new() { Id = 90006, Name = "Consumer Electronics/Mobile Phones", Type = "IN_MARKET" },
|
||||
new() { Id = 90007, Name = "Education/Primary & Secondary Schools (K-12)", Type = "IN_MARKET" },
|
||||
new() { Id = 90008, Name = "Employment/Jobs", Type = "IN_MARKET" },
|
||||
new() { Id = 90009, Name = "Financial Services/Insurance/Auto Insurance", Type = "IN_MARKET" },
|
||||
new() { Id = 90010, Name = "Financial Services/Investment Services", Type = "IN_MARKET" },
|
||||
new() { Id = 90011, Name = "Home & Garden/Home Improvement", Type = "IN_MARKET" },
|
||||
new() { Id = 90012, Name = "Real Estate/Residential Properties", Type = "IN_MARKET" },
|
||||
new() { Id = 90013, Name = "Software/Business Software", Type = "IN_MARKET" },
|
||||
new() { Id = 90014, Name = "Travel/Hotels & Accommodations", Type = "IN_MARKET" },
|
||||
new() { Id = 90015, Name = "Travel/Air Travel", Type = "IN_MARKET" },
|
||||
},
|
||||
LifeEvents = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 70001, Name = "About to graduate college", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70002, Name = "Getting married soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70003, Name = "Recently married", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70004, Name = "Moving soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70005, Name = "Recently moved", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70006, Name = "Purchasing a home soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70007, Name = "Recently purchased a home", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70008, Name = "Starting a new job", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70009, Name = "Retiring soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70010, Name = "Recently started a business", Type = "LIFE_EVENT" },
|
||||
},
|
||||
DetailedDemographics = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 60001, Name = "Parental status/Parents/Parents of infants (0-1 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60002, Name = "Parental status/Parents/Parents of toddlers (1-3 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60003, Name = "Parental status/Parents/Parents of preschoolers (3-5 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60004, Name = "Parental status/Parents/Parents of grade schoolers (6-12 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60005, Name = "Parental status/Parents/Parents of teens (13-17 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60006, Name = "Marital status/Single", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60007, Name = "Marital status/In a relationship", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60008, Name = "Marital status/Married", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60009, Name = "Education/Current college students", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60010, Name = "Education/Bachelor's degree", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60011, Name = "Education/Advanced degree", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60012, Name = "Homeownership/Homeowners", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60013, Name = "Homeownership/Renters", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60014, Name = "Employment/Industry/Technology", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60015, Name = "Employment/Company size/Large employers (10,000+)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
},
|
||||
RetrievedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return ProviderResponse.Success(requestId, response);
|
||||
}
|
||||
|
||||
private ProviderResponse GetEmulatedGeoTargets(string query, string requestId)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Returning emulated geo targets for '{Query}' | RequestId={RequestId}",
|
||||
query, requestId);
|
||||
|
||||
var allTargets = new List<GeoTarget>
|
||||
{
|
||||
new() { Id = 9061285, Name = "Huntington Beach", CanonicalName = "Huntington Beach,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 1013962, Name = "Orange County", CanonicalName = "Orange County,California,United States", TargetType = "County", CountryCode = "US" },
|
||||
new() { Id = 21137, Name = "California", CanonicalName = "California,United States", TargetType = "State", CountryCode = "US" },
|
||||
new() { Id = 1014221, Name = "Los Angeles", CanonicalName = "Los Angeles,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 1014218, Name = "Los Angeles County", CanonicalName = "Los Angeles County,California,United States", TargetType = "County", CountryCode = "US" },
|
||||
new() { Id = 9031936, Name = "Irvine", CanonicalName = "Irvine,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 9031935, Name = "Costa Mesa", CanonicalName = "Costa Mesa,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 9031938, Name = "Newport Beach", CanonicalName = "Newport Beach,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 9031937, Name = "Santa Ana", CanonicalName = "Santa Ana,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 1023191, Name = "New York", CanonicalName = "New York,New York,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 21167, Name = "New York", CanonicalName = "New York,United States", TargetType = "State", CountryCode = "US" },
|
||||
new() { Id = 1014895, Name = "Chicago", CanonicalName = "Chicago,Illinois,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 2840, Name = "United States", CanonicalName = "United States", TargetType = "Country", CountryCode = "US" },
|
||||
};
|
||||
|
||||
// Simple filter by query
|
||||
var queryLower = query.ToLowerInvariant();
|
||||
var matches = allTargets
|
||||
.Where(t => t.Name.ToLowerInvariant().Contains(queryLower) ||
|
||||
t.CanonicalName.ToLowerInvariant().Contains(queryLower))
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
|
||||
{
|
||||
Query = query,
|
||||
Results = matches
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -23,15 +23,24 @@ public sealed class GoogleAdsService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly AudienceService _audienceService;
|
||||
private readonly ReportingService _reportingService;
|
||||
private readonly KeywordForecastService _forecastService;
|
||||
private readonly ILogger<GoogleAdsService> _logger;
|
||||
|
||||
public GoogleAdsService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
AudienceService audienceService,
|
||||
ReportingService reportingService,
|
||||
KeywordForecastService forecastService,
|
||||
ILogger<GoogleAdsService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_audienceService = audienceService;
|
||||
_reportingService = reportingService;
|
||||
_forecastService = forecastService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -65,7 +74,20 @@ public sealed class GoogleAdsService
|
||||
"GetCampaignStats" => GetCampaignStats(request, requestId),
|
||||
"GetAccountStats" => GetAccountStats(request, requestId),
|
||||
|
||||
// Reporting (Campaign Intelligence)
|
||||
"GetCampaignReport" => await _reportingService.GetCampaignReportAsync(request, context, requestId, ct),
|
||||
"GetAccountReport" => await _reportingService.GetAccountReportAsync(request, context, requestId, ct),
|
||||
|
||||
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
|
||||
"CreateCustomerClient" => await CreateCustomerClientAsync(request, context, requestId, ct),
|
||||
|
||||
// Audience Operations
|
||||
"GetAudienceSegments" => await _audienceService.GetAudienceSegmentsAsync(context, requestId, ct),
|
||||
"SearchGeoTargets" => await _audienceService.SearchGeoTargetsAsync(
|
||||
request.GetPayload<GeoTargetSearchPayload>(), context, requestId, ct),
|
||||
|
||||
// Forecast
|
||||
"KeywordForecast" => await ExecuteForecastAsync(request, context, requestId, ct),
|
||||
|
||||
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
|
||||
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
|
||||
@@ -94,6 +116,14 @@ public sealed class GoogleAdsService
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
private async Task<ProviderResponse> ExecuteForecastAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<KeywordForecastPayload>();
|
||||
var result = await _forecastService.ForecastAsync(payload, context, ct);
|
||||
return ProviderResponse.Success(requestId, result);
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> CreateCampaignAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
@@ -495,6 +525,100 @@ ORDER BY campaign.name";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> CreateCustomerClientAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<CreateCustomerClientPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.AccountName))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "AccountName is required");
|
||||
|
||||
// For emulated mode
|
||||
if (!_clientFactory.IsRealApiEnabled)
|
||||
{
|
||||
var fakeId = new Random().NextInt64(1000000000, 9999999999).ToString();
|
||||
_logger.LogInformation("[GoogleAds] EMULATED: Created customer client {AccountName} => {CustomerId}",
|
||||
payload.AccountName, fakeId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
customerId = fakeId,
|
||||
accountName = payload.AccountName,
|
||||
currencyCode = payload.CurrencyCode,
|
||||
timeZone = payload.TimeZone,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// CreateCustomerClient runs against the MCC (manager account).
|
||||
// The context.CustomerId should be the MCC ID.
|
||||
var mccId = GoogleAdsClientFactory.NormalizeCustomerId(
|
||||
context.CustomerId ?? request.TenantId ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mccId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION",
|
||||
"TenantId (MCC customer ID) is required to create a sub-account");
|
||||
|
||||
// Force LoginCustomerId = MCC for this call
|
||||
var mccContext = new GoogleAdsContext
|
||||
{
|
||||
CustomerId = mccId,
|
||||
LoginCustomerId = mccId
|
||||
};
|
||||
|
||||
GoogleAdsClient client = _clientFactory.CreateClient(mccContext);
|
||||
|
||||
CustomerServiceClient customerService =
|
||||
client.GetService(GAdsServices.V22.CustomerService);
|
||||
|
||||
var customerClient = new Customer
|
||||
{
|
||||
DescriptiveName = payload.DescriptiveName ?? payload.AccountName,
|
||||
CurrencyCode = payload.CurrencyCode,
|
||||
TimeZone = payload.TimeZone
|
||||
};
|
||||
|
||||
var response = await customerService.CreateCustomerClientAsync(
|
||||
new CreateCustomerClientRequest
|
||||
{
|
||||
CustomerId = mccId,
|
||||
CustomerClient = customerClient
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// Response contains the resource name like "customers/9336988646/customerClients/1234567890"
|
||||
var newCustomerId = response.ResourceName?.Split('/').LastOrDefault() ?? response.ResourceName;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[GoogleAds] Created customer client {AccountName} => {NewCustomerId} under MCC {MccId}",
|
||||
payload.AccountName, newCustomerId, mccId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
customerId = newCustomerId,
|
||||
resourceName = response.ResourceName,
|
||||
accountName = payload.AccountName,
|
||||
descriptiveName = payload.DescriptiveName ?? payload.AccountName,
|
||||
currencyCode = payload.CurrencyCode,
|
||||
timeZone = payload.TimeZone,
|
||||
mccId = mccId,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
catch (GoogleAdsException gex)
|
||||
{
|
||||
_logger.LogError(gex, "Google Ads API error creating customer client | RequestId={RequestId}", requestId);
|
||||
return HandleGoogleAdsException(gex, requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create customer client via real API");
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)
|
||||
|
||||
219
GoogleApi/Services/KeywordForecastService.cs
Normal file
219
GoogleApi/Services/KeywordForecastService.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using Google.Ads.GoogleAds.Lib;
|
||||
using Google.Ads.GoogleAds.V22.Services;
|
||||
using Google.Ads.GoogleAds.V22.Common;
|
||||
using Google.Ads.GoogleAds.V22.Enums;
|
||||
using Google.Ads.GoogleAds.V22.Resources;
|
||||
|
||||
namespace GoogleApi.Services;
|
||||
|
||||
using GAdsServices = global::Google.Ads.GoogleAds.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates keyword-level forecast metrics via Google Ads KeywordPlanIdeaService.
|
||||
/// Used by the wizard to show estimated performance before campaign creation.
|
||||
///
|
||||
/// EnableRealApi=false → emulated estimates based on budget + keyword count.
|
||||
/// EnableRealApi=true → calls GenerateKeywordForecastMetrics.
|
||||
/// </summary>
|
||||
public sealed class KeywordForecastService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly ILogger<KeywordForecastService> _logger;
|
||||
|
||||
public KeywordForecastService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
ILogger<KeywordForecastService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<KeywordForecastResponse> ForecastAsync(
|
||||
KeywordForecastPayload payload,
|
||||
GoogleAdsContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (payload.Keywords.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("[KeywordForecast] No keywords provided");
|
||||
return EmptyForecast();
|
||||
}
|
||||
|
||||
if (_clientFactory.IsRealApiEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ForecastRealAsync(payload, context, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[KeywordForecast] Real API failed, falling back to emulated");
|
||||
return ForecastEmulated(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return ForecastEmulated(payload);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Real API: GenerateKeywordForecastMetrics
|
||||
// ================================================================
|
||||
|
||||
private async Task<KeywordForecastResponse> ForecastRealAsync(
|
||||
KeywordForecastPayload payload,
|
||||
GoogleAdsContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[KeywordForecast] Real API | Keywords={Count} Budget={Budget} Geos={Geos}",
|
||||
payload.Keywords.Count, payload.MonthlyBudget, payload.GeoTargetIds.Count);
|
||||
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var service = client.GetService(GAdsServices.V22.KeywordPlanIdeaService);
|
||||
|
||||
// Build campaign to forecast
|
||||
var campaign = new CampaignToForecast
|
||||
{
|
||||
KeywordPlanNetwork = KeywordPlanNetworkEnum.Types.KeywordPlanNetwork.GoogleSearch
|
||||
};
|
||||
|
||||
// Geo targets
|
||||
foreach (var geoId in payload.GeoTargetIds)
|
||||
{
|
||||
campaign.GeoModifiers.Add(new CriterionBidModifier
|
||||
{
|
||||
GeoTargetConstant = GeoTargetConstantName.Format(geoId.ToString())
|
||||
});
|
||||
}
|
||||
|
||||
// Ad group with keywords (cap at 20 for sanity)
|
||||
var adGroup = new ForecastAdGroup();
|
||||
foreach (var keyword in payload.Keywords.Take(20))
|
||||
{
|
||||
adGroup.BiddableKeywords.Add(new BiddableKeyword
|
||||
{
|
||||
MaxCpcBidMicros = 2_000_000, // $2.00 default bid for simulation
|
||||
Keyword = new KeywordInfo
|
||||
{
|
||||
Text = keyword,
|
||||
MatchType = KeywordMatchTypeEnum.Types.KeywordMatchType.Broad
|
||||
}
|
||||
});
|
||||
}
|
||||
campaign.AdGroups.Add(adGroup);
|
||||
|
||||
var request = new GenerateKeywordForecastMetricsRequest
|
||||
{
|
||||
Campaign = campaign,
|
||||
ForecastPeriod = new DateRange
|
||||
{
|
||||
StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
|
||||
EndDate = DateTime.UtcNow.AddDays(payload.ForecastDays).ToString("yyyy-MM-dd")
|
||||
},
|
||||
CustomerId = context.CustomerId
|
||||
};
|
||||
|
||||
var response = await service.GenerateKeywordForecastMetricsAsync(request);
|
||||
var m = response.CampaignForecastMetrics;
|
||||
|
||||
// V22 SDK returns non-nullable primitives (0 when no data)
|
||||
var impressions = m.Impressions;
|
||||
var clicks = m.Clicks;
|
||||
var costMicros = m.CostMicros;
|
||||
var conversions = m.Conversions;
|
||||
var avgCpcMicros = m.AverageCpcMicros;
|
||||
|
||||
var cost = costMicros / 1_000_000m;
|
||||
var avgCpc = avgCpcMicros / 1_000_000m;
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var avgCpm = impressions > 0 ? (cost / (decimal)impressions) * 1000m : 0;
|
||||
var cpa = conversions > 0 ? cost / (decimal)conversions : (decimal?)null;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[KeywordForecast] Real result | Imp={Imp} Clicks={Clicks} Cost={Cost}",
|
||||
impressions, clicks, cost);
|
||||
|
||||
return new KeywordForecastResponse
|
||||
{
|
||||
Provider = "google",
|
||||
Monthly = new ForecastEstimates
|
||||
{
|
||||
Impressions = impressions,
|
||||
Clicks = clicks,
|
||||
Cost = cost,
|
||||
Conversions = conversions
|
||||
},
|
||||
Metrics = new ForecastMetrics
|
||||
{
|
||||
AvgCpc = avgCpc,
|
||||
AvgCpm = avgCpm,
|
||||
Ctr = ctr,
|
||||
EstimatedCpa = cpa
|
||||
},
|
||||
Confidence = "medium",
|
||||
DataSource = "keywordForecast"
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Emulated: budget-proportional estimates with realistic variance
|
||||
// ================================================================
|
||||
|
||||
private KeywordForecastResponse ForecastEmulated(KeywordForecastPayload payload)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[KeywordForecast] Emulated | Keywords={Count} Budget={Budget}",
|
||||
payload.Keywords.Count, payload.MonthlyBudget);
|
||||
|
||||
// Realistic Search ranges for SMB tier
|
||||
var keywordFactor = Math.Min(payload.Keywords.Count, 20) / 20.0;
|
||||
var baseCpc = 2.50m - (decimal)(keywordFactor * 1.20); // $1.30 – $2.50
|
||||
|
||||
var budget = payload.MonthlyBudget;
|
||||
var clicks = budget > 0 ? (double)(budget / baseCpc) : 0;
|
||||
var impressions = clicks / 0.045; // ~4.5% CTR
|
||||
var conversions = clicks * 0.035; // ~3.5% conv rate
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
// Seeded variance (±15%) — same inputs → same output, but not canned-looking
|
||||
var rng = new Random((int)(budget * 100) + payload.Keywords.Count);
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
return new KeywordForecastResponse
|
||||
{
|
||||
Provider = "google",
|
||||
Monthly = new ForecastEstimates
|
||||
{
|
||||
Impressions = Math.Round(impressions * v),
|
||||
Clicks = Math.Round(clicks * v),
|
||||
Cost = Math.Round(budget * (decimal)v, 2),
|
||||
Conversions = Math.Round(conversions * v, 1)
|
||||
},
|
||||
Metrics = new ForecastMetrics
|
||||
{
|
||||
AvgCpc = Math.Round(baseCpc, 2),
|
||||
AvgCpm = Math.Round((decimal)cpm, 2),
|
||||
Ctr = Math.Round(ctr, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null
|
||||
},
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
private static KeywordForecastResponse EmptyForecast() => new()
|
||||
{
|
||||
Provider = "google",
|
||||
Confidence = "none",
|
||||
DataSource = "none"
|
||||
};
|
||||
}
|
||||
382
GoogleApi/Services/ReportingService.cs
Normal file
382
GoogleApi/Services/ReportingService.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user