From 5afd204d368e122ba41a8eed895ea91bd9a67663 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 15:17:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20TikTok=20=E6=8B=89=E5=8D=95=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20shop=5Fcipher=20=E4=B8=8E=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=EF=BC=8C=E5=B9=B6=E6=8C=89=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PlatformShop/PlatformShopAuthState.cs | 23 ++ .../PlatformShopFulfillmentClient.cs | 245 ++++++++++++++++-- 2 files changed, 242 insertions(+), 26 deletions(-) diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs index d12b923..9fc17ad 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs @@ -20,6 +20,29 @@ internal static class PlatformShopCrypto } public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + /// + /// TikTok Shop 202309 签名,对齐 WMS TiktokApi.makeTiktokSign: + /// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。 + /// + public static string TiktokSign(string path, IReadOnlyDictionary 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); + } } /// diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index b4d6dea..488d6a3 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -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}×tamp={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 + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) { - ["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT" - }; - using var doc = await PostJsonAsync(url, body, ct, token); - var orders = new List(); - if (doc.RootElement.TryGetProperty("data", out var data) - && data.TryGetProperty("orders", out var arr) - && arr.ValueKind == JsonValueKind.Array) - { - foreach (var item in arr.EnumerateArray()) + return new PlatformShopPullOrdersResult { - var id = GetString(item, "id"); - if (string.IsNullOrWhiteSpace(id)) continue; - var status = GetString(item, "status") ?? "AWAITING_SHIPMENT"; - orders.Add(new PlatformShopPulledOrder + Success = false, + Message = "未获取到 TikTok shop_cipher,请重新授权店铺" + }; + } + + var statuses = NormalizeTiktokPullStatuses(request.OrderStatuses); + var orders = new Dictionary(StringComparer.Ordinal); + var errors = new List(); + + 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) { - PlatformOrderNo = id, - PlatformStatus = status, - PlatformStatusLabel = status, - FulfillStatus = MapTiktokFulfill(status) - }); + 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) || 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() }; } + /// + /// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。 + /// + private async Task GetTiktokShopCipherAsync(SalesStore store, PlatformShopOptions options, CancellationToken ct) + { + var tiktok = options.Tiktok; + const string path = "/authorization/202309/shops"; + var query = new Dictionary + { + ["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; + } + + /// + /// 按单个 TikTok order_status 搜索一页订单。签名和字段对齐 WMS TiktokApi.getOrdersSearch。 + /// + private async Task 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 + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["page_size"] = "50", + ["page_token"] = pageToken, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }; + var body = new Dictionary + { + ["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 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 NormalizeTiktokPullStatuses(IEnumerable? selected) + { + var allowed = new HashSet(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 SyncTiktokAsync( SalesStore store, PlatformShopOptions options, @@ -833,6 +1001,31 @@ internal sealed class PlatformShopFulfillmentClient( return JsonDocument.Parse(raw); } + /// + /// 发送已经参与签名的原始 JSON,避免二次序列化改掉请求体。 + /// + private async Task 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 PostFormAsync( string url, IDictionary form,