From 9c97a218ecc02766377eeb74f99c8cc15dbb8f15 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 16:59:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20TikTok=20=E6=8B=89=E5=8D=95=E6=8C=89?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E8=AF=A6=E6=83=85=E6=98=A0=E5=B0=84=E6=94=B6?= =?UTF-8?q?=E4=BB=B6=E4=BA=BA=E9=87=91=E9=A2=9D=E8=A1=8C=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=B9=B6=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PlatformShopFulfillmentClient.cs | 362 ++++++++++++++++-- .../PlatformStoreOrderFulfillmentService.cs | 31 +- .../Orders/PlatformStoreOrderParcelBuilder.cs | 75 +++- 3 files changed, 429 insertions(+), 39 deletions(-) diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index ce0e09b..e73b5bd 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -29,6 +29,14 @@ public interface IPlatformShopFulfillmentClient SalesStore store, string platformOrderNo, CancellationToken ct = default); + + /// + /// 拉取平台订单完整明细并映射为落库结构;不支持时返回 null。 + /// + Task FetchOrderDetailAsync( + SalesStore store, + string platformOrderNo, + CancellationToken ct = default); } public sealed class PlatformShopPullOrdersRequest @@ -55,6 +63,24 @@ public sealed class PlatformShopPulledOrder public string? OrderCountry { get; set; } + public string? RecipientName { get; set; } + + public string? RecipientPhone { get; set; } + + public string? RecipientEmail { get; set; } + + public string? RecipientCountry { get; set; } + + public string? RecipientProvince { get; set; } + + public string? RecipientCity { get; set; } + + public string? RecipientZipCode { get; set; } + + public string? RecipientAddress1 { get; set; } + + public string? RecipientAddress2 { get; set; } + public int ItemCount { get; set; } public decimal? TotalAmount { get; set; } @@ -73,6 +99,8 @@ public sealed class PlatformShopPulledOrder public string? ShippingLabelUrl { get; set; } + public bool IsMultiLabel { get; set; } + public string? ExtraJson { get; set; } public List Lines { get; set; } = []; @@ -86,6 +114,10 @@ public sealed class PlatformShopPulledOrderLine public string? ProductName { get; set; } + public string? ProductImageUrl { get; set; } + + public string? VariantText { get; set; } + public int Quantity { get; set; } public decimal? UnitPrice { get; set; } @@ -212,6 +244,19 @@ internal sealed class PlatformShopFulfillmentClient( }; } + public async Task FetchOrderDetailAsync( + SalesStore store, + string platformOrderNo, + CancellationToken ct = default) + { + var options = await configProvider.GetAsync(ct); + return store.PlatformCode.ToLowerInvariant() switch + { + "tiktok" => await FetchTiktokOrderDetailAsync(store, options, platformOrderNo, ct), + _ => null + }; + } + #region Shopee private async Task PullShopeeAsync( @@ -628,14 +673,8 @@ internal sealed class PlatformShopFulfillmentClient( { 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) - }; + // 搜索结果可能已带明细;没有 line_items 时后面再按详情接口补齐 + orders[id] = MapTiktokOrder(item, statusFilter); } } @@ -656,6 +695,8 @@ internal sealed class PlatformShopFulfillmentClient( }; } + await EnrichTiktokOrderDetailsAsync(store, options, shopCipher, orders, errors, ct); + var messageText = $"TikTok 拉单完成,处理 {orders.Count} 单"; if (errors.Count > 0) { @@ -670,6 +711,186 @@ internal sealed class PlatformShopFulfillmentClient( }; } + /// + /// 按订单 ID 批量拉详情(对齐 WMS getOrderDetail),补齐收件人、金额、行商品、运单号。 + /// + private async Task EnrichTiktokOrderDetailsAsync( + SalesStore store, + PlatformShopOptions options, + string shopCipher, + Dictionary orders, + List errors, + CancellationToken ct) + { + var needDetail = orders.Values + .Where(x => x.Lines.Count == 0 + || string.IsNullOrWhiteSpace(x.RecipientAddress1) + || !x.TotalAmount.HasValue) + .Select(x => x.PlatformOrderNo) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (needDetail.Count == 0) + { + return; + } + + var tiktok = options.Tiktok; + for (var i = 0; i < needDetail.Count; i += 50) + { + var chunk = needDetail.Skip(i).Take(50).ToList(); + using var doc = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = string.Join(",", chunk), + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(doc.RootElement, out var data, out var detailError) + || !data.TryGetProperty("orders", out var arr) + || arr.ValueKind != JsonValueKind.Array) + { + errors.Add("订单详情失败:" + detailError); + continue; + } + + foreach (var item in arr.EnumerateArray()) + { + var mapped = MapTiktokOrder(item); + if (string.IsNullOrWhiteSpace(mapped.PlatformOrderNo)) continue; + orders[mapped.PlatformOrderNo] = mapped; + } + } + } + + /// + /// 按 TikTok 订单明细 JSON 取值,字段对齐店铺订单主表/明细表。 + /// + private static PlatformShopPulledOrder MapTiktokOrder(JsonElement order, string? fallbackStatus = null) + { + var id = GetString(order, "id") ?? string.Empty; + var status = GetString(order, "status") ?? fallbackStatus ?? string.Empty; + var recipient = order.TryGetProperty("recipient_address", out var addr) && addr.ValueKind == JsonValueKind.Object + ? addr + : default; + var payment = order.TryGetProperty("payment", out var pay) && pay.ValueKind == JsonValueKind.Object + ? pay + : default; + + var (province, city, countryFromDistrict) = ParseTiktokDistrict(recipient); + var country = FirstNonEmpty( + GetString(recipient, "region_code"), + countryFromDistrict, + GetString(recipient, "region")); + + var lines = new List(); + var packageIds = new HashSet(StringComparer.Ordinal); + if (order.TryGetProperty("line_items", out var lineItems) && lineItems.ValueKind == JsonValueKind.Array) + { + foreach (var line in lineItems.EnumerateArray()) + { + var packageId = GetString(line, "package_id"); + if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId); + lines.Add(new PlatformShopPulledOrderLine + { + PlatformLineId = GetString(line, "id"), + PlatformSku = GetString(line, "seller_sku"), + ProductName = GetString(line, "product_name"), + ProductImageUrl = GetString(line, "sku_image"), + VariantText = GetString(line, "sku_name"), + // TikTok 一行一件,没有 quantity 字段 + Quantity = 1, + UnitPrice = GetDecimal(line, "sale_price") ?? GetDecimal(line, "original_price"), + Currency = GetString(line, "currency") + }); + } + } + + if (order.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array) + { + foreach (var pkg in packages.EnumerateArray()) + { + var packageId = GetString(pkg, "id"); + if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId); + } + } + + var tracking = GetString(order, "tracking_number"); + if (string.IsNullOrWhiteSpace(tracking) + && order.TryGetProperty("line_items", out var itemsForTrack) + && itemsForTrack.ValueKind == JsonValueKind.Array + && itemsForTrack.GetArrayLength() > 0) + { + tracking = GetString(itemsForTrack[0], "tracking_number"); + } + + return new PlatformShopPulledOrder + { + PlatformOrderNo = id, + OrderCountry = country, + RecipientName = FirstNonEmpty(GetString(recipient, "name"), GetString(recipient, "full_address")), + RecipientPhone = GetString(recipient, "phone_number"), + RecipientEmail = GetString(order, "buyer_email"), + RecipientCountry = country, + RecipientProvince = province, + RecipientCity = city, + RecipientZipCode = GetString(recipient, "postal_code"), + RecipientAddress1 = FirstNonEmpty( + GetString(recipient, "address_line1"), + GetString(recipient, "address_detail"), + GetString(recipient, "full_address")), + RecipientAddress2 = GetString(recipient, "address_line2"), + ItemCount = lines.Count, + TotalAmount = GetDecimal(payment, "total_amount") ?? GetDecimal(payment, "sub_total"), + Currency = FirstNonEmpty(GetString(payment, "currency"), lines.FirstOrDefault()?.Currency), + PlatformOrderTime = FromUnixSeconds(GetLong(order, "create_time")), + PlatformStatus = status, + PlatformStatusLabel = status, + FulfillStatus = MapTiktokFulfill(status), + ShippingLabelNo = tracking, + IsMultiLabel = packageIds.Count > 1, + ExtraJson = order.ValueKind == JsonValueKind.Undefined ? null : order.GetRawText(), + Lines = lines + }; + } + + private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrict(JsonElement recipient) + { + string? province = null; + string? city = null; + string? country = null; + if (recipient.ValueKind != JsonValueKind.Object + || !recipient.TryGetProperty("district_info", out var districts) + || districts.ValueKind != JsonValueKind.Array) + { + return (null, null, null); + } + + foreach (var district in districts.EnumerateArray()) + { + var level = GetString(district, "address_level"); + var name = GetString(district, "address_name"); + var iso = GetString(district, "iso_code"); + switch (level) + { + case "L0": + country = FirstNonEmpty(iso, name); + break; + case "L1": + province = name; + break; + case "L3": + city = name; + break; + case "L2" when string.IsNullOrWhiteSpace(city): + city = name; + break; + } + } + + return (province, city, country); + } + /// /// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。 /// @@ -791,34 +1012,57 @@ internal sealed class PlatformShopFulfillmentClient( return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses]; } - private async Task SyncTiktokAsync( + private async Task FetchTiktokOrderDetailAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { EnsureTiktok(store, options); - var tiktok = options.Tiktok; - var token = store.AccessToken!; - var apiPath = $"/order/202309/orders?app_key={tiktok.AppKey}&ids={platformOrderNo}×tamp={PlatformShopCrypto.UnixSeconds()}"; - var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret); - var url = $"{tiktok.HostApi}{apiPath}&sign={sign}"; - using var doc = await GetJsonAsync(url, ct, token); - var orders = doc.RootElement.GetProperty("data").GetProperty("orders"); - if (orders.GetArrayLength() == 0) + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) { - return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单" }; + return null; + } + + using var doc = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = options.Tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = platformOrderNo, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(doc.RootElement, out var data, out _) + || !data.TryGetProperty("orders", out var orders) + || orders.ValueKind != JsonValueKind.Array + || orders.GetArrayLength() == 0) + { + return null; + } + + return MapTiktokOrder(orders[0]); + } + + private async Task SyncTiktokAsync( + SalesStore store, + PlatformShopOptions options, + string platformOrderNo, + CancellationToken ct) + { + var mapped = await FetchTiktokOrderDetailAsync(store, options, platformOrderNo, ct); + if (mapped == null) + { + return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单或缺少 shop_cipher" }; } - var order = orders[0]; - var status = GetString(order, "status") ?? string.Empty; return new PlatformShopOrderStatusResult { Success = true, Message = "同步成功", - PlatformStatus = status, - PlatformStatusLabel = status, - FulfillStatus = MapTiktokFulfill(status) + PlatformStatus = mapped.PlatformStatus, + PlatformStatusLabel = mapped.PlatformStatusLabel, + FulfillStatus = mapped.FulfillStatus, + ShippingLabelNo = mapped.ShippingLabelNo }; } @@ -1266,11 +1510,73 @@ internal sealed class PlatformShopFulfillmentClient( } } - private static string? GetString(JsonElement el, string name) => - el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : - el.TryGetProperty(name, out p) && p.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False - ? p.ToString() - : null; + private static string? GetString(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p)) + { + return null; + } + + return p.ValueKind switch + { + JsonValueKind.String => p.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => p.ToString(), + _ => null + }; + } + + private static decimal? GetDecimal(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p)) + { + return null; + } + + if (p.ValueKind == JsonValueKind.Number && p.TryGetDecimal(out var number)) + { + return number; + } + + if (p.ValueKind == JsonValueKind.String + && decimal.TryParse(p.GetString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + + return null; + } + + private static long? GetLong(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p)) + { + return null; + } + + if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var number)) + { + return number; + } + + if (p.ValueKind == JsonValueKind.String + && long.TryParse(p.GetString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + + return null; + } + + private static DateTime? FromUnixSeconds(long? unix) + { + if (unix is null or <= 0) return null; + return DateTimeOffset.FromUnixTimeSeconds(unix.Value).LocalDateTime; + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); #endregion } diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs index e8e06de..7f9bc3a 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs @@ -187,6 +187,23 @@ internal sealed class PlatformStoreOrderFulfillmentService( try { + var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo); + if (detail != null) + { + await UpsertPulledOrderAsync(order.CustomerId, store, detail); + var refreshed = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; + return new PlatformStoreOrderBatchActionItemOutput + { + Id = refreshed.Id, + PlatformOrderNo = refreshed.PlatformOrderNo, + Success = true, + Message = "同步成功", + FulfillStatus = refreshed.FulfillStatus, + ShippingLabelNo = refreshed.ShippingLabelNo, + ShippingLabelUrl = refreshed.ShippingLabelUrl + }; + } + var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo); if (!result.Success) { @@ -307,6 +324,15 @@ internal sealed class PlatformStoreOrderFulfillmentService( } entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry; + if (!string.IsNullOrWhiteSpace(pulled.RecipientName)) entity.RecipientName = pulled.RecipientName; + if (!string.IsNullOrWhiteSpace(pulled.RecipientPhone)) entity.RecipientPhone = pulled.RecipientPhone; + if (!string.IsNullOrWhiteSpace(pulled.RecipientEmail)) entity.RecipientEmail = pulled.RecipientEmail; + if (!string.IsNullOrWhiteSpace(pulled.RecipientCountry)) entity.RecipientCountry = pulled.RecipientCountry; + if (!string.IsNullOrWhiteSpace(pulled.RecipientProvince)) entity.RecipientProvince = pulled.RecipientProvince; + if (!string.IsNullOrWhiteSpace(pulled.RecipientCity)) entity.RecipientCity = pulled.RecipientCity; + if (!string.IsNullOrWhiteSpace(pulled.RecipientZipCode)) entity.RecipientZipCode = pulled.RecipientZipCode; + if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress1)) entity.RecipientAddress1 = pulled.RecipientAddress1; + if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress2)) entity.RecipientAddress2 = pulled.RecipientAddress2; entity.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount; entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount; entity.Currency = pulled.Currency ?? entity.Currency; @@ -316,6 +342,7 @@ internal sealed class PlatformStoreOrderFulfillmentService( entity.FulfillStatus = pulled.FulfillStatus; entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo; entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl; + entity.IsMultiLabel = pulled.IsMultiLabel || entity.IsMultiLabel; entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson; entity.LastSyncTime = now; PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson); @@ -348,7 +375,9 @@ internal sealed class PlatformStoreOrderFulfillmentService( PlatformLineId = line.PlatformLineId, PlatformSku = line.PlatformSku, ProductName = line.ProductName, - Quantity = line.Quantity, + ProductImageUrl = line.ProductImageUrl, + VariantText = line.VariantText, + Quantity = line.Quantity > 0 ? line.Quantity : 1, UnitPrice = line.UnitPrice, Currency = line.Currency }); diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs index c4828d8..ae10249 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs @@ -306,7 +306,10 @@ public static class PlatformStoreOrderParcelBuilder private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node) { var candidates = new List { node }; - foreach (var key in new[] { "recipient", "receiver", "shippingAddress", "address" }) + foreach (var key in new[] + { + "recipient_address", "recipientAddress", "recipient", "receiver", "shippingAddress", "address" + }) { if (node[key] != null) { @@ -321,10 +324,22 @@ public static class PlatformStoreOrderParcelBuilder continue; } - var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName"); - var country = ReadText(candidate, "countryCode", "country", "region", "orderCountry") ?? ReadText(node, "orderCountry"); - var zip = ReadText(candidate, "zipCode", "zip", "postalCode", "postcode"); - var address = ReadText(candidate, "streetAddress1", "address", "address1", "fullAddress", "detailAddress"); + var (province, city, countryFromDistrict) = ParseTiktokDistrictInfo(candidate); + var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName", "first_name"); + var country = ReadText(candidate, "countryCode", "country", "region_code", "region", "orderCountry") + ?? countryFromDistrict + ?? ReadText(node, "orderCountry"); + var zip = ReadText(candidate, "zipCode", "zip", "postal_code", "postalCode", "postcode"); + var address = ReadText( + candidate, + "streetAddress1", + "address_line1", + "address_detail", + "address", + "address1", + "full_address", + "fullAddress", + "detailAddress"); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address)) { continue; @@ -334,14 +349,14 @@ public static class PlatformStoreOrderParcelBuilder { RecipientsName = name, CompanyName = ReadText(candidate, "companyName", "company"), - Phone = ReadText(candidate, "phone", "mobile", "phoneNumber"), + Phone = ReadText(candidate, "phone", "mobile", "phone_number", "phoneNumber"), TelePhone = ReadText(candidate, "telePhone", "tel"), - Email = ReadText(candidate, "email"), + Email = ReadText(candidate, "email") ?? ReadText(node, "buyer_email"), CountryCode = country, - Province = ReadText(candidate, "province", "state", "regionState"), - City = ReadText(candidate, "city"), + Province = ReadText(candidate, "province", "state", "regionState") ?? province, + City = ReadText(candidate, "city") ?? city, StreetAddress1 = address, - StreetAddress2 = ReadText(candidate, "streetAddress2", "address2"), + StreetAddress2 = ReadText(candidate, "streetAddress2", "address_line2", "address2"), ZipCode = zip }; } @@ -349,6 +364,46 @@ public static class PlatformStoreOrderParcelBuilder return null; } + /// + /// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。 + /// + private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrictInfo(JsonNode candidate) + { + var districts = candidate["district_info"]?.AsArray(); + if (districts == null || districts.Count == 0) + { + return (null, null, null); + } + + string? province = null; + string? city = null; + string? country = null; + foreach (var district in districts) + { + if (district == null) continue; + var level = district["address_level"]?.ToString(); + var name = district["address_name"]?.ToString()?.Trim(); + var iso = district["iso_code"]?.ToString()?.Trim(); + switch (level) + { + case "L0": + country = !string.IsNullOrWhiteSpace(iso) ? iso : name; + break; + case "L1": + province = name; + break; + case "L3": + city = name; + break; + case "L2" when string.IsNullOrWhiteSpace(city): + city = name; + break; + } + } + + return (province, city, country); + } + private static string? ReadText(JsonNode node, params string[] keys) { foreach (var key in keys)