diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index 488d6a3..ce0e09b 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -1,7 +1,9 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using Fake.ObjectStorage; using Microsoft.Extensions.Logging; +using Oms.Application.Storage; using Oms.Domain.OrdersAggregate.Entities; namespace Oms.Application.Integrations.PlatformShop; @@ -141,6 +143,7 @@ public sealed class PlatformShopLabelResult internal sealed class PlatformShopFulfillmentClient( IHttpClientFactory httpClientFactory, IPlatformShopConfigProvider configProvider, + IObjectStorage objectStorage, ILogger logger) : IPlatformShopFulfillmentClient { private const string LabelPendingText = "系统获取中"; @@ -853,21 +856,161 @@ internal sealed class PlatformShopFulfillmentClient( }; } - private Task LabelTiktokAsync( + /// + /// 对齐 WMS ShopordTiktokImpl.retryOrderSheet:订单详情取 package_id,再取 SHIPPING_LABEL 并落库。 + /// + private async Task LabelTiktokAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { - var labelNo = $"TT-{platformOrderNo}"; - var labelUrl = $"https://seller.tiktokglobalshop.com/order/label/{platformOrderNo}"; - return Task.FromResult(new PlatformShopLabelResult + EnsureTiktok(store, options); + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) + { + return new PlatformShopLabelResult { Success = false, Message = "未获取到 TikTok shop_cipher,请重新授权店铺" }; + } + + var tiktok = options.Tiktok; + using var detail = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = platformOrderNo, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(detail.RootElement, out var data, out var detailError)) + { + return new PlatformShopLabelResult { Success = false, Message = "订单详情信息异常:" + detailError }; + } + + if (!data.TryGetProperty("orders", out var orders) + || orders.ValueKind != JsonValueKind.Array + || orders.GetArrayLength() == 0) + { + return new PlatformShopLabelResult { Success = false, Message = "TikTok 未找到订单" }; + } + + var order = orders[0]; + if (!order.TryGetProperty("line_items", out var lines) + || lines.ValueKind != JsonValueKind.Array + || lines.GetArrayLength() == 0) + { + return new PlatformShopLabelResult { Success = false, Message = "订单没有包裹,无法获取面单" }; + } + + var line = lines[0]; + var packageId = GetString(line, "package_id"); + var trackingNumber = GetString(line, "tracking_number"); + if (string.IsNullOrWhiteSpace(packageId)) + { + return new PlatformShopLabelResult { Success = false, Message = "订单没有 package_id,请先发货后再获取面单" }; + } + + using var document = await GetTiktokSignedAsync( + options, + store.AccessToken!, + $"/fulfillment/202309/packages/{packageId}/shipping_documents", + new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["document_type"] = "SHIPPING_LABEL", + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, + ct); + if (!TryGetTiktokData(document.RootElement, out var docData, out var docError)) + { + return new PlatformShopLabelResult { Success = false, Message = "获取面单失败:" + docError }; + } + + var docUrl = GetString(docData, "doc_url"); + if (string.IsNullOrWhiteSpace(docUrl) || !docUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return new PlatformShopLabelResult + { + Success = false, + Message = string.IsNullOrWhiteSpace(docUrl) ? "获取面单失败" : "获取面单失败:" + docUrl + }; + } + + var storedUrl = await SaveTiktokLabelAsync(docUrl, trackingNumber, platformOrderNo, ct); + return new PlatformShopLabelResult { Success = true, - Message = "TikTok 面单信息已更新", - ShippingLabelNo = labelNo, - ShippingLabelUrl = labelUrl - }); + Message = "面单获取成功", + ShippingLabelNo = string.IsNullOrWhiteSpace(trackingNumber) ? null : trackingNumber, + ShippingLabelUrl = storedUrl + }; + } + + /// + /// 下载 TikTok 面单 PDF 并写入对象存储。下载失败时退回平台临时地址。 + /// + private async Task SaveTiktokLabelAsync( + string docUrl, + string? trackingNumber, + string platformOrderNo, + CancellationToken ct) + { + try + { + var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); + var bytes = await client.GetByteArrayAsync(docUrl, ct); + if (bytes.Length == 0) + { + return docUrl; + } + + var name = string.IsNullOrWhiteSpace(trackingNumber) ? platformOrderNo : trackingNumber; + var key = $"uploads/platform-label/{DateTime.Now:yyyyMM}/md_{name}_{Guid.NewGuid():N}.pdf"; + await using var stream = new MemoryStream(bytes); + var objectKey = await objectStorage.SaveObjectAsync(stream, key, "application/pdf", ct); + var publicUrl = await objectStorage.GetPublicUrlAsync(objectKey, ct); + return publicUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? publicUrl : docUrl; + } + catch (Exception ex) + { + logger.LogWarning(ex, "TikTok 面单下载落库失败,改用平台地址"); + return docUrl; + } + } + + private async Task GetTiktokSignedAsync( + PlatformShopOptions options, + string accessToken, + string path, + Dictionary query, + CancellationToken ct) + { + var sign = PlatformShopCrypto.TiktokSign(path, query, null, options.Tiktok.AppSecret); + var url = $"{options.Tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}"; + return await GetJsonAsync(url, ct, accessToken); + } + + private static bool TryGetTiktokData(JsonElement root, out JsonElement data, out string error) + { + data = default; + error = "返回为空"; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + var code = GetString(root, "code"); + error = GetString(root, "message") ?? GetString(root, "msg") ?? error; + if (code is not ("0" or null)) + { + return false; + } + + if (!root.TryGetProperty("data", out data) || data.ValueKind != JsonValueKind.Object) + { + return false; + } + + return true; } private static string MapTiktokFulfill(string? status) => status?.ToUpperInvariant() switch