fix: TikTok 拉单补齐 shop_cipher 与官方签名,并按状态翻页
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-16 15:17:46 +08:00
parent 1540a74d52
commit 5afd204d36
2 changed files with 242 additions and 26 deletions

View File

@ -20,6 +20,29 @@ internal static class PlatformShopCrypto
}
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
/// <summary>
/// TikTok Shop 202309 签名,对齐 WMS <c>TiktokApi.makeTiktokSign</c>:
/// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。
/// </summary>
public static string TiktokSign(string path, IReadOnlyDictionary<string, string> query, string? body, string appSecret)
{
var input = new StringBuilder(path);
foreach (var key in query.Keys
.Where(k => k is not ("sign" or "access_token"))
.OrderBy(k => k, StringComparer.Ordinal))
{
input.Append(key).Append(query[key]);
}
if (!string.IsNullOrEmpty(body))
{
input.Append(body);
}
var payload = appSecret + input + appSecret;
return HmacSha256Hex(payload, appSecret);
}
}
/// <summary>

View File

@ -582,44 +582,212 @@ internal sealed class PlatformShopFulfillmentClient(
CancellationToken ct)
{
EnsureTiktok(store, options);
var tiktok = options.Tiktok;
var token = store.AccessToken!;
var apiPath = $"/order/202309/orders/search?app_key={tiktok.AppKey}&timestamp={PlatformShopCrypto.UnixSeconds()}&page_size=50";
var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
var body = new Dictionary<string, object?>
var shopCipher = await GetTiktokShopCipherAsync(store, options, ct);
if (string.IsNullOrWhiteSpace(shopCipher))
{
["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT"
return new PlatformShopPullOrdersResult
{
Success = false,
Message = "未获取到 TikTok shop_cipher,请重新授权店铺"
};
using var doc = await PostJsonAsync(url, body, ct, token);
var orders = new List<PlatformShopPulledOrder>();
if (doc.RootElement.TryGetProperty("data", out var data)
&& data.TryGetProperty("orders", out var arr)
&& arr.ValueKind == JsonValueKind.Array)
}
var statuses = NormalizeTiktokPullStatuses(request.OrderStatuses);
var orders = new Dictionary<string, PlatformShopPulledOrder>(StringComparer.Ordinal);
var errors = new List<string>();
foreach (var statusFilter in statuses)
{
var pageToken = "";
for (var page = 0; page < 40; page++)
{
using var doc = await PostTiktokOrderSearchAsync(
store, options, request, statusFilter, shopCipher, pageToken, ct);
var root = doc.RootElement;
var code = root.ValueKind == JsonValueKind.Object ? GetString(root, "code") : null;
if (root.ValueKind != JsonValueKind.Object
|| code is not ("0" or null)
|| !root.TryGetProperty("data", out var data)
|| data.ValueKind != JsonValueKind.Object)
{
var message = root.ValueKind == JsonValueKind.Object
? GetString(root, "message") ?? GetString(root, "msg")
: null;
errors.Add(string.IsNullOrWhiteSpace(message)
? $"{statusFilter} 返回为空"
: $"{statusFilter} {code} {message}".Trim());
break;
}
if (data.TryGetProperty("orders", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
var id = GetString(item, "id");
if (string.IsNullOrWhiteSpace(id)) continue;
var status = GetString(item, "status") ?? "AWAITING_SHIPMENT";
orders.Add(new PlatformShopPulledOrder
if (string.IsNullOrWhiteSpace(id) || orders.ContainsKey(id)) continue;
var status = GetString(item, "status") ?? statusFilter;
orders[id] = new PlatformShopPulledOrder
{
PlatformOrderNo = id,
PlatformStatus = status,
PlatformStatusLabel = status,
FulfillStatus = MapTiktokFulfill(status)
});
};
}
}
pageToken = GetString(data, "next_page_token") ?? "";
if (string.IsNullOrEmpty(pageToken))
{
break;
}
}
}
if (orders.Count == 0 && errors.Count > 0)
{
return new PlatformShopPullOrdersResult
{
Success = false,
Message = string.Join(";", errors.Distinct())
};
}
var messageText = $"TikTok 拉单完成,处理 {orders.Count} 单";
if (errors.Count > 0)
{
messageText += ";" + string.Join(";", errors.Distinct());
}
return new PlatformShopPullOrdersResult
{
Success = true,
Message = $"TikTok 拉单完成,处理 {orders.Count} 单",
Orders = orders
Message = messageText,
Orders = orders.Values.ToList()
};
}
/// <summary>
/// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。
/// </summary>
private async Task<string?> GetTiktokShopCipherAsync(SalesStore store, PlatformShopOptions options, CancellationToken ct)
{
var tiktok = options.Tiktok;
const string path = "/authorization/202309/shops";
var query = new Dictionary<string, string>
{
["app_key"] = tiktok.AppKey,
["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
};
var sign = PlatformShopCrypto.TiktokSign(path, query, null, tiktok.AppSecret);
var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
using var doc = await GetJsonAsync(url, ct, store.AccessToken);
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("data", out var data)
|| data.ValueKind != JsonValueKind.Object
|| !data.TryGetProperty("shops", out var shops)
|| shops.ValueKind != JsonValueKind.Array)
{
var message = doc.RootElement.ValueKind == JsonValueKind.Object
? GetString(doc.RootElement, "message")
: null;
logger.LogWarning("TikTok shops 未返回 cipher:{Message}", message);
return null;
}
string? fallback = null;
foreach (var shop in shops.EnumerateArray())
{
var cipher = GetString(shop, "cipher");
if (string.IsNullOrWhiteSpace(cipher)) continue;
fallback ??= cipher;
var id = GetString(shop, "id") ?? GetString(shop, "shop_id");
if (!string.IsNullOrWhiteSpace(store.PlatformStoreId)
&& string.Equals(id, store.PlatformStoreId, StringComparison.Ordinal))
{
return cipher;
}
}
return fallback;
}
/// <summary>
/// 按单个 TikTok order_status 搜索一页订单。签名和字段对齐 WMS TiktokApi.getOrdersSearch。
/// </summary>
private async Task<JsonDocument> PostTiktokOrderSearchAsync(
SalesStore store,
PlatformShopOptions options,
PlatformShopPullOrdersRequest request,
string orderStatus,
string shopCipher,
string pageToken,
CancellationToken ct)
{
var tiktok = options.Tiktok;
const string path = "/order/202309/orders/search";
var query = new Dictionary<string, string>
{
["app_key"] = tiktok.AppKey,
["shop_cipher"] = shopCipher,
["page_size"] = "50",
["page_token"] = pageToken,
["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
};
var body = new Dictionary<string, object>
{
["order_status"] = orderStatus
};
if (request.TimeStart.HasValue)
{
body["create_time_ge"] = ToUnix(request.TimeStart.Value);
}
if (request.TimeEnd.HasValue)
{
body["create_time_lt"] = ToUnix(request.TimeEnd.Value.AddSeconds(1));
}
var bodyJson = JsonSerializer.Serialize(body);
var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, tiktok.AppSecret);
var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
return await PostRawJsonAsync(url, bodyJson, ct, store.AccessToken);
}
private static string BuildTiktokQuery(IReadOnlyDictionary<string, string> query, string sign)
{
var parts = query
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")
.ToList();
parts.Add($"sign={Uri.EscapeDataString(sign)}");
return string.Join("&", parts);
}
private static readonly string[] TiktokPullStatuses =
[
"UNPAID",
"ON_HOLD",
"PARTIALLY_SHIPPING",
"AWAITING_SHIPMENT",
"AWAITING_COLLECTION",
"IN_TRANSIT",
"DELIVERED",
"COMPLETED",
"CANCELLED"
];
private static List<string> NormalizeTiktokPullStatuses(IEnumerable<string>? selected)
{
var allowed = new HashSet<string>(TiktokPullStatuses, StringComparer.OrdinalIgnoreCase);
var normalized = (selected ?? [])
.Select(x => (x ?? string.Empty).Trim().ToUpperInvariant())
.Where(allowed.Contains)
.Distinct(StringComparer.Ordinal)
.ToList();
return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses];
}
private async Task<PlatformShopOrderStatusResult> SyncTiktokAsync(
SalesStore store,
PlatformShopOptions options,
@ -833,6 +1001,31 @@ internal sealed class PlatformShopFulfillmentClient(
return JsonDocument.Parse(raw);
}
/// <summary>
/// 发送已经参与签名的原始 JSON,避免二次序列化改掉请求体。
/// </summary>
private async Task<JsonDocument> PostRawJsonAsync(
string url,
string bodyJson,
CancellationToken ct,
string? accessToken = null)
{
var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(bodyJson, Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken);
}
using var response = await client.SendAsync(request, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("Platform POST {Url} => {Body}", url, raw);
return JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw);
}
private async Task<JsonDocument> PostFormAsync(
string url,
IDictionary<string, string> form,