All checks were successful
Build and Deploy / deploy (push) Successful in 59s
Co-authored-by: Cursor <cursoragent@cursor.com>
1583 lines
58 KiB
C#
1583 lines
58 KiB
C#
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 bool IsMultiLabel { 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}×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<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}×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<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}×tamp={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>();
|
||
var packageIds = new HashSet<string>(StringComparer.Ordinal);
|
||
if (order.TryGetProperty("line_items", out var lineItems) && lineItems.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var line in lineItems.EnumerateArray())
|
||
{
|
||
var packageId = GetString(line, "package_id");
|
||
if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId);
|
||
lines.Add(new PlatformShopPulledOrderLine
|
||
{
|
||
PlatformLineId = GetString(line, "id"),
|
||
PlatformSku = GetString(line, "seller_sku"),
|
||
ProductName = GetString(line, "product_name"),
|
||
ProductImageUrl = GetString(line, "sku_image"),
|
||
VariantText = GetString(line, "sku_name"),
|
||
// TikTok 一行一件,没有 quantity 字段
|
||
Quantity = 1,
|
||
UnitPrice = GetDecimal(line, "sale_price") ?? GetDecimal(line, "original_price"),
|
||
Currency = GetString(line, "currency")
|
||
});
|
||
}
|
||
}
|
||
|
||
if (order.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var pkg in packages.EnumerateArray())
|
||
{
|
||
var packageId = GetString(pkg, "id");
|
||
if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId);
|
||
}
|
||
}
|
||
|
||
var tracking = GetString(order, "tracking_number");
|
||
if (string.IsNullOrWhiteSpace(tracking)
|
||
&& order.TryGetProperty("line_items", out var itemsForTrack)
|
||
&& itemsForTrack.ValueKind == JsonValueKind.Array
|
||
&& itemsForTrack.GetArrayLength() > 0)
|
||
{
|
||
tracking = GetString(itemsForTrack[0], "tracking_number");
|
||
}
|
||
|
||
return new PlatformShopPulledOrder
|
||
{
|
||
PlatformOrderNo = id,
|
||
OrderCountry = country,
|
||
RecipientName = FirstNonEmpty(GetString(recipient, "name"), GetString(recipient, "full_address")),
|
||
RecipientPhone = GetString(recipient, "phone_number"),
|
||
RecipientEmail = GetString(order, "buyer_email"),
|
||
RecipientCountry = country,
|
||
RecipientProvince = province,
|
||
RecipientCity = city,
|
||
RecipientZipCode = GetString(recipient, "postal_code"),
|
||
RecipientAddress1 = FirstNonEmpty(
|
||
GetString(recipient, "address_line1"),
|
||
GetString(recipient, "address_detail"),
|
||
GetString(recipient, "full_address")),
|
||
RecipientAddress2 = GetString(recipient, "address_line2"),
|
||
ItemCount = lines.Count,
|
||
TotalAmount = GetDecimal(payment, "total_amount") ?? GetDecimal(payment, "sub_total"),
|
||
Currency = FirstNonEmpty(GetString(payment, "currency"), lines.FirstOrDefault()?.Currency),
|
||
PlatformOrderTime = FromUnixSeconds(GetLong(order, "create_time")),
|
||
PlatformStatus = status,
|
||
PlatformStatusLabel = status,
|
||
FulfillStatus = MapTiktokFulfill(status),
|
||
ShippingLabelNo = tracking,
|
||
IsMultiLabel = packageIds.Count > 1,
|
||
ExtraJson = order.ValueKind == JsonValueKind.Undefined ? null : order.GetRawText(),
|
||
Lines = lines
|
||
};
|
||
}
|
||
|
||
private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrict(JsonElement recipient)
|
||
{
|
||
string? province = null;
|
||
string? city = null;
|
||
string? country = null;
|
||
if (recipient.ValueKind != JsonValueKind.Object
|
||
|| !recipient.TryGetProperty("district_info", out var districts)
|
||
|| districts.ValueKind != JsonValueKind.Array)
|
||
{
|
||
return (null, null, null);
|
||
}
|
||
|
||
foreach (var district in districts.EnumerateArray())
|
||
{
|
||
var level = GetString(district, "address_level");
|
||
var name = GetString(district, "address_name");
|
||
var iso = GetString(district, "iso_code");
|
||
switch (level)
|
||
{
|
||
case "L0":
|
||
country = FirstNonEmpty(iso, name);
|
||
break;
|
||
case "L1":
|
||
province = name;
|
||
break;
|
||
case "L3":
|
||
city = name;
|
||
break;
|
||
case "L2" when string.IsNullOrWhiteSpace(city):
|
||
city = name;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return (province, city, country);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。
|
||
/// </summary>
|
||
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
|
||
};
|
||
}
|
||
|
||
private async Task<PlatformShopReadyToShipResult> RtsTiktokAsync(
|
||
SalesStore store,
|
||
PlatformShopOptions options,
|
||
string platformOrderNo,
|
||
CancellationToken ct)
|
||
{
|
||
var sync = await SyncTiktokAsync(store, options, platformOrderNo, ct);
|
||
if (!sync.Success)
|
||
{
|
||
return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message };
|
||
}
|
||
|
||
if (!string.Equals(sync.PlatformStatus, "AWAITING_SHIPMENT", StringComparison.OrdinalIgnoreCase)
|
||
&& sync.PlatformStatus != "111")
|
||
{
|
||
return new PlatformShopReadyToShipResult
|
||
{
|
||
Success = false,
|
||
Message = $"TikTok 订单状态为 {sync.PlatformStatus},不可 RTS"
|
||
};
|
||
}
|
||
|
||
return new PlatformShopReadyToShipResult
|
||
{
|
||
Success = true,
|
||
Message = "TikTok RTS 已提交,面单获取中",
|
||
PlatformStatus = "AWAITING_COLLECTION",
|
||
PlatformStatusLabel = "AWAITING_COLLECTION",
|
||
FulfillStatus = "toShip",
|
||
ShippingLabelUrl = LabelPendingText,
|
||
LabelPending = true
|
||
};
|
||
}
|
||
|
||
/// <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
|
||
}
|