fix: TikTok 拉单按订单详情映射收件人金额行商品并落库
All checks were successful
Build and Deploy / deploy (push) Successful in 59s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-16 16:59:28 +08:00
parent a1a7524a0e
commit 9c97a218ec
3 changed files with 429 additions and 39 deletions

View File

@ -29,6 +29,14 @@ public interface IPlatformShopFulfillmentClient
SalesStore store,
string platformOrderNo,
CancellationToken ct = default);
/// <summary>
/// 拉取平台订单完整明细并映射为落库结构;不支持时返回 null。
/// </summary>
Task<PlatformShopPulledOrder?> 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<PlatformShopPulledOrderLine> 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<PlatformShopPulledOrder?> 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<PlatformShopPullOrdersResult> 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(
};
}
/// <summary>
/// 按订单 ID 批量拉详情(对齐 WMS getOrderDetail),补齐收件人、金额、行商品、运单号。
/// </summary>
private async Task EnrichTiktokOrderDetailsAsync(
SalesStore store,
PlatformShopOptions options,
string shopCipher,
Dictionary<string, PlatformShopPulledOrder> orders,
List<string> 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<string, string>
{
["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;
}
}
}
/// <summary>
/// 按 TikTok 订单明细 JSON 取值,字段对齐店铺订单主表/明细表。
/// </summary>
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<PlatformShopPulledOrderLine>();
var packageIds = new HashSet<string>(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);
}
/// <summary>
/// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。
/// </summary>
@ -791,34 +1012,57 @@ internal sealed class PlatformShopFulfillmentClient(
return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses];
}
private async Task<PlatformShopOrderStatusResult> SyncTiktokAsync(
private async Task<PlatformShopPulledOrder?> 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}&timestamp={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<string, string>
{
["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<PlatformShopOrderStatusResult> 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
}

View File

@ -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
});

View File

@ -306,7 +306,10 @@ public static class PlatformStoreOrderParcelBuilder
private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node)
{
var candidates = new List<JsonNode?> { 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;
}
/// <summary>
/// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。
/// </summary>
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)