1.06 #10
@ -20,6 +20,29 @@ internal static class PlatformShopCrypto
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
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>
|
/// <summary>
|
||||||
|
|||||||
@ -582,44 +582,212 @@ internal sealed class PlatformShopFulfillmentClient(
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
EnsureTiktok(store, options);
|
EnsureTiktok(store, options);
|
||||||
var tiktok = options.Tiktok;
|
var shopCipher = await GetTiktokShopCipherAsync(store, options, ct);
|
||||||
var token = store.AccessToken!;
|
if (string.IsNullOrWhiteSpace(shopCipher))
|
||||||
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?>
|
|
||||||
{
|
{
|
||||||
["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT"
|
return new PlatformShopPullOrdersResult
|
||||||
};
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
foreach (var item in arr.EnumerateArray())
|
|
||||||
{
|
{
|
||||||
var id = GetString(item, "id");
|
Success = false,
|
||||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
Message = "未获取到 TikTok shop_cipher,请重新授权店铺"
|
||||||
var status = GetString(item, "status") ?? "AWAITING_SHIPMENT";
|
};
|
||||||
orders.Add(new PlatformShopPulledOrder
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
PlatformOrderNo = id,
|
var message = root.ValueKind == JsonValueKind.Object
|
||||||
PlatformStatus = status,
|
? GetString(root, "message") ?? GetString(root, "msg")
|
||||||
PlatformStatusLabel = status,
|
: null;
|
||||||
FulfillStatus = MapTiktokFulfill(status)
|
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
|
return new PlatformShopPullOrdersResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = $"TikTok 拉单完成,处理 {orders.Count} 单",
|
Message = messageText,
|
||||||
Orders = orders
|
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(
|
private async Task<PlatformShopOrderStatusResult> SyncTiktokAsync(
|
||||||
SalesStore store,
|
SalesStore store,
|
||||||
PlatformShopOptions options,
|
PlatformShopOptions options,
|
||||||
@ -833,6 +1001,31 @@ internal sealed class PlatformShopFulfillmentClient(
|
|||||||
return JsonDocument.Parse(raw);
|
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(
|
private async Task<JsonDocument> PostFormAsync(
|
||||||
string url,
|
string url,
|
||||||
IDictionary<string, string> form,
|
IDictionary<string, string> form,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user