admin/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs
zzh 983dff12c5
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
feat: 店铺订单落库物流商与履约类型,并规范迁移脚本日期命名
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 18:01:46 +08:00

1718 lines
64 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<PlatformShopPullOrdersResult> PullOrdersAsync(
SalesStore store,
PlatformShopPullOrdersRequest request,
CancellationToken ct = default);
Task<PlatformShopOrderStatusResult> FetchOrderStatusAsync(
SalesStore store,
string platformOrderNo,
CancellationToken ct = default);
Task<PlatformShopReadyToShipResult> ReadyToShipAsync(
SalesStore store,
string platformOrderNo,
CancellationToken ct = default);
Task<PlatformShopLabelResult> FetchShippingLabelAsync(
SalesStore store,
string platformOrderNo,
CancellationToken ct = default);
/// <summary>
/// 拉取平台订单完整明细并映射为落库结构;不支持时返回 null。
/// </summary>
Task<PlatformShopPulledOrder?> FetchOrderDetailAsync(
SalesStore store,
string platformOrderNo,
CancellationToken ct = default);
}
public sealed class PlatformShopPullOrdersRequest
{
public List<string> 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<PlatformShopPulledOrder> 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<PlatformShopPulledOrderLine> 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<PlatformShopFulfillmentClient> logger) : IPlatformShopFulfillmentClient
{
private const string LabelPendingText = "系统获取中";
public async Task<PlatformShopPullOrdersResult> 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<PlatformShopOrderStatusResult> 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<PlatformShopReadyToShipResult> 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<PlatformShopLabelResult> 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<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(
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<string>();
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}&timestamp={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<PlatformShopPulledOrder>();
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<PlatformShopOrderStatusResult> 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}&timestamp={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<PlatformShopReadyToShipResult> 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}&timestamp={ts}";
var body = new Dictionary<string, object?>
{
["order_sn"] = platformOrderNo,
["dropoff"] = new Dictionary<string, object?>()
};
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<PlatformShopLabelResult> 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<PlatformShopPullOrdersResult> 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<string, string>(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<PlatformShopPulledOrder>();
if (doc.RootElement.TryGetProperty("data", out var data)
&& data.TryGetProperty("orders", out var arr)
&& arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
var 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<PlatformShopOrderStatusResult> SyncLazadaAsync(
SalesStore store,
PlatformShopOptions options,
string platformOrderNo,
CancellationToken ct)
{
EnsureLazada(store, options);
var lazada = options.Lazada;
var apiPath = "/order/get";
var parameters = new SortedDictionary<string, string>(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<PlatformShopReadyToShipResult> 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<PlatformShopLabelResult> 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<string, string> 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<PlatformShopPullOrdersResult> 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<string, PlatformShopPulledOrder>(StringComparer.Ordinal);
var errors = new List<string>();
foreach (var statusFilter in statuses)
{
var pageToken = "";
for (var page = 0; page < 40; page++)
{
using var doc = await PostTiktokOrderSearchAsync(
store, options, request, statusFilter, shopCipher, pageToken, ct);
var root = doc.RootElement;
var code = root.ValueKind == JsonValueKind.Object ? GetString(root, "code") : null;
if (root.ValueKind != JsonValueKind.Object
|| code is not ("0" or null)
|| !root.TryGetProperty("data", out var data)
|| data.ValueKind != JsonValueKind.Object)
{
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()
};
}
/// <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>();
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);
}
/// <summary>
/// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。
/// </summary>
private async Task<string?> GetTiktokShopCipherAsync(SalesStore store, PlatformShopOptions options, CancellationToken ct)
{
var tiktok = options.Tiktok;
const string path = "/authorization/202309/shops";
var query = new Dictionary<string, string>
{
["app_key"] = tiktok.AppKey,
["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
};
var sign = PlatformShopCrypto.TiktokSign(path, query, null, tiktok.AppSecret);
var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
using var doc = await GetJsonAsync(url, ct, store.AccessToken);
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("data", out var data)
|| data.ValueKind != JsonValueKind.Object
|| !data.TryGetProperty("shops", out var shops)
|| shops.ValueKind != JsonValueKind.Array)
{
var message = doc.RootElement.ValueKind == JsonValueKind.Object
? GetString(doc.RootElement, "message")
: null;
logger.LogWarning("TikTok shops 未返回 cipher:{Message}", message);
return null;
}
string? fallback = null;
foreach (var shop in shops.EnumerateArray())
{
var cipher = GetString(shop, "cipher");
if (string.IsNullOrWhiteSpace(cipher)) continue;
fallback ??= cipher;
var id = GetString(shop, "id") ?? GetString(shop, "shop_id");
if (!string.IsNullOrWhiteSpace(store.PlatformStoreId)
&& string.Equals(id, store.PlatformStoreId, StringComparison.Ordinal))
{
return cipher;
}
}
return fallback;
}
/// <summary>
/// 按单个 TikTok order_status 搜索一页订单。签名和字段对齐 WMS TiktokApi.getOrdersSearch。
/// </summary>
private async Task<JsonDocument> PostTiktokOrderSearchAsync(
SalesStore store,
PlatformShopOptions options,
PlatformShopPullOrdersRequest request,
string orderStatus,
string shopCipher,
string pageToken,
CancellationToken ct)
{
var tiktok = options.Tiktok;
const string path = "/order/202309/orders/search";
var query = new Dictionary<string, string>
{
["app_key"] = tiktok.AppKey,
["shop_cipher"] = shopCipher,
["page_size"] = "50",
["page_token"] = pageToken,
["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
};
var body = new Dictionary<string, object>
{
["order_status"] = orderStatus
};
if (request.TimeStart.HasValue)
{
body["create_time_ge"] = ToUnix(request.TimeStart.Value);
}
if (request.TimeEnd.HasValue)
{
body["create_time_lt"] = ToUnix(request.TimeEnd.Value.AddSeconds(1));
}
var bodyJson = JsonSerializer.Serialize(body);
var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, tiktok.AppSecret);
var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}";
return await PostRawJsonAsync(url, bodyJson, ct, store.AccessToken);
}
private static string BuildTiktokQuery(IReadOnlyDictionary<string, string> query, string sign)
{
var parts = query
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")
.ToList();
parts.Add($"sign={Uri.EscapeDataString(sign)}");
return string.Join("&", parts);
}
private static readonly string[] TiktokPullStatuses =
[
"UNPAID",
"ON_HOLD",
"PARTIALLY_SHIPPING",
"AWAITING_SHIPMENT",
"AWAITING_COLLECTION",
"IN_TRANSIT",
"DELIVERED",
"COMPLETED",
"CANCELLED"
];
private static List<string> NormalizeTiktokPullStatuses(IEnumerable<string>? selected)
{
var allowed = new HashSet<string>(TiktokPullStatuses, StringComparer.OrdinalIgnoreCase);
var normalized = (selected ?? [])
.Select(x => (x ?? string.Empty).Trim().ToUpperInvariant())
.Where(allowed.Contains)
.Distinct(StringComparer.Ordinal)
.ToList();
return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses];
}
private async Task<PlatformShopPulledOrder?> 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<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" };
}
return new PlatformShopOrderStatusResult
{
Success = true,
Message = "同步成功",
PlatformStatus = mapped.PlatformStatus,
PlatformStatusLabel = mapped.PlatformStatusLabel,
FulfillStatus = mapped.FulfillStatus,
ShippingLabelNo = mapped.ShippingLabelNo
};
}
/// <summary>
/// 对齐 WMS ShopordTiktokImpl.send:订单详情取 package_id → 校验 DropOff → shipPackage,再尝试拉面单。
/// </summary>
private async Task<PlatformShopReadyToShipResult> 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<string, string>
{
["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<string>();
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<string, string>
{
["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<string, string>
{
["app_key"] = tiktok.AppKey,
["shop_cipher"] = shopCipher,
["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString()
};
var shipBody = new Dictionary<string, object> { ["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<JsonDocument> PostTiktokSignedAsync(
PlatformShopOptions options,
string accessToken,
string path,
Dictionary<string, string> 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);
}
/// <summary>
/// 对齐 WMS ShopordTiktokImpl.retryOrderSheet:订单详情取 package_id,再取 SHIPPING_LABEL 并落库。
/// </summary>
private async Task<PlatformShopLabelResult> 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<string, string>
{
["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<string, string>
{
["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
};
}
/// <summary>
/// 下载 TikTok 面单 PDF 并写入对象存储。下载失败时退回平台临时地址。
/// </summary>
private async Task<string> 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<JsonDocument> GetTiktokSignedAsync(
PlatformShopOptions options,
string accessToken,
string path,
Dictionary<string, string> 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<PlatformShopPullOrdersResult> PullTemuAsync(
SalesStore store,
PlatformShopOptions options,
PlatformShopPullOrdersRequest request,
CancellationToken ct)
{
EnsureTemu(store, options);
var temu = options.Temu;
var body = new Dictionary<string, string>
{
["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<PlatformShopPulledOrder>();
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<PlatformShopOrderStatusResult> 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<PlatformShopLabelResult> 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<JsonDocument> 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<JsonDocument> 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);
}
/// <summary>
/// 发送已经参与签名的原始 JSON,避免二次序列化改掉请求体。
/// </summary>
private async Task<JsonDocument> PostRawJsonAsync(
string url,
string bodyJson,
CancellationToken ct,
string? accessToken = null)
{
var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(bodyJson, Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken);
}
using var response = await client.SendAsync(request, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("Platform POST {Url} => {Body}", url, raw);
return JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw);
}
private async Task<JsonDocument> PostFormAsync(
string url,
IDictionary<string, string> 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<JsonDocument> PostJsonFormAsync(string url, Dictionary<string, string> 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<string, string> 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
}