using System.Text.Json; using TikTokApi.Configuration; using TikTokApi.Models; using Microsoft.Extensions.Options; namespace TikTokApi.Services; /// /// 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/ /// public sealed class TikTokMarketingService { private readonly TikTokConfig _config; private readonly TikTokApiClient _apiClient; private readonly ILogger _logger; public TikTokMarketingService( IOptions config, TikTokApiClient apiClient, ILogger logger) { _config = config.Value; _apiClient = apiClient; _logger = logger; } public async Task 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 CreateCampaignAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); 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 CreateCampaignRealAsync( CreateCampaignPayload payload, TikTokApiContext context, string requestId, CancellationToken ct) { // POST /campaign/create/ var body = new Dictionary { ["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 GetCampaignAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); 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 { ["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 UpdateCampaignAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); 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 { ["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 UpdateCampaignStatusAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); 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 { ["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 ListCampaignsAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) { var queryParams = new Dictionary { ["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 GetReportAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) { // POST /report/integrated/get/ var body = new Dictionary { ["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 CreateAdvertiserAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); 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 { ["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 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 { ["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 TransferFundsAsync( ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); 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 { ["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 // ================================================================ /// /// Map platform objective enum to TikTok API string. /// 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" }; /// /// Map platform status enum to TikTok API string. /// TikTok uses ENABLE/DISABLE, not ACTIVE/PAUSED. /// 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); }