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,