Initial import into Gitea
This commit is contained in:
95
TikTokApi/Configuration/TikTokConfig.cs
Normal file
95
TikTokApi/Configuration/TikTokConfig.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
namespace TikTokApi.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for TikTok Marketing API integration.
|
||||
/// Bind to the "TikTok" section in appsettings.json or environment variables.
|
||||
///
|
||||
/// TikTok auth model:
|
||||
/// - Register as developer → create app → OAuth authorize → get access_token
|
||||
/// - Access tokens do NOT expire unless the advertiser revokes authorization
|
||||
/// - Token is passed via "Access-Token" HTTP header (not query param like Meta)
|
||||
///
|
||||
/// Agency model:
|
||||
/// - Business Center (BC) is the parent entity (equivalent to Google MCC / Meta BM)
|
||||
/// - BC owns advertiser accounts (ad accounts) created for clients
|
||||
/// - BC API: /bc/advertiser/create, /bc/asset/get, /bc/transfer (fund management)
|
||||
/// </summary>
|
||||
public sealed class TikTokConfig
|
||||
{
|
||||
public const string SectionName = "TikTok";
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable real API calls. When false, the provider returns emulated responses.
|
||||
/// Override via: TikTok__EnableRealApi=true
|
||||
/// </summary>
|
||||
public bool EnableRealApi { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// TikTok Marketing API version (e.g. "v1.3").
|
||||
/// Used in URL path: /open_api/v1.3/...
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "v1.3";
|
||||
|
||||
/// <summary>
|
||||
/// TikTok App ID from the Developer Portal (My Apps).
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// TikTok App Secret from the Developer Portal.
|
||||
/// Store in Key Vault; inject via environment variable in prod.
|
||||
/// </summary>
|
||||
public string AppSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Long-lived access token obtained via OAuth authorization flow.
|
||||
/// Does NOT expire unless the advertiser revokes authorization.
|
||||
/// Passed in "Access-Token" header on all API calls.
|
||||
/// Store in Key Vault; inject via environment variable in prod.
|
||||
/// </summary>
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// USIM's Business Center ID.
|
||||
/// All client advertiser accounts are created under this BC.
|
||||
/// Format: numeric string (e.g. "7123456789012345678")
|
||||
/// </summary>
|
||||
public string BusinessCenterId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Default advertiser ID for testing/sandbox.
|
||||
/// Format: numeric string (e.g. "7123456789012345678")
|
||||
/// </summary>
|
||||
public string? DefaultAdvertiserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds for API calls.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// TikTok Marketing API base URL.
|
||||
/// Production: https://business-api.tiktok.com
|
||||
/// Sandbox: https://sandbox-ads.tiktok.com
|
||||
/// </summary>
|
||||
public string ApiBaseUrl { get; set; } = "https://business-api.tiktok.com";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-request TikTok API context extracted from ProviderRequest.
|
||||
/// </summary>
|
||||
public sealed class TikTokApiContext
|
||||
{
|
||||
/// <summary>
|
||||
/// TikTok advertiser (ad account) ID.
|
||||
/// Derived from ProviderRequest.TenantId (accExternalAccountId).
|
||||
/// Format: numeric string.
|
||||
/// </summary>
|
||||
public string AdvertiserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Business Center ID that owns/manages the advertiser account.
|
||||
/// Falls back to TikTokConfig.BusinessCenterId if not in request.
|
||||
/// </summary>
|
||||
public string? BusinessCenterId { get; set; }
|
||||
}
|
||||
83
TikTokApi/Controllers/InternalController.cs
Normal file
83
TikTokApi/Controllers/InternalController.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TikTokApi.Models;
|
||||
using TikTokApi.Security;
|
||||
using TikTokApi.Services;
|
||||
|
||||
namespace TikTokApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal API endpoint called by Gateway.
|
||||
/// Protected by X-Internal-Key header validation.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal")]
|
||||
public sealed class InternalController : ControllerBase
|
||||
{
|
||||
private readonly TikTokMarketingService _tikTokAds;
|
||||
private readonly ILogger<InternalController> _logger;
|
||||
|
||||
public InternalController(TikTokMarketingService tikTokAds, ILogger<InternalController> logger)
|
||||
{
|
||||
_tikTokAds = tikTokAds;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check - no auth required.
|
||||
/// </summary>
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
_logger.LogDebug("[InternalController] Health check");
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "TikTokApi",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main execution endpoint - Gateway calls this.
|
||||
/// Protected by InternalAuthFilter.
|
||||
/// </summary>
|
||||
[ServiceFilter(typeof(InternalAuthFilter))]
|
||||
[HttpPost("execute")]
|
||||
public async Task<IActionResult> Execute([FromBody] ProviderRequest request, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[InternalController] Execute called | Operation={Operation} RequestId={RequestId}",
|
||||
request?.Operation, request?.RequestId);
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return BadRequest(ProviderResponse.Fail(null, "VALIDATION", "Request body is required"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Operation))
|
||||
{
|
||||
return BadRequest(ProviderResponse.Fail(request.RequestId, "VALIDATION", "Operation is required"));
|
||||
}
|
||||
|
||||
var result = await _tikTokAds.ExecuteAsync(request, ct);
|
||||
|
||||
if (result.Ok)
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
var statusCode = result.Error?.Code switch
|
||||
{
|
||||
"VALIDATION" => 400,
|
||||
"NOT_FOUND" => 404,
|
||||
"UNAUTHORIZED" => 401,
|
||||
"FORBIDDEN" => 403,
|
||||
"RATE_LIMITED" => 429,
|
||||
_ => 400
|
||||
};
|
||||
|
||||
return StatusCode(statusCode, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
179
TikTokApi/GATEWAY_INTEGRATION.md
Normal file
179
TikTokApi/GATEWAY_INTEGRATION.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Gateway Integration: Meta & TikTok Provider Routing
|
||||
|
||||
## Overview
|
||||
|
||||
The Gateway's `ExecutionService` already routes `provider="google"` to the GoogleApi container.
|
||||
Adding Meta and TikTok follows the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gateway ExecutionService Changes
|
||||
|
||||
### GetProviderUrl() — add meta + tiktok routing
|
||||
|
||||
```csharp
|
||||
private string GetProviderUrl(string provider)
|
||||
{
|
||||
return provider.ToLower() switch
|
||||
{
|
||||
"google" => Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL")
|
||||
?? _config["Providers:Google:Url"]
|
||||
?? "http://localhost:5200",
|
||||
|
||||
"meta" => Environment.GetEnvironmentVariable("META_PROVIDER_URL")
|
||||
?? _config["Providers:Meta:Url"]
|
||||
?? "http://localhost:5300",
|
||||
|
||||
"tiktok" => Environment.GetEnvironmentVariable("TIKTOK_PROVIDER_URL")
|
||||
?? _config["Providers:TikTok:Url"]
|
||||
?? "http://localhost:5400",
|
||||
|
||||
_ => throw new ArgumentException($"Unknown provider: {provider}")
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### GetProviderKey() — add meta + tiktok internal keys
|
||||
|
||||
```csharp
|
||||
private string GetProviderKey(string provider)
|
||||
{
|
||||
return provider.ToLower() switch
|
||||
{
|
||||
"google" => Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY")
|
||||
?? _config["Providers:Google:InternalKey"] ?? "",
|
||||
|
||||
"meta" => Environment.GetEnvironmentVariable("META_INTERNAL_KEY")
|
||||
?? _config["Providers:Meta:InternalKey"] ?? "",
|
||||
|
||||
"tiktok" => Environment.GetEnvironmentVariable("TIKTOK_INTERNAL_KEY")
|
||||
?? _config["Providers:TikTok:InternalKey"] ?? "",
|
||||
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Gateway Environment Variables (Azure Container Apps)
|
||||
|
||||
Add to the Gateway container's env vars:
|
||||
|
||||
```bash
|
||||
# Meta
|
||||
META_PROVIDER_URL=https://usim-adp-metaapi.internal.<env>.azurecontainerapps.io
|
||||
META_INTERNAL_KEY=<shared-secret>
|
||||
|
||||
# TikTok
|
||||
TIKTOK_PROVIDER_URL=https://usim-adp-tiktokapi.internal.<env>.azurecontainerapps.io
|
||||
TIKTOK_INTERNAL_KEY=<shared-secret>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Gateway appsettings.json — MultiChannel StatusMappings
|
||||
|
||||
The Gateway already has a MultiChannel config section for status mapping.
|
||||
Add/verify meta and tiktok entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"MultiChannel": {
|
||||
"google": {
|
||||
"StatusMappings": {
|
||||
"ENABLED": "active",
|
||||
"PAUSED": "paused",
|
||||
"REMOVED": "cancelled"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"StatusMappings": {
|
||||
"ACTIVE": "active",
|
||||
"PAUSED": "paused",
|
||||
"DELETED": "cancelled",
|
||||
"ARCHIVED": "archived"
|
||||
}
|
||||
},
|
||||
"tiktok": {
|
||||
"StatusMappings": {
|
||||
"ENABLE": "active",
|
||||
"DISABLE": "paused",
|
||||
"DELETE": "cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Account Validation (Optional — implement when ready)
|
||||
|
||||
Currently `ValidateGoogleAccountAsync` checks Google-specific account setup.
|
||||
When ready, add equivalent methods:
|
||||
|
||||
```csharp
|
||||
// In ExecutionService or a dedicated AccountValidationService:
|
||||
private async Task ValidateMetaAccountAsync(string adAccountId) { ... }
|
||||
private async Task ValidateTikTokAccountAsync(string advertiserId) { ... }
|
||||
```
|
||||
|
||||
These would verify the external account ID exists and is accessible
|
||||
before forwarding operations to the provider containers.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Views & Stored Procedures
|
||||
|
||||
### Meta
|
||||
```sql
|
||||
-- vwMetaAccount: mirrors vwGoogleAccount for accNetwork='meta'
|
||||
CREATE VIEW vwMetaAccount AS
|
||||
SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ...
|
||||
FROM tbAdAccount WHERE accNetwork = 'meta';
|
||||
|
||||
-- spMetaAccount: account linking/validation
|
||||
-- spMetaCredential: token storage (System User token doesn't expire, but store for reference)
|
||||
```
|
||||
|
||||
### TikTok
|
||||
```sql
|
||||
-- vwTikTokAccount: mirrors vwGoogleAccount for accNetwork='tiktok'
|
||||
CREATE VIEW vwTikTokAccount AS
|
||||
SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ...
|
||||
FROM tbAdAccount WHERE accNetwork = 'tiktok';
|
||||
|
||||
-- spTikTokAccount: account linking/validation
|
||||
-- spTikTokCredential: access token storage (doesn't expire unless revoked)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Port Map (All Services)
|
||||
|
||||
| Service | Port | Container Name |
|
||||
|---------|------|----------------|
|
||||
| Gateway | 5000 | usim-adp-gateway |
|
||||
| Creative | 5100 | usim-adp-creative |
|
||||
| GoogleApi | 5200 | usim-adp-googleapi |
|
||||
| MetaApi | 5300 | usim-adp-metaapi |
|
||||
| TikTokApi | 5400 | usim-adp-tiktokapi |
|
||||
| Management | 5500 | usim-adp-management |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Checklist
|
||||
|
||||
For each new provider (meta, tiktok):
|
||||
|
||||
- [ ] Container builds and starts locally
|
||||
- [ ] `GET /` returns service info
|
||||
- [ ] `GET /internal/health` returns ok
|
||||
- [ ] `POST /internal/execute` with Ping operation works (no auth needed for Ping)
|
||||
- [ ] `POST /internal/execute` rejects without X-Internal-Key
|
||||
- [ ] `POST /internal/execute` with CreateCampaign returns emulated response
|
||||
- [ ] Gateway routes `provider="meta"` / `provider="tiktok"` correctly
|
||||
- [ ] Gateway passes X-Internal-Key header
|
||||
- [ ] End-to-end: client app → Gateway → provider container → emulated response
|
||||
- [ ] Swagger UI accessible at `/swagger`
|
||||
326
TikTokApi/Models/OperationPayloads.cs
Normal file
326
TikTokApi/Models/OperationPayloads.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TikTokApi.Models;
|
||||
|
||||
#region Campaign Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Create a TikTok campaign.
|
||||
/// TikTok hierarchy: Campaign → Ad Group → Ad (same as Google, unlike Meta's Ad Set).
|
||||
/// Campaign sets the objective and budget type.
|
||||
/// </summary>
|
||||
public sealed class CreateCampaignPayload
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public TikTokObjective Objective { get; set; } = TikTokObjective.Traffic;
|
||||
|
||||
/// <summary>
|
||||
/// Budget mode. TikTok supports BUDGET_MODE_DAY and BUDGET_MODE_TOTAL at campaign level.
|
||||
/// </summary>
|
||||
public TikTokBudgetMode BudgetMode { get; set; } = TikTokBudgetMode.Day;
|
||||
|
||||
/// <summary>
|
||||
/// Budget amount in account currency (float, e.g., 50.00).
|
||||
/// Minimum varies by country (typically $50/day for campaign-level budget).
|
||||
/// Null = no campaign budget (budget set at ad group level).
|
||||
/// </summary>
|
||||
public decimal? Budget { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Special industry categories: HOUSING, CREDIT, EMPLOYMENT.
|
||||
/// Empty = none (most campaigns).
|
||||
/// </summary>
|
||||
public List<string> SpecialIndustries { get; set; } = new();
|
||||
|
||||
public TikTokCampaignStatus Status { get; set; } = TikTokCampaignStatus.Disable;
|
||||
}
|
||||
|
||||
public sealed class GetCampaignPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateCampaignPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public TikTokCampaignStatus? Status { get; set; }
|
||||
public decimal? Budget { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ListCampaignsPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by status. Null = return all.
|
||||
/// TikTok supports filtering by multiple statuses.
|
||||
/// </summary>
|
||||
public TikTokCampaignStatus? StatusFilter { get; set; }
|
||||
|
||||
public int PageSize { get; set; } = 50;
|
||||
public int Page { get; set; } = 1;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ad Group Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Create a TikTok Ad Group (equivalent to Google Ad Group / Meta Ad Set).
|
||||
/// Ad Groups define targeting, budget, schedule, placement, and bid strategy.
|
||||
/// </summary>
|
||||
public sealed class CreateAdGroupPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Budget amount in account currency (float).</summary>
|
||||
public decimal Budget { get; set; }
|
||||
|
||||
public TikTokBudgetMode BudgetMode { get; set; } = TikTokBudgetMode.Day;
|
||||
|
||||
/// <summary>Schedule start time in UTC (ISO 8601 / "yyyy-MM-dd HH:mm:ss").</summary>
|
||||
public string? ScheduleStartTime { get; set; }
|
||||
|
||||
/// <summary>Schedule end time in UTC. Null = no end date.</summary>
|
||||
public string? ScheduleEndTime { get; set; }
|
||||
|
||||
public TikTokOptimizationGoal OptimizationGoal { get; set; } = TikTokOptimizationGoal.Click;
|
||||
|
||||
public TikTokBillingEvent BillingEvent { get; set; } = TikTokBillingEvent.CPC;
|
||||
|
||||
/// <summary>Bid amount in account currency. Required for non-auto-bid strategies.</summary>
|
||||
public decimal? BidPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Placement: list of placements. Default = automatic (PLACEMENT_TYPE_AUTOMATIC).
|
||||
/// Options: PLACEMENT_TIKTOK, PLACEMENT_PANGLE, PLACEMENT_GLOBALAPP_BUNDLE
|
||||
/// </summary>
|
||||
public List<string> Placements { get; set; } = new();
|
||||
|
||||
/// <summary>Target location codes (country/region codes).</summary>
|
||||
public List<string> LocationIds { get; set; } = new();
|
||||
|
||||
public TikTokCampaignStatus Status { get; set; } = TikTokCampaignStatus.Disable;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ad Account (Advertiser) Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Create a new advertiser account under USIM's Business Center.
|
||||
/// Endpoint: POST /bc/advertiser/create
|
||||
/// Requires BC admin access.
|
||||
/// </summary>
|
||||
public sealed class CreateAdvertiserPayload
|
||||
{
|
||||
/// <summary>Display name for the new advertiser account.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Currency code (e.g. "USD"). ISO 4217.</summary>
|
||||
public string Currency { get; set; } = "USD";
|
||||
|
||||
/// <summary>
|
||||
/// Timezone string. TikTok uses Olson/IANA format.
|
||||
/// e.g., "America/Los_Angeles", "UTC", "Europe/London"
|
||||
/// </summary>
|
||||
public string Timezone { get; set; } = "America/Los_Angeles";
|
||||
|
||||
/// <summary>Advertiser's company name.</summary>
|
||||
public string Company { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Industry ID. Use /tool/industry/ endpoint to get valid IDs.</summary>
|
||||
public string? IndustryId { get; set; }
|
||||
|
||||
/// <summary>Advertiser's contact email.</summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>Advertiser's contact phone number.</summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List advertiser accounts under the Business Center.
|
||||
/// Endpoint: GET /bc/advertiser/get
|
||||
/// </summary>
|
||||
public sealed class ListAdvertisersPayload
|
||||
{
|
||||
public int PageSize { get; set; } = 50;
|
||||
public int Page { get; set; } = 1;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Insights / Reporting Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve campaign/ad group/ad performance metrics.
|
||||
/// TikTok Reporting API: POST /report/integrated/get/
|
||||
/// </summary>
|
||||
public sealed class ReportPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Report type: BASIC, AUDIENCE, PLAYABLE_MATERIAL, CATALOG
|
||||
/// </summary>
|
||||
public string ReportType { get; set; } = "BASIC";
|
||||
|
||||
/// <summary>
|
||||
/// Data level: AUCTION_CAMPAIGN, AUCTION_ADGROUP, AUCTION_AD
|
||||
/// </summary>
|
||||
public string DataLevel { get; set; } = "AUCTION_CAMPAIGN";
|
||||
|
||||
/// <summary>
|
||||
/// Dimensions to group by (e.g., ["campaign_id", "stat_time_day"]).
|
||||
/// </summary>
|
||||
public List<string> Dimensions { get; set; } = new() { "campaign_id", "stat_time_day" };
|
||||
|
||||
/// <summary>
|
||||
/// Metrics to query (e.g., ["spend", "impressions", "clicks", "cpc", "ctr"]).
|
||||
/// </summary>
|
||||
public List<string> Metrics { get; set; } = new() { "spend", "impressions", "clicks", "cpc", "ctr", "cpm" };
|
||||
|
||||
/// <summary>Start date in format "YYYY-MM-DD".</summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>End date in format "YYYY-MM-DD".</summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>Use lifetime stats instead of date range.</summary>
|
||||
public bool Lifetime { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Filters (e.g., [{"field_name":"campaign_ids","filter_type":"IN","filter_value":"[\"123\"]"}]).
|
||||
/// </summary>
|
||||
public List<ReportFilter>? Filters { get; set; }
|
||||
|
||||
public int PageSize { get; set; } = 50;
|
||||
public int Page { get; set; } = 1;
|
||||
}
|
||||
|
||||
public sealed class ReportFilter
|
||||
{
|
||||
public string FieldName { get; set; } = string.Empty;
|
||||
public string FilterType { get; set; } = "IN";
|
||||
public string FilterValue { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fund Management Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Transfer funds to/from an advertiser account in the Business Center.
|
||||
/// Endpoint: POST /bc/transfer/
|
||||
/// This is a unique TikTok feature — direct fund management via API.
|
||||
/// </summary>
|
||||
public sealed class TransferFundsPayload
|
||||
{
|
||||
/// <summary>Target advertiser account ID.</summary>
|
||||
public string AdvertiserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Transfer type: "RECHARGE" (add funds) or "DEDUCT" (remove funds).</summary>
|
||||
public string TransferType { get; set; } = "RECHARGE";
|
||||
|
||||
/// <summary>Amount to transfer in account currency.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enums
|
||||
|
||||
/// <summary>
|
||||
/// TikTok campaign objectives.
|
||||
/// See: https://business-api.tiktok.com/portal/docs?id=1739381516454913
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TikTokObjective
|
||||
{
|
||||
/// <summary>REACH - Maximize ad reach</summary>
|
||||
Reach = 0,
|
||||
|
||||
/// <summary>TRAFFIC - Drive traffic to website/app</summary>
|
||||
Traffic = 1,
|
||||
|
||||
/// <summary>VIDEO_VIEWS - Maximize video views</summary>
|
||||
VideoViews = 2,
|
||||
|
||||
/// <summary>LEAD_GENERATION - Collect leads via instant forms</summary>
|
||||
LeadGeneration = 3,
|
||||
|
||||
/// <summary>COMMUNITY_INTERACTION - Grow followers and profile visits</summary>
|
||||
CommunityInteraction = 4,
|
||||
|
||||
/// <summary>APP_PROMOTION - Drive app installs/re-engagement</summary>
|
||||
AppPromotion = 5,
|
||||
|
||||
/// <summary>WEB_CONVERSIONS - Drive website conversions</summary>
|
||||
WebConversions = 6,
|
||||
|
||||
/// <summary>PRODUCT_SALES - Drive catalog/shop sales</summary>
|
||||
ProductSales = 7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TikTok campaign/ad group status values.
|
||||
/// TikTok uses ENABLE/DISABLE rather than ACTIVE/PAUSED.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TikTokCampaignStatus
|
||||
{
|
||||
/// <summary>ENABLE - Running/active</summary>
|
||||
Enable = 0,
|
||||
|
||||
/// <summary>DISABLE - Paused</summary>
|
||||
Disable = 1,
|
||||
|
||||
/// <summary>DELETE - Soft-deleted</summary>
|
||||
Delete = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TikTok budget modes.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TikTokBudgetMode
|
||||
{
|
||||
/// <summary>BUDGET_MODE_DAY - Daily budget</summary>
|
||||
Day = 0,
|
||||
|
||||
/// <summary>BUDGET_MODE_TOTAL - Lifetime/total budget</summary>
|
||||
Total = 1,
|
||||
|
||||
/// <summary>BUDGET_MODE_INFINITE - No budget limit (budget at ad group level)</summary>
|
||||
Infinite = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TikTok billing events for ad groups.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TikTokBillingEvent
|
||||
{
|
||||
CPC = 0,
|
||||
CPM = 1,
|
||||
CPV = 2,
|
||||
OCPM = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TikTok optimization goals for ad groups.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TikTokOptimizationGoal
|
||||
{
|
||||
Click = 0,
|
||||
Impression = 1,
|
||||
Reach = 2,
|
||||
VideoView = 3,
|
||||
Landing_Page_View = 4,
|
||||
Lead_Generation = 5,
|
||||
Convert = 6,
|
||||
Install = 7,
|
||||
Value = 8
|
||||
}
|
||||
|
||||
#endregion
|
||||
96
TikTokApi/Models/ProviderModels.cs
Normal file
96
TikTokApi/Models/ProviderModels.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TikTokApi.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request from Gateway to TikTokApi.
|
||||
/// Identical contract to GoogleApi/MetaApi ProviderRequest for Gateway compatibility.
|
||||
/// </summary>
|
||||
public sealed class ProviderRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignInsights")
|
||||
/// </summary>
|
||||
public string Operation { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant/account ID - maps to TikTok advertiser_id (ad account ID).
|
||||
/// Populated by Gateway from tbAdAccount.accExternalAccountId where accNetwork='tiktok'.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Login customer ID - maps to TikTok Business Center ID.
|
||||
/// In TikTok's agency model, the BC owns and manages client advertiser accounts.
|
||||
/// Populated by Gateway from tbAdAccount.accLoginAccountId.
|
||||
/// </summary>
|
||||
public string? LoginCustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for request tracing.
|
||||
/// </summary>
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Operation-specific payload.
|
||||
/// </summary>
|
||||
public JsonElement? Payload { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize payload to strongly-typed object.
|
||||
/// </summary>
|
||||
public T GetPayload<T>() where T : new()
|
||||
{
|
||||
if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined)
|
||||
return new T();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from TikTokApi to Gateway.
|
||||
/// Identical contract to GoogleApi/MetaApi ProviderResponse.
|
||||
/// </summary>
|
||||
public sealed class ProviderResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public object? Data { get; set; }
|
||||
public ProviderError? Error { get; set; }
|
||||
|
||||
public static ProviderResponse Success(string? requestId, object? data = null)
|
||||
=> new() { Ok = true, RequestId = requestId, Data = data };
|
||||
|
||||
public static ProviderResponse Fail(string? requestId, string code, string message, object? detail = null)
|
||||
=> new()
|
||||
{
|
||||
Ok = false,
|
||||
RequestId = requestId,
|
||||
Error = new ProviderError { Code = code, Message = message, Detail = detail }
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ProviderError
|
||||
{
|
||||
public string Code { get; set; } = "ERROR";
|
||||
public string Message { get; set; } = "Unknown error";
|
||||
public object? Detail { get; set; }
|
||||
}
|
||||
|
||||
internal static class JsonOptions
|
||||
{
|
||||
public static readonly JsonSerializerOptions Default = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
66
TikTokApi/Program.cs
Normal file
66
TikTokApi/Program.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using TikTokApi.Configuration;
|
||||
using TikTokApi.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Port binding (same pattern as GoogleApi/MetaApi)
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "5400";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// Configuration
|
||||
builder.Services.Configure<TikTokConfig>(options =>
|
||||
{
|
||||
// Bind from appsettings TikTok section
|
||||
builder.Configuration.GetSection("TikTok").Bind(options);
|
||||
|
||||
// Environment variable overrides (Azure Container Apps pattern)
|
||||
options.AppId = Environment.GetEnvironmentVariable("TikTok__AppId") ?? options.AppId;
|
||||
options.AppSecret = Environment.GetEnvironmentVariable("TikTok__AppSecret") ?? options.AppSecret;
|
||||
options.AccessToken = Environment.GetEnvironmentVariable("TikTok__AccessToken") ?? options.AccessToken;
|
||||
options.BusinessCenterId = Environment.GetEnvironmentVariable("TikTok__BusinessCenterId") ?? options.BusinessCenterId;
|
||||
options.ApiVersion = Environment.GetEnvironmentVariable("TikTok__ApiVersion") ?? options.ApiVersion;
|
||||
options.ApiBaseUrl = Environment.GetEnvironmentVariable("TikTok__ApiBaseUrl") ?? options.ApiBaseUrl;
|
||||
|
||||
var enableReal = Environment.GetEnvironmentVariable("TikTok__EnableRealApi");
|
||||
if (bool.TryParse(enableReal, out var realApi))
|
||||
options.EnableRealApi = realApi;
|
||||
});
|
||||
|
||||
// HTTP client for TikTok Marketing API
|
||||
builder.Services.AddHttpClient<TikTokApiClient>(client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Services
|
||||
builder.Services.AddScoped<TikTokMarketingService>();
|
||||
builder.Services.AddScoped<TikTokApi.Security.InternalAuthFilter>();
|
||||
|
||||
// Controllers + Swagger
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "TikTokApi", Version = "v1" });
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Swagger (all environments - same as GoogleApi/MetaApi)
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "TikTokApi",
|
||||
status = "running",
|
||||
version = "1.0.0",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
app.Logger.LogInformation("TikTokApi starting on port {Port}", port);
|
||||
|
||||
app.Run();
|
||||
52
TikTokApi/Properties/launchSettings.json
Normal file
52
TikTokApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5205"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7202;http://localhost:5205"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Container (.NET SDK)": {
|
||||
"commandName": "SdkContainer",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": true
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:14543",
|
||||
"sslPort": 44335
|
||||
}
|
||||
}
|
||||
}
|
||||
299
TikTokApi/README.md
Normal file
299
TikTokApi/README.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# TikTokApi - TikTok Marketing API Provider Service
|
||||
|
||||
Standalone microservice for TikTok advertising integration. Mirrors the GoogleApi/MetaApi architecture — the Gateway routes `provider="tiktok"` requests here via internal HTTP.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Gateway ──(POST /internal/execute)──► TikTokApi ──(REST)──► TikTok Marketing API
|
||||
X-Internal-Key auth │ business-api.tiktok.com
|
||||
├── Emulated mode (default)
|
||||
└── Real API mode (TikTok__EnableRealApi=true)
|
||||
```
|
||||
|
||||
## Platform Comparison
|
||||
|
||||
| Aspect | GoogleApi | MetaApi | TikTokApi |
|
||||
|--------|-----------|---------|-----------|
|
||||
| Parent entity | MCC (Manager) | Business Manager | **Business Center (BC)** |
|
||||
| Child accounts | Customer ID | Ad Account (`act_XXX`) | **Advertiser ID** (numeric) |
|
||||
| Auth | OAuth refresh tokens | System User token | **OAuth → long-lived token** |
|
||||
| Token expiry | Requires refresh | Never (if user active) | **Never (unless revoked)** |
|
||||
| SDK | Google.Ads NuGet | HttpClient (Graph API) | **HttpClient (REST)** |
|
||||
| Auth header | OAuth Bearer | query param | **`Access-Token` header** |
|
||||
| Base URL | via SDK | graph.facebook.com | **business-api.tiktok.com** |
|
||||
| API versioning | SDK version | URL path (`/v21.0/`) | **URL path (`/v1.3/`)** |
|
||||
| Hierarchy | Campaign→Ad Group→Ad | Campaign→Ad Set→Ad | **Campaign→Ad Group→Ad** |
|
||||
| Status values | ENABLED/PAUSED | ACTIVE/PAUSED | **ENABLE/DISABLE** |
|
||||
| Response format | gRPC via SDK | JSON `{data}` | **JSON `{code,message,data}`** |
|
||||
| Fund management | N/A | N/A | **BC Transfer API** |
|
||||
|
||||
## Operations
|
||||
|
||||
| Operation | Description | Endpoint | Real API |
|
||||
|-----------|-------------|----------|----------|
|
||||
| Ping / TestPing | Health check | N/A | N/A |
|
||||
| CreateCampaign | Create campaign | POST /campaign/create/ | ✅ |
|
||||
| GetCampaign | Retrieve campaign | GET /campaign/get/ | ✅ |
|
||||
| UpdateCampaign | Update name/budget | POST /campaign/update/ | ✅ |
|
||||
| UpdateCampaignStatus | Enable/disable/delete | POST /campaign/status/update/ | ✅ |
|
||||
| ListCampaigns | List with filters | GET /campaign/get/ | ✅ |
|
||||
| GetReport | Performance metrics | POST /report/integrated/get/ | ✅ |
|
||||
| CreateAdvertiser | Create ad account under BC | POST /bc/advertiser/create | ✅ |
|
||||
| ListAdvertisers | List BC ad accounts | GET /bc/advertiser/get | ✅ |
|
||||
| TransferFunds | Recharge/deduct ad account | POST /bc/transfer/ | ✅ |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TIKTOK_INTERNAL_KEY` | Yes | Gateway→TikTokApi shared auth key |
|
||||
| `TikTok__EnableRealApi` | No | `false` (default) = emulated responses |
|
||||
| `TikTok__AppId` | For real API | App ID from TikTok Developer Portal |
|
||||
| `TikTok__AppSecret` | For real API | App Secret from Developer Portal |
|
||||
| `TikTok__AccessToken` | For real API | Long-lived token from OAuth flow |
|
||||
| `TikTok__BusinessCenterId` | For real API | USIM's Business Center numeric ID |
|
||||
| `TikTok__ApiVersion` | No | API version (default: `v1.3`) |
|
||||
| `TikTok__ApiBaseUrl` | No | `https://business-api.tiktok.com` (prod) or `https://sandbox-ads.tiktok.com` (sandbox) |
|
||||
|
||||
---
|
||||
|
||||
## TikTok Business Center & API Setup
|
||||
|
||||
### Phase 1: Business Center Setup
|
||||
|
||||
1. **Create TikTok For Business Account**
|
||||
- Go to https://www.tiktok.com/business/
|
||||
- Sign up with a business email (use USIM company email)
|
||||
- Complete business verification
|
||||
|
||||
2. **Create Business Center**
|
||||
- Go to https://business.tiktok.com/
|
||||
- Click "Create Business Center"
|
||||
- Choose **Agency** type (not Advertiser — this is critical for managing client accounts)
|
||||
- Enter USIM business details, complete verification
|
||||
- Note the **Business Center ID** (numeric, visible in the URL)
|
||||
|
||||
3. **Verify Business**
|
||||
- Business Center → Settings → Verification
|
||||
- Upload required business documentation
|
||||
- Verification typically takes 1-3 business days
|
||||
- **Required before creating ad accounts programmatically**
|
||||
|
||||
### Phase 2: Developer App Registration
|
||||
|
||||
4. **Register as Developer**
|
||||
- Go to https://business-api.tiktok.com/portal
|
||||
- Click "Become a Developer" (top right)
|
||||
- Fill in developer application:
|
||||
- Company name: USIM
|
||||
- Website: your company URL
|
||||
- Use case description: "Agency platform for managing client TikTok advertising campaigns programmatically via API. Creating and managing advertiser accounts, campaigns, ad groups, ads, and pulling performance reports."
|
||||
- Submit and wait for approval (usually 1-2 business days)
|
||||
|
||||
5. **Create App**
|
||||
- My Apps → Create New
|
||||
- **App Name**: "USIM AdPlatform" (or similar)
|
||||
- **Description**: "Multi-channel advertising management platform for SMB clients"
|
||||
- **Advertiser Redirect URL**: `https://adptestapi.usimdev.com/auth/tiktok/callback` (for OAuth flow)
|
||||
- **Scope of Permissions** — select all that apply:
|
||||
- ✅ Ad Account Management (`ad_account_management`)
|
||||
- ✅ Campaign Management (`campaign_management`)
|
||||
- ✅ Ad Management (`ad_management`)
|
||||
- ✅ Creative Management (`creative_management`)
|
||||
- ✅ Reporting (`reporting`)
|
||||
- ✅ Audience Management (`audience_management`)
|
||||
- ✅ Business Center Management (`bc_management`)
|
||||
- Note your **App ID** and **App Secret**
|
||||
|
||||
6. **App Review**
|
||||
- After creating the app, it goes through TikTok review
|
||||
- Basic access is granted immediately (sandbox)
|
||||
- For production access to campaign management, you may need additional review
|
||||
|
||||
### Phase 3: Access Token Generation
|
||||
|
||||
7. **Authorize the App**
|
||||
- Navigate to the Authorization URL (found in app details):
|
||||
```
|
||||
https://business-api.tiktok.com/portal/auth?app_id={APP_ID}&state=usim&redirect_uri={REDIRECT_URI}
|
||||
```
|
||||
- Log in with the TikTok Business account that owns the Business Center
|
||||
- Grant all requested permissions
|
||||
- **DO NOT close the redirect page** — copy the `auth_code` from the URL
|
||||
|
||||
8. **Exchange Auth Code for Access Token**
|
||||
```bash
|
||||
curl -X POST "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"secret": "YOUR_APP_SECRET",
|
||||
"auth_code": "AUTH_CODE_FROM_REDIRECT"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"advertiser_ids": ["7123456789012345678", "7123456789012345679"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The access token does NOT expire unless the advertiser revokes authorization.
|
||||
Store it securely in Azure Key Vault.
|
||||
|
||||
9. **Verify Token Works**
|
||||
```bash
|
||||
curl "https://business-api.tiktok.com/open_api/v1.3/oauth2/advertiser/get/?app_id={APP_ID}&secret={APP_SECRET}" \
|
||||
-H "Access-Token: YOUR_ACCESS_TOKEN"
|
||||
```
|
||||
This should return the list of advertiser IDs you have access to.
|
||||
|
||||
### Phase 4: Sandbox Testing (Recommended)
|
||||
|
||||
10. **Use Sandbox Environment First**
|
||||
- Sandbox URL: `https://sandbox-ads.tiktok.com`
|
||||
- Set `TikTok__ApiBaseUrl=https://sandbox-ads.tiktok.com` in env vars
|
||||
- Sandbox uses the same endpoints but with test data
|
||||
- Create test campaigns, verify response formats
|
||||
- Switch to production URL when ready
|
||||
|
||||
### Phase 5: Deploy TikTokApi Container
|
||||
|
||||
11. **Configure Azure Container Apps**
|
||||
```bash
|
||||
# Create container
|
||||
az containerapp create \
|
||||
--name usim-adp-tiktokapi \
|
||||
--resource-group USIM-AdPlatform \
|
||||
--environment usim-adp-env \
|
||||
--image <registry>/usim-adp-tiktokapi:latest \
|
||||
--target-port 5400 \
|
||||
--ingress internal \
|
||||
--env-vars \
|
||||
TIKTOK_INTERNAL_KEY=secretref:tiktok-internal-key \
|
||||
TikTok__EnableRealApi=false
|
||||
|
||||
# Add secrets (when ready for real API)
|
||||
az containerapp secret set \
|
||||
--name usim-adp-tiktokapi \
|
||||
--resource-group USIM-AdPlatform \
|
||||
--secrets \
|
||||
tiktok-access-token=<token> \
|
||||
tiktok-app-secret=<secret>
|
||||
```
|
||||
|
||||
12. **Configure Gateway**
|
||||
- Add environment variables to Gateway container:
|
||||
```
|
||||
TIKTOK_PROVIDER_URL=https://usim-adp-tiktokapi.internal.<env>.azurecontainerapps.io
|
||||
TIKTOK_INTERNAL_KEY=<matching-key>
|
||||
```
|
||||
- Gateway's `ExecutionService.GetProviderUrl()` routes `provider="tiktok"` to `TIKTOK_PROVIDER_URL`
|
||||
|
||||
### Phase 6: Database Setup
|
||||
|
||||
13. **Add TikTok channel references** (if not already present)
|
||||
- Ensure `tbChannelConfig` has `tiktok` channel entry
|
||||
- Create `vwTikTokAccount` view (mirrors vwGoogleAccount pattern)
|
||||
- Create `vwTikTokCampaign` view
|
||||
- Create `spTikTokAccount` stored procedure for account linking
|
||||
|
||||
14. **Gateway MultiChannel config**
|
||||
- Verify `appsettings.json` MultiChannel section includes tiktok with StatusMappings:
|
||||
```json
|
||||
{
|
||||
"tiktok": {
|
||||
"StatusMappings": {
|
||||
"ENABLE": "active",
|
||||
"DISABLE": "paused",
|
||||
"DELETE": "cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TikTok API Quick Reference
|
||||
|
||||
### Response Envelope
|
||||
All TikTok Marketing API responses follow this format:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": { ... },
|
||||
"request_id": "202502171234567890..."
|
||||
}
|
||||
```
|
||||
- `code: 0` = success
|
||||
- Non-zero = error (e.g., 40001 = auth error, 40002 = permission denied)
|
||||
|
||||
### Key Endpoint Patterns
|
||||
```
|
||||
GET /open_api/v1.3/campaign/get/?advertiser_id=XXX
|
||||
POST /open_api/v1.3/campaign/create/ (JSON body with advertiser_id)
|
||||
POST /open_api/v1.3/campaign/update/ (JSON body)
|
||||
POST /open_api/v1.3/campaign/status/update/ (separate from update)
|
||||
POST /open_api/v1.3/report/integrated/get/ (reporting)
|
||||
POST /open_api/v1.3/bc/advertiser/create (BC operations)
|
||||
POST /open_api/v1.3/bc/transfer/ (fund management)
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 40001 | Authentication error (invalid/expired token) |
|
||||
| 40002 | No permission for this operation |
|
||||
| 40100 | Invalid parameter |
|
||||
| 40700 | Rate limit exceeded |
|
||||
| 50000 | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
TikTokApi/
|
||||
├── Configuration/
|
||||
│ └── TikTokConfig.cs # Config + TikTokApiContext
|
||||
├── Controllers/
|
||||
│ └── InternalController.cs # /internal/execute + /internal/health
|
||||
├── Models/
|
||||
│ ├── OperationPayloads.cs # TikTok-specific payloads + enums
|
||||
│ └── ProviderModels.cs # ProviderRequest/Response (Gateway contract)
|
||||
├── Security/
|
||||
│ └── InternalAuthFilter.cs # X-Internal-Key validation
|
||||
├── Services/
|
||||
│ ├── TikTokApiClient.cs # HTTP wrapper for business-api.tiktok.com
|
||||
│ └── TikTokMarketingService.cs # Operation dispatcher (emulated/real)
|
||||
├── Program.cs
|
||||
├── appsettings.json
|
||||
└── TikTokApi.csproj
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
dotnet run --project TikTokApi
|
||||
# → http://localhost:5400
|
||||
# → Swagger: http://localhost:5400/swagger
|
||||
```
|
||||
|
||||
## Port Assignments
|
||||
|
||||
| Service | Port |
|
||||
|---------|------|
|
||||
| Gateway | 5000 |
|
||||
| GoogleApi | 5200 |
|
||||
| MetaApi | 5300 |
|
||||
| **TikTokApi** | **5400** |
|
||||
| Creative | 5100 |
|
||||
58
TikTokApi/Security/InternalAuthFilter.cs
Normal file
58
TikTokApi/Security/InternalAuthFilter.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace TikTokApi.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the X-Internal-Key header for internal service-to-service calls.
|
||||
/// Gateway must provide the correct key to call TikTokApi endpoints.
|
||||
/// </summary>
|
||||
public sealed class InternalAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<InternalAuthFilter> _logger;
|
||||
|
||||
public InternalAuthFilter(IConfiguration config, ILogger<InternalAuthFilter> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var headerName = _config["InternalAuth:HeaderName"] ?? "X-Internal-Key";
|
||||
|
||||
// Try multiple sources for the key
|
||||
var expectedKey = _config["InternalAuth:Key"]
|
||||
?? _config["TIKTOK_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("TIKTOK_INTERNAL_KEY");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expectedKey))
|
||||
{
|
||||
_logger.LogError("[InternalAuth] No internal key configured - check TIKTOK_INTERNAL_KEY env var");
|
||||
context.Result = new ObjectResult(new { error = "Internal auth key not configured" })
|
||||
{
|
||||
StatusCode = 500
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(headerName, out var providedKey) ||
|
||||
string.IsNullOrWhiteSpace(providedKey))
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] Missing {HeaderName} header", headerName);
|
||||
context.Result = new UnauthorizedObjectResult(new { error = $"Missing {headerName} header" });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!string.Equals(providedKey.ToString(), expectedKey, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] Invalid key provided");
|
||||
context.Result = new UnauthorizedObjectResult(new { error = "Invalid internal auth key" });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_logger.LogDebug("[InternalAuth] Request authorized");
|
||||
return next();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
23
TikTokApi/TikTokApi.csproj
Normal file
23
TikTokApi/TikTokApi.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- Container Settings -->
|
||||
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
|
||||
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
|
||||
<ContainerRepository>tiktokapi</ContainerRepository>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<!-- No TikTok SDK needed - all calls via HttpClient to business-api.tiktok.com -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ContainerPort Include="8080" Type="tcp" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
TikTokApi/TikTokApi.http
Normal file
6
TikTokApi/TikTokApi.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@TikTokApi_HostAddress = http://localhost:5205
|
||||
|
||||
GET {{TikTokApi_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
8
TikTokApi/appsettings.Development.json
Normal file
8
TikTokApi/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
TikTokApi/appsettings.json
Normal file
19
TikTokApi/appsettings.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"TikTok": {
|
||||
"AppId": "",
|
||||
"AppSecret": "",
|
||||
"AccessToken": "",
|
||||
"BusinessCenterId": "",
|
||||
"ApiVersion": "v1.3",
|
||||
"EnableRealApi": false,
|
||||
"ApiBaseUrl": "https://business-api.tiktok.com",
|
||||
"TimeoutSeconds": 30
|
||||
},
|
||||
"InternalKey": "dev-tiktok-internal-key-change-in-production"
|
||||
}
|
||||
Reference in New Issue
Block a user