Initial import into Gitea
This commit is contained in:
257
TikTokApi/Services/TikTokApiClient.cs
Normal file
257
TikTokApi/Services/TikTokApiClient.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using TikTokApi.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TikTokApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP wrapper for TikTok Marketing API calls.
|
||||
/// Handles authentication, API versioning, error parsing, and response envelope unwrapping.
|
||||
///
|
||||
/// TikTok Marketing API pattern:
|
||||
/// Base: https://business-api.tiktok.com/open_api/{version}/{endpoint}
|
||||
/// Auth: Access-Token header (NOT query param, NOT Bearer)
|
||||
/// Request: JSON body for POST, query params for GET
|
||||
/// Response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."}
|
||||
/// Code 0 = success, anything else = error
|
||||
///
|
||||
/// Sandbox: https://sandbox-ads.tiktok.com/open_api/{version}/{endpoint}
|
||||
/// </summary>
|
||||
public sealed class TikTokApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly TikTokConfig _config;
|
||||
private readonly ILogger<TikTokApiClient> _logger;
|
||||
|
||||
public TikTokApiClient(HttpClient http, IOptions<TikTokConfig> config, ILogger<TikTokApiClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_config = config.Value;
|
||||
_logger = logger;
|
||||
|
||||
var baseUrl = _config.ApiBaseUrl.TrimEnd('/');
|
||||
_http.BaseAddress = new Uri($"{baseUrl}/open_api/{_config.ApiVersion}/");
|
||||
_http.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds);
|
||||
}
|
||||
|
||||
public bool IsRealApiEnabled => _config.EnableRealApi
|
||||
&& !string.IsNullOrWhiteSpace(_config.AccessToken);
|
||||
|
||||
// ================================================================
|
||||
// GET - for read operations (campaign/get, advertiser/info, etc.)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// GET request to TikTok Marketing API.
|
||||
/// TikTok GET endpoints use query parameters.
|
||||
/// </summary>
|
||||
public async Task<TikTokApiResponse> GetAsync(
|
||||
string endpoint, Dictionary<string, string>? queryParams = null, CancellationToken ct = default)
|
||||
{
|
||||
var url = BuildUrl(endpoint, queryParams);
|
||||
var safeUrl = SanitizeForLogging(url);
|
||||
|
||||
_logger.LogDebug("[TikTokApi] GET {Url}", safeUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
InjectAuth(request);
|
||||
|
||||
var response = await _http.SendAsync(request, ct);
|
||||
return await ParseResponseAsync(response, safeUrl, ct);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("[TikTokApi] GET {Url} timed out", safeUrl);
|
||||
return TikTokApiResponse.Error("Request timed out", -1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TikTokApi] GET {Url} failed", safeUrl);
|
||||
return TikTokApiResponse.Error(ex.Message, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// POST - for write operations (campaign/create, campaign/update, etc.)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// POST request to TikTok Marketing API.
|
||||
/// TikTok POST endpoints accept JSON body.
|
||||
/// </summary>
|
||||
public async Task<TikTokApiResponse> PostAsync(
|
||||
string endpoint, object body, CancellationToken ct = default)
|
||||
{
|
||||
var safeEndpoint = SanitizeForLogging(endpoint);
|
||||
_logger.LogDebug("[TikTokApi] POST {Endpoint}", safeEndpoint);
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
InjectAuth(request);
|
||||
|
||||
var response = await _http.SendAsync(request, ct);
|
||||
return await ParseResponseAsync(response, safeEndpoint, ct);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("[TikTokApi] POST {Endpoint} timed out", safeEndpoint);
|
||||
return TikTokApiResponse.Error("Request timed out", -1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TikTokApi] POST {Endpoint} failed", safeEndpoint);
|
||||
return TikTokApiResponse.Error(ex.Message, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Response parsing
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Parse TikTok response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."}
|
||||
/// Code 0 = success, anything else = error.
|
||||
/// </summary>
|
||||
private async Task<TikTokApiResponse> ParseResponseAsync(
|
||||
HttpResponseMessage response, string context, CancellationToken ct)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[TikTokApi] HTTP {StatusCode} from {Context}: {Body}",
|
||||
(int)response.StatusCode, context, Truncate(body, 500));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var code = root.TryGetProperty("code", out var codeProp) ? codeProp.GetInt32() : -1;
|
||||
var message = root.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null;
|
||||
var requestId = root.TryGetProperty("request_id", out var ridProp) ? ridProp.GetString() : null;
|
||||
|
||||
if (code == 0)
|
||||
{
|
||||
// Success - extract data
|
||||
JsonElement? data = root.TryGetProperty("data", out var dataProp) ? dataProp.Clone() : null;
|
||||
|
||||
_logger.LogDebug("[TikTokApi] Success from {Context} | RequestId={RequestId}",
|
||||
context, requestId);
|
||||
|
||||
return new TikTokApiResponse
|
||||
{
|
||||
IsSuccess = true,
|
||||
Code = 0,
|
||||
Message = message ?? "OK",
|
||||
Data = data,
|
||||
TikTokRequestId = requestId
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Error
|
||||
_logger.LogWarning(
|
||||
"[TikTokApi] Error from {Context} | Code={Code} Message={Message} RequestId={RequestId}",
|
||||
context, code, message, requestId);
|
||||
|
||||
return new TikTokApiResponse
|
||||
{
|
||||
IsSuccess = false,
|
||||
Code = code,
|
||||
Message = message ?? "Unknown error",
|
||||
TikTokRequestId = requestId
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TikTokApi] Failed to parse response from {Context}: {Body}",
|
||||
context, Truncate(body, 300));
|
||||
return TikTokApiResponse.Error($"Invalid JSON response: {ex.Message}", -1);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Inject Access-Token header. TikTok uses a custom header name, NOT "Authorization: Bearer".
|
||||
/// </summary>
|
||||
private void InjectAuth(HttpRequestMessage request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_config.AccessToken))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Access-Token", _config.AccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildUrl(string endpoint, Dictionary<string, string>? queryParams)
|
||||
{
|
||||
if (queryParams == null || queryParams.Count == 0)
|
||||
return endpoint;
|
||||
|
||||
var sb = new StringBuilder(endpoint);
|
||||
sb.Append('?');
|
||||
var first = true;
|
||||
foreach (var (key, value) in queryParams)
|
||||
{
|
||||
if (!first) sb.Append('&');
|
||||
sb.Append(Uri.EscapeDataString(key));
|
||||
sb.Append('=');
|
||||
sb.Append(Uri.EscapeDataString(value));
|
||||
first = false;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip tokens/secrets from URLs for safe logging.
|
||||
/// </summary>
|
||||
private static string SanitizeForLogging(string input)
|
||||
{
|
||||
// TikTok doesn't put tokens in URLs (they're in headers), but sanitize just in case
|
||||
return input;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
=> text.Length <= maxLength ? text : text[..maxLength] + "...";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed TikTok API response.
|
||||
/// TikTok envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."}
|
||||
/// </summary>
|
||||
public sealed class TikTokApiResponse
|
||||
{
|
||||
public bool IsSuccess { get; set; }
|
||||
|
||||
/// <summary>TikTok error code. 0 = success.</summary>
|
||||
public int Code { get; set; }
|
||||
|
||||
/// <summary>Human-readable message from TikTok.</summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>Response data payload (when successful).</summary>
|
||||
public JsonElement? Data { get; set; }
|
||||
|
||||
/// <summary>TikTok-assigned request ID for support debugging.</summary>
|
||||
public string? TikTokRequestId { get; set; }
|
||||
|
||||
public static TikTokApiResponse Error(string message, int code)
|
||||
=> new() { IsSuccess = false, Code = code, Message = message };
|
||||
}
|
||||
641
TikTokApi/Services/TikTokMarketingService.cs
Normal file
641
TikTokApi/Services/TikTokMarketingService.cs
Normal file
@@ -0,0 +1,641 @@
|
||||
using System.Text.Json;
|
||||
using TikTokApi.Configuration;
|
||||
using TikTokApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TikTokApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Core service for TikTok Marketing API operations.
|
||||
/// Follows the same dual-mode pattern as GoogleAdsService / MetaMarketingService:
|
||||
/// - When EnableRealApi=false: returns emulated responses
|
||||
/// - When EnableRealApi=true: makes real Marketing API calls
|
||||
///
|
||||
/// TikTok Marketing API endpoints:
|
||||
/// Campaign: /campaign/create/, /campaign/get/, /campaign/update/, /campaign/status/update/
|
||||
/// Ad Group: /adgroup/create/, /adgroup/get/, /adgroup/update/
|
||||
/// Report: /report/integrated/get/
|
||||
/// BC: /bc/advertiser/create, /bc/advertiser/get, /bc/transfer/
|
||||
/// </summary>
|
||||
public sealed class TikTokMarketingService
|
||||
{
|
||||
private readonly TikTokConfig _config;
|
||||
private readonly TikTokApiClient _apiClient;
|
||||
private readonly ILogger<TikTokMarketingService> _logger;
|
||||
|
||||
public TikTokMarketingService(
|
||||
IOptions<TikTokConfig> config,
|
||||
TikTokApiClient apiClient,
|
||||
ILogger<TikTokMarketingService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_apiClient = apiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ProviderResponse> ExecuteAsync(ProviderRequest request, CancellationToken ct)
|
||||
{
|
||||
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
|
||||
var operation = (request.Operation ?? string.Empty).Trim();
|
||||
|
||||
_logger.LogInformation(
|
||||
"[TikTokAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}",
|
||||
operation, requestId, request.TenantId, _apiClient.IsRealApiEnabled);
|
||||
|
||||
try
|
||||
{
|
||||
var context = new TikTokApiContext
|
||||
{
|
||||
AdvertiserId = request.TenantId ?? string.Empty,
|
||||
BusinessCenterId = request.LoginCustomerId ?? _config.BusinessCenterId
|
||||
};
|
||||
|
||||
var result = operation switch
|
||||
{
|
||||
"Ping" => Ping(requestId),
|
||||
"TestPing" => Ping(requestId),
|
||||
|
||||
// Campaign operations
|
||||
"CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct),
|
||||
"GetCampaign" => await GetCampaignAsync(request, context, requestId, ct),
|
||||
"UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct),
|
||||
"ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct),
|
||||
"UpdateCampaignStatus" => await UpdateCampaignStatusAsync(request, context, requestId, ct),
|
||||
|
||||
// Reporting
|
||||
"GetReport" => await GetReportAsync(request, context, requestId, ct),
|
||||
|
||||
// Advertiser (ad account) management via Business Center
|
||||
"CreateAdvertiser" => await CreateAdvertiserAsync(request, context, requestId, ct),
|
||||
"ListAdvertisers" => await ListAdvertisersAsync(context, requestId, ct),
|
||||
|
||||
// Fund management (BC-specific)
|
||||
"TransferFunds" => await TransferFundsAsync(request, context, requestId, ct),
|
||||
|
||||
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
|
||||
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"[TikTokAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}",
|
||||
operation, requestId, result.Ok);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TikTokAds] Error in {Operation} | RequestId={RequestId}", operation, requestId);
|
||||
return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Ping
|
||||
// ================================================================
|
||||
|
||||
private ProviderResponse Ping(string requestId)
|
||||
=> ProviderResponse.Success(requestId, new
|
||||
{
|
||||
message = "TikTokApi provider is healthy",
|
||||
service = "TikTokApi",
|
||||
realApiEnabled = _apiClient.IsRealApiEnabled,
|
||||
apiVersion = _config.ApiVersion,
|
||||
businessCenterId = _config.BusinessCenterId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Campaign Operations
|
||||
// ================================================================
|
||||
|
||||
private async Task<ProviderResponse> CreateCampaignAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<CreateCampaignPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.Name))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId))
|
||||
return await CreateCampaignRealAsync(payload, context, requestId, ct);
|
||||
|
||||
// Emulated
|
||||
var emulatedId = GenerateId().ToString();
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId = emulatedId,
|
||||
name = payload.Name,
|
||||
objective = MapObjectiveToApi(payload.Objective),
|
||||
status = MapStatusToApi(payload.Status),
|
||||
budgetMode = MapBudgetModeToApi(payload.BudgetMode),
|
||||
budget = payload.Budget,
|
||||
advertiserId = context.AdvertiserId,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> CreateCampaignRealAsync(
|
||||
CreateCampaignPayload payload, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
// POST /campaign/create/
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["advertiser_id"] = context.AdvertiserId,
|
||||
["campaign_name"] = payload.Name,
|
||||
["objective_type"] = MapObjectiveToApi(payload.Objective),
|
||||
["budget_mode"] = MapBudgetModeToApi(payload.BudgetMode),
|
||||
["operation_status"] = MapStatusToApi(payload.Status)
|
||||
};
|
||||
|
||||
if (payload.Budget.HasValue)
|
||||
body["budget"] = payload.Budget.Value;
|
||||
|
||||
if (payload.SpecialIndustries.Count > 0)
|
||||
body["special_industries"] = payload.SpecialIndustries;
|
||||
|
||||
var result = await _apiClient.PostAsync("campaign/create/", body, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to create campaign",
|
||||
new { tikTokCode = result.Code, tikTokRequestId = result.TikTokRequestId });
|
||||
|
||||
var campaignId = result.Data?.TryGetProperty("campaign_id", out var idProp) == true
|
||||
? idProp.GetString() : null;
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId,
|
||||
name = payload.Name,
|
||||
objective = MapObjectiveToApi(payload.Objective),
|
||||
advertiserId = context.AdvertiserId,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> GetCampaignAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<GetCampaignPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.CampaignId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId))
|
||||
{
|
||||
// GET /campaign/get/ with filtering
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
["advertiser_id"] = context.AdvertiserId,
|
||||
["filtering"] = JsonSerializer.Serialize(new
|
||||
{
|
||||
campaign_ids = new[] { payload.CampaignId }
|
||||
})
|
||||
};
|
||||
|
||||
var result = await _apiClient.GetAsync("campaign/get/", queryParams, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to get campaign");
|
||||
|
||||
return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false });
|
||||
}
|
||||
|
||||
// Emulated
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId = payload.CampaignId,
|
||||
name = $"Emulated Campaign {payload.CampaignId}",
|
||||
objectiveType = "TRAFFIC",
|
||||
operationStatus = "DISABLE",
|
||||
budgetMode = "BUDGET_MODE_DAY",
|
||||
budget = 50.00m,
|
||||
createTime = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"),
|
||||
modifyTime = DateTimeOffset.UtcNow.ToString("o"),
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> UpdateCampaignAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<UpdateCampaignPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.CampaignId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId))
|
||||
{
|
||||
// POST /campaign/update/
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["advertiser_id"] = context.AdvertiserId,
|
||||
["campaign_id"] = payload.CampaignId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(payload.Name))
|
||||
body["campaign_name"] = payload.Name;
|
||||
|
||||
if (payload.Budget.HasValue)
|
||||
body["budget"] = payload.Budget.Value;
|
||||
|
||||
var result = await _apiClient.PostAsync("campaign/update/", body, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to update campaign");
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId = payload.CampaignId,
|
||||
updated = true,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
|
||||
// Emulated
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId = payload.CampaignId,
|
||||
updated = true,
|
||||
name = payload.Name,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> UpdateCampaignStatusAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<UpdateCampaignPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.CampaignId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
|
||||
|
||||
if (!payload.Status.HasValue)
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "Status is required");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId))
|
||||
{
|
||||
// POST /campaign/status/update/ (separate endpoint from /campaign/update/)
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["advertiser_id"] = context.AdvertiserId,
|
||||
["campaign_ids"] = new[] { payload.CampaignId },
|
||||
["operation_status"] = MapStatusToApi(payload.Status.Value)
|
||||
};
|
||||
|
||||
var result = await _apiClient.PostAsync("campaign/status/update/", body, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to update campaign status");
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId = payload.CampaignId,
|
||||
status = MapStatusToApi(payload.Status.Value),
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
|
||||
// Emulated
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId = payload.CampaignId,
|
||||
status = MapStatusToApi(payload.Status.Value),
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> ListCampaignsAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<ListCampaignsPayload>();
|
||||
|
||||
if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId))
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
["advertiser_id"] = context.AdvertiserId,
|
||||
["page_size"] = payload.PageSize.ToString(),
|
||||
["page"] = payload.Page.ToString()
|
||||
};
|
||||
|
||||
if (payload.StatusFilter.HasValue)
|
||||
{
|
||||
queryParams["filtering"] = JsonSerializer.Serialize(new
|
||||
{
|
||||
operation_status = MapStatusToApi(payload.StatusFilter.Value)
|
||||
});
|
||||
}
|
||||
|
||||
var result = await _apiClient.GetAsync("campaign/get/", queryParams, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to list campaigns");
|
||||
|
||||
return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false });
|
||||
}
|
||||
|
||||
// Emulated
|
||||
var campaigns = Enumerable.Range(1, 3).Select(i => new
|
||||
{
|
||||
campaign_id = GenerateId().ToString(),
|
||||
campaign_name = $"Emulated Campaign {i}",
|
||||
objective_type = "TRAFFIC",
|
||||
operation_status = i == 1 ? "ENABLE" : "DISABLE",
|
||||
budget_mode = "BUDGET_MODE_DAY",
|
||||
budget = 50.00m * i,
|
||||
create_time = DateTimeOffset.UtcNow.AddDays(-i * 7).ToString("o")
|
||||
});
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaigns,
|
||||
advertiserId = context.AdvertiserId,
|
||||
pageInfo = new { page = 1, pageSize = 50, totalNumber = 3, totalPage = 1 },
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Reporting
|
||||
// ================================================================
|
||||
|
||||
private async Task<ProviderResponse> GetReportAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<ReportPayload>();
|
||||
|
||||
if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId))
|
||||
{
|
||||
// POST /report/integrated/get/
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["advertiser_id"] = context.AdvertiserId,
|
||||
["report_type"] = payload.ReportType,
|
||||
["data_level"] = payload.DataLevel,
|
||||
["dimensions"] = payload.Dimensions,
|
||||
["metrics"] = payload.Metrics,
|
||||
["page_size"] = payload.PageSize,
|
||||
["page"] = payload.Page,
|
||||
["lifetime"] = payload.Lifetime
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(payload.StartDate))
|
||||
body["start_date"] = payload.StartDate;
|
||||
if (!string.IsNullOrWhiteSpace(payload.EndDate))
|
||||
body["end_date"] = payload.EndDate;
|
||||
if (payload.Filters?.Count > 0)
|
||||
body["filters"] = payload.Filters;
|
||||
|
||||
var result = await _apiClient.PostAsync("report/integrated/get/", body, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to get report");
|
||||
|
||||
return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false });
|
||||
}
|
||||
|
||||
// Emulated report data
|
||||
var rng = new Random();
|
||||
var rows = Enumerable.Range(0, 7).Select(i =>
|
||||
{
|
||||
var date = DateTime.UtcNow.Date.AddDays(-i);
|
||||
var impressions = rng.Next(2000, 80000);
|
||||
var clicks = rng.Next(100, impressions / 8);
|
||||
var spend = Math.Round(clicks * (rng.NextDouble() * 1.5 + 0.3), 2);
|
||||
return new
|
||||
{
|
||||
dimensions = new { stat_time_day = date.ToString("yyyy-MM-dd"), campaign_id = GenerateId().ToString() },
|
||||
metrics = new
|
||||
{
|
||||
spend = spend.ToString("F2"),
|
||||
impressions = impressions.ToString(),
|
||||
clicks = clicks.ToString(),
|
||||
cpc = (spend / clicks).ToString("F2"),
|
||||
ctr = (clicks * 100.0 / impressions).ToString("F2"),
|
||||
cpm = (spend / impressions * 1000).ToString("F2")
|
||||
}
|
||||
};
|
||||
}).Reverse();
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
list = rows,
|
||||
pageInfo = new { page = 1, pageSize = 50, totalNumber = 7, totalPage = 1 },
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Advertiser (Ad Account) Management via Business Center
|
||||
// ================================================================
|
||||
|
||||
private async Task<ProviderResponse> CreateAdvertiserAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<CreateAdvertiserPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.Name))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "Advertiser name is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.BusinessCenterId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessCenterId is required");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled)
|
||||
{
|
||||
// POST /bc/advertiser/create
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["bc_id"] = context.BusinessCenterId,
|
||||
["advertiser_name"] = payload.Name,
|
||||
["currency"] = payload.Currency,
|
||||
["timezone"] = payload.Timezone,
|
||||
["company"] = payload.Company
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(payload.IndustryId))
|
||||
body["industry_id"] = payload.IndustryId;
|
||||
if (!string.IsNullOrWhiteSpace(payload.ContactEmail))
|
||||
body["contact_email"] = payload.ContactEmail;
|
||||
if (!string.IsNullOrWhiteSpace(payload.ContactPhone))
|
||||
body["contact_phone"] = payload.ContactPhone;
|
||||
|
||||
var result = await _apiClient.PostAsync("bc/advertiser/create", body, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to create advertiser",
|
||||
new { tikTokCode = result.Code, tikTokRequestId = result.TikTokRequestId });
|
||||
|
||||
var advertiserId = result.Data?.TryGetProperty("advertiser_id", out var idProp) == true
|
||||
? idProp.GetString() : null;
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
advertiserId,
|
||||
name = payload.Name,
|
||||
currency = payload.Currency,
|
||||
businessCenterId = context.BusinessCenterId,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
|
||||
// Emulated
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
advertiserId = GenerateId().ToString(),
|
||||
name = payload.Name,
|
||||
currency = payload.Currency,
|
||||
timezone = payload.Timezone,
|
||||
businessCenterId = context.BusinessCenterId,
|
||||
status = "STATUS_ENABLE",
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> ListAdvertisersAsync(
|
||||
TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.BusinessCenterId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessCenterId is required");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled)
|
||||
{
|
||||
// GET /bc/advertiser/get
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
["bc_id"] = context.BusinessCenterId,
|
||||
["page_size"] = "100",
|
||||
["page"] = "1"
|
||||
};
|
||||
|
||||
var result = await _apiClient.GetAsync("bc/advertiser/get", queryParams, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to list advertisers");
|
||||
|
||||
return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false });
|
||||
}
|
||||
|
||||
// Emulated
|
||||
var advertisers = Enumerable.Range(1, 3).Select(i => new
|
||||
{
|
||||
advertiser_id = GenerateId().ToString(),
|
||||
advertiser_name = $"Client Account {i}",
|
||||
status = "STATUS_ENABLE",
|
||||
currency = "USD",
|
||||
timezone = "America/Los_Angeles",
|
||||
balance = (i * 500.00m).ToString("F2")
|
||||
});
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
advertisers,
|
||||
businessCenterId = context.BusinessCenterId,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Fund Management (Business Center)
|
||||
// ================================================================
|
||||
|
||||
private async Task<ProviderResponse> TransferFundsAsync(
|
||||
ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<TransferFundsPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.AdvertiserId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "AdvertiserId is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.BusinessCenterId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessCenterId is required");
|
||||
|
||||
if (payload.Amount <= 0)
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "Amount must be greater than zero");
|
||||
|
||||
if (_apiClient.IsRealApiEnabled)
|
||||
{
|
||||
// POST /bc/transfer/
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["bc_id"] = context.BusinessCenterId,
|
||||
["advertiser_id"] = payload.AdvertiserId,
|
||||
["transfer_type"] = payload.TransferType,
|
||||
["cash_amount"] = payload.Amount
|
||||
};
|
||||
|
||||
var result = await _apiClient.PostAsync("bc/transfer/", body, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR",
|
||||
result.Message ?? "Failed to transfer funds");
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
advertiserId = payload.AdvertiserId,
|
||||
transferType = payload.TransferType,
|
||||
amount = payload.Amount,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
|
||||
// Emulated
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
advertiserId = payload.AdvertiserId,
|
||||
transferType = payload.TransferType,
|
||||
amount = payload.Amount,
|
||||
balanceAfter = payload.TransferType == "RECHARGE" ? 1500.00m : 500.00m,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Map platform objective enum to TikTok API string.
|
||||
/// </summary>
|
||||
private static string MapObjectiveToApi(TikTokObjective objective) => objective switch
|
||||
{
|
||||
TikTokObjective.Reach => "REACH",
|
||||
TikTokObjective.Traffic => "TRAFFIC",
|
||||
TikTokObjective.VideoViews => "VIDEO_VIEWS",
|
||||
TikTokObjective.LeadGeneration => "LEAD_GENERATION",
|
||||
TikTokObjective.CommunityInteraction => "COMMUNITY_INTERACTION",
|
||||
TikTokObjective.AppPromotion => "APP_PROMOTION",
|
||||
TikTokObjective.WebConversions => "WEB_CONVERSIONS",
|
||||
TikTokObjective.ProductSales => "PRODUCT_SALES",
|
||||
_ => "TRAFFIC"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map platform status enum to TikTok API string.
|
||||
/// TikTok uses ENABLE/DISABLE, not ACTIVE/PAUSED.
|
||||
/// </summary>
|
||||
private static string MapStatusToApi(TikTokCampaignStatus status) => status switch
|
||||
{
|
||||
TikTokCampaignStatus.Enable => "ENABLE",
|
||||
TikTokCampaignStatus.Disable => "DISABLE",
|
||||
TikTokCampaignStatus.Delete => "DELETE",
|
||||
_ => "DISABLE"
|
||||
};
|
||||
|
||||
private static string MapBudgetModeToApi(TikTokBudgetMode mode) => mode switch
|
||||
{
|
||||
TikTokBudgetMode.Day => "BUDGET_MODE_DAY",
|
||||
TikTokBudgetMode.Total => "BUDGET_MODE_TOTAL",
|
||||
TikTokBudgetMode.Infinite => "BUDGET_MODE_INFINITE",
|
||||
_ => "BUDGET_MODE_DAY"
|
||||
};
|
||||
|
||||
private static long GenerateId() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000 + Random.Shared.Next(999);
|
||||
}
|
||||
Reference in New Issue
Block a user