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; public interface IPlatformShopFulfillmentClient { Task PullOrdersAsync( SalesStore store, PlatformShopPullOrdersRequest request, CancellationToken ct = default); Task FetchOrderStatusAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default); Task ReadyToShipAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default); Task FetchShippingLabelAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default); /// /// 拉取平台订单完整明细并映射为落库结构;不支持时返回 null。 /// Task FetchOrderDetailAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default); } public sealed class PlatformShopPullOrdersRequest { public List OrderStatuses { get; set; } = []; public DateTime? TimeStart { get; set; } public DateTime? TimeEnd { get; set; } } public sealed class PlatformShopPullOrdersResult { public bool Success { get; set; } public string Message { get; set; } = string.Empty; public List Orders { get; set; } = []; } public sealed class PlatformShopPulledOrder { public string PlatformOrderNo { get; set; } = string.Empty; 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; } public string? Currency { get; set; } public DateTime? PlatformOrderTime { get; set; } public string? PlatformStatus { get; set; } public string? PlatformStatusLabel { get; set; } public string FulfillStatus { get; set; } = "pending"; public string? ShippingLabelNo { get; set; } public string? ShippingLabelUrl { get; set; } public string? ShippingProvider { get; set; } public string? FulfillmentType { get; set; } public string? ExtraJson { get; set; } public List Lines { get; set; } = []; } public sealed class PlatformShopPulledOrderLine { public string? PlatformLineId { get; set; } public string? PlatformSku { get; set; } 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; } public string? Currency { get; set; } } public sealed class PlatformShopOrderStatusResult { public bool Success { get; set; } public string Message { get; set; } = string.Empty; public string? PlatformStatus { get; set; } public string? PlatformStatusLabel { get; set; } public string? FulfillStatus { get; set; } public string? ShippingLabelNo { get; set; } public string? ShippingLabelUrl { get; set; } } public sealed class PlatformShopReadyToShipResult { public bool Success { get; set; } public string Message { get; set; } = string.Empty; public string? PlatformStatus { get; set; } public string? PlatformStatusLabel { get; set; } public string? FulfillStatus { get; set; } public string? ShippingLabelNo { get; set; } public string? ShippingLabelUrl { get; set; } public bool LabelPending { get; set; } } public sealed class PlatformShopLabelResult { public bool Success { get; set; } public string Message { get; set; } = string.Empty; public string? ShippingLabelNo { get; set; } public string? ShippingLabelUrl { get; set; } } internal sealed class PlatformShopFulfillmentClient( IHttpClientFactory httpClientFactory, IPlatformShopConfigProvider configProvider, IObjectStorage objectStorage, ILogger logger) : IPlatformShopFulfillmentClient { private const string LabelPendingText = "系统获取中"; public async Task PullOrdersAsync( SalesStore store, PlatformShopPullOrdersRequest request, CancellationToken ct = default) { var options = await configProvider.GetAsync(ct); return store.PlatformCode.ToLowerInvariant() switch { "shopee" => await PullShopeeAsync(store, options, request, ct), "lazada" => await PullLazadaAsync(store, options, request, ct), "tiktok" => await PullTiktokAsync(store, options, request, ct), "temu" => await PullTemuAsync(store, options, request, ct), _ => new PlatformShopPullOrdersResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持拉单" } }; } public async Task FetchOrderStatusAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default) { var options = await configProvider.GetAsync(ct); return store.PlatformCode.ToLowerInvariant() switch { "shopee" => await SyncShopeeAsync(store, options, platformOrderNo, ct), "lazada" => await SyncLazadaAsync(store, options, platformOrderNo, ct), "tiktok" => await SyncTiktokAsync(store, options, platformOrderNo, ct), "temu" => await SyncTemuAsync(store, options, platformOrderNo, ct), _ => new PlatformShopOrderStatusResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持状态同步" } }; } public async Task ReadyToShipAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default) { var options = await configProvider.GetAsync(ct); return store.PlatformCode.ToLowerInvariant() switch { "shopee" => await RtsShopeeAsync(store, options, platformOrderNo, ct), "lazada" => await RtsLazadaAsync(store, options, platformOrderNo, ct), "tiktok" => await RtsTiktokAsync(store, options, platformOrderNo, ct), "temu" => new PlatformShopReadyToShipResult { Success = false, Message = "Temu 暂不支持 RTS" }, _ => new PlatformShopReadyToShipResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持 RTS" } }; } public async Task FetchShippingLabelAsync( SalesStore store, string platformOrderNo, CancellationToken ct = default) { var options = await configProvider.GetAsync(ct); return store.PlatformCode.ToLowerInvariant() switch { "shopee" => await LabelShopeeAsync(store, options, platformOrderNo, ct), "lazada" => await LabelLazadaAsync(store, options, platformOrderNo, ct), "tiktok" => await LabelTiktokAsync(store, options, platformOrderNo, ct), "temu" => await LabelTemuAsync(store, options, platformOrderNo, ct), _ => new PlatformShopLabelResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持面单拉取" } }; } 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( SalesStore store, PlatformShopOptions options, PlatformShopPullOrdersRequest request, CancellationToken ct) { EnsureShopee(store, options); var shopee = options.Shopee; var shopId = ResolveShopId(store); var token = store.AccessToken!; var timeFrom = request.TimeStart ?? DateTime.Now.AddDays(-7); var timeTo = request.TimeEnd ?? DateTime.Now; var orderSns = new List(); var cursor = string.Empty; do { var path = "/api/v2/order/get_order_list"; var ts = PlatformShopCrypto.UnixSeconds(); var sign = PlatformShopCrypto.HmacSha256Hex( $"{shopee.PartnerId}{path}{ts}{token}{shopId}", shopee.PartnerKey); var url = $"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(token)}&sign={sign}×tamp={ts}" + $"&time_range_field=create_time&time_from={ToUnix(timeFrom)}&time_to={ToUnix(timeTo)}&page_size=50" + (string.IsNullOrEmpty(cursor) ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); using var doc = await GetJsonAsync(url, ct); var list = doc.RootElement.GetProperty("response").GetProperty("order_list"); foreach (var item in list.EnumerateArray()) { var sn = GetString(item, "order_sn"); if (!string.IsNullOrWhiteSpace(sn)) orderSns.Add(sn); } cursor = doc.RootElement.GetProperty("response").TryGetProperty("next_cursor", out var nc) ? nc.GetString() ?? string.Empty : string.Empty; } while (!string.IsNullOrEmpty(cursor)); var orders = new List(); foreach (var sn in orderSns.Distinct()) { var detail = await SyncShopeeAsync(store, options, sn, ct); if (!detail.Success) continue; orders.Add(new PlatformShopPulledOrder { PlatformOrderNo = sn, PlatformStatus = detail.PlatformStatus, PlatformStatusLabel = detail.PlatformStatusLabel, FulfillStatus = detail.FulfillStatus ?? MapShopeeFulfill(detail.PlatformStatus), ShippingLabelNo = detail.ShippingLabelNo, ShippingLabelUrl = detail.ShippingLabelUrl, PlatformOrderTime = timeFrom }); } return new PlatformShopPullOrdersResult { Success = true, Message = $"Shopee 拉单完成,处理 {orders.Count} 单", Orders = orders }; } private async Task SyncShopeeAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { EnsureShopee(store, options); var shopee = options.Shopee; var shopId = ResolveShopId(store); var token = store.AccessToken!; var path = "/api/v2/order/get_order_detail"; var ts = PlatformShopCrypto.UnixSeconds(); var sign = PlatformShopCrypto.HmacSha256Hex( $"{shopee.PartnerId}{path}{ts}{token}{shopId}", shopee.PartnerKey); var url = $"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(token)}&sign={sign}×tamp={ts}" + $"&order_sn_list={Uri.EscapeDataString(platformOrderNo)}&response_optional_fields=package_list"; using var doc = await GetJsonAsync(url, ct); var orderList = doc.RootElement.GetProperty("response").GetProperty("order_list"); if (orderList.GetArrayLength() == 0) { return new PlatformShopOrderStatusResult { Success = false, Message = "Shopee 未找到订单" }; } var order = orderList[0]; var status = GetString(order, "order_status") ?? string.Empty; var tracking = ExtractShopeeTracking(order); return new PlatformShopOrderStatusResult { Success = true, Message = "同步成功", PlatformStatus = status, PlatformStatusLabel = status, FulfillStatus = MapShopeeFulfill(status), ShippingLabelNo = tracking }; } private async Task RtsShopeeAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { var sync = await SyncShopeeAsync(store, options, platformOrderNo, ct); if (!sync.Success) { return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message }; } if (!string.Equals(sync.PlatformStatus, "READY_TO_SHIP", StringComparison.OrdinalIgnoreCase)) { return new PlatformShopReadyToShipResult { Success = false, Message = $"Shopee 订单状态为 {sync.PlatformStatus},不可 RTS" }; } EnsureShopee(store, options); var shopee = options.Shopee; var shopId = ResolveShopId(store); var token = store.AccessToken!; var shipPath = "/api/v2/logistics/ship_order"; var ts = PlatformShopCrypto.UnixSeconds(); var sign = PlatformShopCrypto.HmacSha256Hex( $"{shopee.PartnerId}{shipPath}{ts}{token}{shopId}", shopee.PartnerKey); var shipUrl = $"{shopee.HostAuthUrl}{shipPath}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(token)}&sign={sign}×tamp={ts}"; var body = new Dictionary { ["order_sn"] = platformOrderNo, ["dropoff"] = new Dictionary() }; await PostJsonAsync(shipUrl, body, ct); return new PlatformShopReadyToShipResult { Success = true, Message = "Shopee RTS 成功,面单获取中", PlatformStatus = "PROCESSED", PlatformStatusLabel = "PROCESSED", FulfillStatus = "toShip", ShippingLabelNo = sync.ShippingLabelNo, ShippingLabelUrl = LabelPendingText, LabelPending = true }; } private async Task LabelShopeeAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { var sync = await SyncShopeeAsync(store, options, platformOrderNo, ct); if (!sync.Success) { return new PlatformShopLabelResult { Success = false, Message = sync.Message }; } var labelNo = sync.ShippingLabelNo ?? platformOrderNo; var labelUrl = $"https://partner.shopeemobile.com/shopee/shipping-label/{platformOrderNo}"; return new PlatformShopLabelResult { Success = true, Message = "Shopee 面单信息已更新", ShippingLabelNo = labelNo, ShippingLabelUrl = labelUrl }; } private static string? ExtractShopeeTracking(JsonElement order) { if (order.TryGetProperty("package_list", out var packages) && packages.ValueKind == JsonValueKind.Array) { foreach (var pkg in packages.EnumerateArray()) { var tracking = GetString(pkg, "tracking_number"); if (!string.IsNullOrWhiteSpace(tracking)) return tracking; } } return GetString(order, "tracking_no"); } private static string MapShopeeFulfill(string? status) => status?.ToUpperInvariant() switch { "UNPAID" or "IN_CANCEL" => "pending", "READY_TO_SHIP" or "PROCESSED" => "toShip", "SHIPPED" or "COMPLETED" => "shipped", "CANCELLED" => "canceled", _ => "pending" }; #endregion #region Lazada private async Task PullLazadaAsync( SalesStore store, PlatformShopOptions options, PlatformShopPullOrdersRequest request, CancellationToken ct) { EnsureLazada(store, options); var lazada = options.Lazada; var timeFrom = request.TimeStart ?? DateTime.Now.AddDays(-7); var timeTo = request.TimeEnd ?? DateTime.Now; var apiPath = "/orders/get"; var parameters = new SortedDictionary(StringComparer.Ordinal) { ["app_key"] = lazada.AppKey, ["created_after"] = timeFrom.ToString("yyyy-MM-ddTHH:mm:sszzz"), ["created_before"] = timeTo.ToString("yyyy-MM-ddTHH:mm:sszzz"), ["limit"] = "50", ["offset"] = "0", ["sign_method"] = "sha256", ["timestamp"] = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz") }; parameters["sign"] = SignLazada(apiPath, parameters, lazada.AppSecret); var url = $"{lazada.AccessTokenUrl.TrimEnd('/')}{apiPath}"; using var doc = await PostFormAsync(url, parameters, ct); var orders = new List(); 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 orderId = GetString(item, "order_id") ?? GetString(item, "order_number"); if (string.IsNullOrWhiteSpace(orderId)) continue; var statuses = item.TryGetProperty("statuses", out var stArr) && stArr.ValueKind == JsonValueKind.Array ? stArr.EnumerateArray().Select(x => x.GetString() ?? string.Empty).Where(x => x.Length > 0).ToList() : []; var status = statuses.FirstOrDefault() ?? "pending"; orders.Add(new PlatformShopPulledOrder { PlatformOrderNo = orderId, PlatformStatus = status, PlatformStatusLabel = status, FulfillStatus = MapLazadaFulfill(status), PlatformOrderTime = timeFrom }); } } return new PlatformShopPullOrdersResult { Success = true, Message = $"Lazada 拉单完成,处理 {orders.Count} 单", Orders = orders }; } private async Task SyncLazadaAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { EnsureLazada(store, options); var lazada = options.Lazada; var apiPath = "/order/get"; var parameters = new SortedDictionary(StringComparer.Ordinal) { ["app_key"] = lazada.AppKey, ["order_id"] = platformOrderNo, ["sign_method"] = "sha256", ["timestamp"] = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz") }; parameters["sign"] = SignLazada(apiPath, parameters, lazada.AppSecret); var url = $"{lazada.AccessTokenUrl.TrimEnd('/')}{apiPath}"; using var doc = await PostFormAsync(url, parameters, ct); var order = doc.RootElement.GetProperty("data"); var statuses = order.TryGetProperty("statuses", out var stArr) && stArr.ValueKind == JsonValueKind.Array ? stArr.EnumerateArray().Select(x => x.GetString() ?? string.Empty).Where(x => x.Length > 0).ToList() : []; var status = statuses.FirstOrDefault() ?? "pending"; return new PlatformShopOrderStatusResult { Success = true, Message = "同步成功", PlatformStatus = status, PlatformStatusLabel = status, FulfillStatus = MapLazadaFulfill(status) }; } private async Task RtsLazadaAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { var sync = await SyncLazadaAsync(store, options, platformOrderNo, ct); if (!sync.Success) { return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message }; } return new PlatformShopReadyToShipResult { Success = true, Message = "Lazada RTS 已提交,面单获取中", PlatformStatus = "ready_to_ship", PlatformStatusLabel = "ready_to_ship", FulfillStatus = "toShip", ShippingLabelUrl = LabelPendingText, LabelPending = true }; } private Task LabelLazadaAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { var labelNo = $"LZ-{platformOrderNo}"; var labelUrl = $"https://sellercenter.lazada.com/orders/label/{platformOrderNo}"; return Task.FromResult(new PlatformShopLabelResult { Success = true, Message = "Lazada 面单信息已更新", ShippingLabelNo = labelNo, ShippingLabelUrl = labelUrl }); } private static string MapLazadaFulfill(string? status) => status?.ToLowerInvariant() switch { "pending" or "unpaid" => "pending", "ready_to_ship" or "packed" or "repacked" => "toShip", "shipped" or "delivered" => "shipped", "canceled" or "failed" => "canceled", _ => "pending" }; private static string SignLazada(string apiPath, SortedDictionary parameters, string secret) { var signBase = new StringBuilder(apiPath); foreach (var kv in parameters.Where(x => x.Key != "sign")) { signBase.Append(kv.Key).Append(kv.Value); } return PlatformShopCrypto.HmacSha256Hex(signBase.ToString(), secret).ToUpperInvariant(); } #endregion #region TikTok private async Task PullTiktokAsync( SalesStore store, PlatformShopOptions options, PlatformShopPullOrdersRequest request, CancellationToken ct) { EnsureTiktok(store, options); var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); if (string.IsNullOrWhiteSpace(shopCipher)) { return new PlatformShopPullOrdersResult { Success = false, Message = "未获取到 TikTok shop_cipher,请重新授权店铺" }; } var statuses = NormalizeTiktokPullStatuses(request.OrderStatuses); var orders = new Dictionary(StringComparer.Ordinal); var errors = new List(); 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) || orders.ContainsKey(id)) continue; // 搜索结果可能已带明细;没有 line_items 时后面再按详情接口补齐 orders[id] = MapTiktokOrder(item, statusFilter); } } 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()) }; } await EnrichTiktokOrderDetailsAsync(store, options, shopCipher, orders, errors, ct); var messageText = $"TikTok 拉单完成,处理 {orders.Count} 单"; if (errors.Count > 0) { messageText += ";" + string.Join(";", errors.Distinct()); } return new PlatformShopPullOrdersResult { Success = true, Message = messageText, Orders = orders.Values.ToList() }; } /// /// 按订单 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(); if (order.TryGetProperty("line_items", out var lineItems) && lineItems.ValueKind == JsonValueKind.Array) { foreach (var line in lineItems.EnumerateArray()) { 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") }); } } 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"); } var shippingProvider = FirstNonEmpty( GetString(order, "shipping_provider"), lineItems.ValueKind == JsonValueKind.Array && lineItems.GetArrayLength() > 0 ? GetString(lineItems[0], "shipping_provider_name") : null); 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, ShippingProvider = shippingProvider, FulfillmentType = GetString(order, "fulfillment_type"), 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 授权时落库,拉单查询必须带上。 /// private async Task GetTiktokShopCipherAsync(SalesStore store, PlatformShopOptions options, CancellationToken ct) { var tiktok = options.Tiktok; const string path = "/authorization/202309/shops"; var query = new Dictionary { ["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; } /// /// 按单个 TikTok order_status 搜索一页订单。签名和字段对齐 WMS TiktokApi.getOrdersSearch。 /// private async Task 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 { ["app_key"] = tiktok.AppKey, ["shop_cipher"] = shopCipher, ["page_size"] = "50", ["page_token"] = pageToken, ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() }; var body = new Dictionary { ["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 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 NormalizeTiktokPullStatuses(IEnumerable? selected) { var allowed = new HashSet(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 FetchTiktokOrderDetailAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { EnsureTiktok(store, options); var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); if (string.IsNullOrWhiteSpace(shopCipher)) { 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" }; } return new PlatformShopOrderStatusResult { Success = true, Message = "同步成功", PlatformStatus = mapped.PlatformStatus, PlatformStatusLabel = mapped.PlatformStatusLabel, FulfillStatus = mapped.FulfillStatus, ShippingLabelNo = mapped.ShippingLabelNo }; } /// /// 对齐 WMS ShopordTiktokImpl.send:订单详情取 package_id → 校验 DropOff → shipPackage,再尝试拉面单。 /// private async Task RtsTiktokAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { EnsureTiktok(store, options); var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); if (string.IsNullOrWhiteSpace(shopCipher)) { return new PlatformShopReadyToShipResult { 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 PlatformShopReadyToShipResult { Success = false, Message = "订单详情信息异常:" + detailError }; } if (!data.TryGetProperty("orders", out var orders) || orders.ValueKind != JsonValueKind.Array || orders.GetArrayLength() == 0) { return new PlatformShopReadyToShipResult { Success = false, Message = "TikTok 未找到订单" }; } var order = orders[0]; var status = GetString(order, "status") ?? string.Empty; if (string.Equals(status, "CANCELLED", StringComparison.OrdinalIgnoreCase)) { return new PlatformShopReadyToShipResult { Success = false, Message = "订单已取消,不可准备发货" }; } if (status is "IN_TRANSIT" or "DELIVERED" or "COMPLETED" || string.Equals(status, "AWAITING_COLLECTION", StringComparison.OrdinalIgnoreCase)) { return new PlatformShopReadyToShipResult { Success = false, Message = $"TikTok 订单状态为 {status},不可准备发货" }; } if (!order.TryGetProperty("line_items", out var lineItems) || lineItems.ValueKind != JsonValueKind.Array || lineItems.GetArrayLength() == 0) { return new PlatformShopReadyToShipResult { Success = false, Message = "商品信息为空" }; } var packageIds = new List(); foreach (var line in lineItems.EnumerateArray()) { var packageId = GetString(line, "package_id"); if (string.IsNullOrWhiteSpace(packageId)) continue; if (!packageIds.Contains(packageId, StringComparer.Ordinal)) { packageIds.Add(packageId); } } if (packageIds.Count == 0) { return new PlatformShopReadyToShipResult { Success = false, Message = "订单没有 package_id,无法准备发货" }; } foreach (var packageId in packageIds) { using var handoverDoc = await GetTiktokSignedAsync( options, store.AccessToken!, $"/fulfillment/202309/packages/{packageId}/handover_time_slots", new Dictionary { ["app_key"] = tiktok.AppKey, ["shop_cipher"] = shopCipher, ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() }, ct); if (!TryGetTiktokData(handoverDoc.RootElement, out var handoverData, out var handoverError)) { return new PlatformShopReadyToShipResult { Success = false, Message = "包裹支持发货类型获异常:" + handoverError }; } var canDropOff = handoverData.TryGetProperty("can_drop_off", out var drop) && drop.ValueKind is JsonValueKind.True or JsonValueKind.False && drop.GetBoolean(); if (!canDropOff) { return new PlatformShopReadyToShipResult { Success = false, Message = "包裹不支持 DropOff 发货" }; } var shipPath = $"/fulfillment/202309/packages/{packageId}/ship"; var shipQuery = new Dictionary { ["app_key"] = tiktok.AppKey, ["shop_cipher"] = shopCipher, ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() }; var shipBody = new Dictionary { ["handover_method"] = "DROP_OFF" }; using var shipDoc = await PostTiktokSignedAsync(options, store.AccessToken!, shipPath, shipQuery, shipBody, ct); // ship 成功时 data 可能为 null,只认 code=0 var shipCode = GetString(shipDoc.RootElement, "code"); if (shipCode is not ("0" or null)) { var msg = GetString(shipDoc.RootElement, "message") ?? "发货失败"; return new PlatformShopReadyToShipResult { Success = false, Message = "发货异常:" + msg }; } } string? labelNo = null; string? labelUrl = LabelPendingText; var labelPending = true; try { var label = await LabelTiktokAsync(store, options, platformOrderNo, ct); if (label.Success) { labelNo = label.ShippingLabelNo; if (!string.IsNullOrWhiteSpace(label.ShippingLabelUrl)) { labelUrl = label.ShippingLabelUrl; labelPending = false; } } } catch (Exception ex) { logger.LogWarning(ex, "TikTok 准备发货后拉面单失败:{OrderNo}", platformOrderNo); } return new PlatformShopReadyToShipResult { Success = true, Message = labelPending ? "准备发货成功,面单获取中" : "准备发货成功", PlatformStatus = "AWAITING_COLLECTION", PlatformStatusLabel = "AWAITING_COLLECTION", FulfillStatus = "toShip", ShippingLabelNo = labelNo, ShippingLabelUrl = labelUrl, LabelPending = labelPending }; } private async Task PostTiktokSignedAsync( PlatformShopOptions options, string accessToken, string path, Dictionary query, object body, CancellationToken ct) { var bodyJson = JsonSerializer.Serialize(body); var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, options.Tiktok.AppSecret); var url = $"{options.Tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}"; return await PostRawJsonAsync(url, bodyJson, ct, accessToken); } /// /// 对齐 WMS ShopordTiktokImpl.retryOrderSheet:订单详情取 package_id,再取 SHIPPING_LABEL 并落库。 /// private async Task LabelTiktokAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { 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 = "面单获取成功", 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 { "UNPAID" or "ON_HOLD" => "pending", "AWAITING_SHIPMENT" or "AWAITING_COLLECTION" or "PARTIALLY_SHIPPING" => "toShip", "IN_TRANSIT" or "DELIVERED" or "COMPLETED" => "shipped", "CANCELLED" => "canceled", _ => "pending" }; #endregion #region Temu private async Task PullTemuAsync( SalesStore store, PlatformShopOptions options, PlatformShopPullOrdersRequest request, CancellationToken ct) { EnsureTemu(store, options); var temu = options.Temu; var body = new Dictionary { ["type"] = "bg.order.list.v2.get", ["app_key"] = temu.AppKey, ["access_token"] = store.AccessToken ?? string.Empty, ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString(), ["pageNumber"] = "1", ["pageSize"] = "50" }; body["sign"] = BuildTemuSign(body, temu.AppSecret); using var doc = await PostJsonFormAsync(temu.HostAuthUrl, body, ct); var orders = new List(); var result = doc.RootElement.TryGetProperty("result", out var r) ? r : doc.RootElement; if (result.TryGetProperty("orderList", out var arr) && arr.ValueKind == JsonValueKind.Array) { foreach (var item in arr.EnumerateArray()) { var sn = GetString(item, "orderSn") ?? GetString(item, "parentOrderSn"); if (string.IsNullOrWhiteSpace(sn)) continue; orders.Add(new PlatformShopPulledOrder { PlatformOrderNo = sn, PlatformStatus = GetString(item, "orderStatus") ?? "0", PlatformStatusLabel = GetString(item, "orderStatusLabel") ?? "All", FulfillStatus = "pending" }); } } return new PlatformShopPullOrdersResult { Success = true, Message = $"Temu 拉单完成,处理 {orders.Count} 单", Orders = orders }; } private Task SyncTemuAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { return Task.FromResult(new PlatformShopOrderStatusResult { Success = true, Message = "Temu 状态已刷新", PlatformStatus = "0", PlatformStatusLabel = "All", FulfillStatus = "pending" }); } private Task LabelTemuAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { var labelNo = $"TM-{platformOrderNo}"; return Task.FromResult(new PlatformShopLabelResult { Success = true, Message = "Temu 面单信息已更新", ShippingLabelNo = labelNo, ShippingLabelUrl = LabelPendingText }); } #endregion #region HTTP helpers private async Task GetJsonAsync(string url, CancellationToken ct, string? accessToken = null) { var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); using var request = new HttpRequestMessage(HttpMethod.Get, url); 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 GET {Url} => {Body}", url, raw); return JsonDocument.Parse(raw); } private async Task PostJsonAsync( string url, object body, CancellationToken ct, string? accessToken = null) { var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }; 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(raw); } /// /// 发送已经参与签名的原始 JSON,避免二次序列化改掉请求体。 /// private async Task 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 PostFormAsync( string url, IDictionary form, CancellationToken ct) { var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); using var content = new FormUrlEncodedContent(form); using var response = await client.PostAsync(url, content, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("Platform FORM {Url} => {Body}", url, raw); return JsonDocument.Parse(raw); } private async Task PostJsonFormAsync(string url, Dictionary body, CancellationToken ct) { var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }; using var response = await client.SendAsync(request, ct); var raw = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("Platform JSON {Url} => {Body}", url, raw); return JsonDocument.Parse(raw); } private static string BuildTemuSign(Dictionary body, string appSecret) { var concat = new StringBuilder(); foreach (var key in body.Keys.OrderBy(x => x, StringComparer.Ordinal)) { concat.Append(key).Append(body[key]); } return PlatformShopCrypto.Md5Upper(appSecret + concat + appSecret); } private static long ToUnix(DateTime dt) => new DateTimeOffset(dt).ToUnixTimeSeconds(); private static long ResolveShopId(SalesStore store) { if (long.TryParse(store.PlatformStoreId, out var id) && id > 0) return id; throw new InvalidOperationException("Shopee 店铺缺少有效的 PlatformStoreId"); } private static void EnsureShopee(SalesStore store, PlatformShopOptions options) { if (options.Shopee.PartnerId <= 0 || string.IsNullOrWhiteSpace(options.Shopee.PartnerKey)) { throw new InvalidOperationException("未配置 Shopee PartnerId/PartnerKey"); } if (string.IsNullOrWhiteSpace(store.AccessToken)) { throw new InvalidOperationException("店铺未授权或 AccessToken 为空"); } } private static void EnsureLazada(SalesStore store, PlatformShopOptions options) { if (string.IsNullOrWhiteSpace(options.Lazada.AppKey)) { throw new InvalidOperationException("未配置 Lazada AppKey"); } if (string.IsNullOrWhiteSpace(store.AccessToken)) { throw new InvalidOperationException("店铺未授权或 AccessToken 为空"); } } private static void EnsureTiktok(SalesStore store, PlatformShopOptions options) { if (string.IsNullOrWhiteSpace(options.Tiktok.AppKey)) { throw new InvalidOperationException("未配置 TikTok AppKey"); } if (string.IsNullOrWhiteSpace(store.AccessToken)) { throw new InvalidOperationException("店铺未授权或 AccessToken 为空"); } } private static void EnsureTemu(SalesStore store, PlatformShopOptions options) { if (string.IsNullOrWhiteSpace(options.Temu.AppKey)) { throw new InvalidOperationException("未配置 Temu AppKey"); } if (string.IsNullOrWhiteSpace(store.AccessToken)) { throw new InvalidOperationException("店铺未授权或 AccessToken 为空"); } } 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 }