Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View 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 };
}

View 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);
}