fix: TikTok 拉单补齐 shop_cipher 与官方签名,并按状态翻页
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1540a74d52
commit
5afd204d36
@ -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>
|
||||
|
||||
@ -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<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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user