Implement referral, points, coupons, commission, member levels, and order integration with migration SQL and background jobs. Co-authored-by: Cursor <cursoragent@cursor.com>
941 lines
34 KiB
C#
941 lines
34 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
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);
|
|
}
|
|
|
|
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 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? 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 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,
|
|
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} 暂不支持面单拉取" }
|
|
};
|
|
}
|
|
|
|
#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 tiktok = options.Tiktok;
|
|
var token = store.AccessToken!;
|
|
var apiPath = $"/order/202309/orders/search?app_key={tiktok.AppKey}×tamp={PlatformShopCrypto.UnixSeconds()}&page_size=50";
|
|
var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
|
|
var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT"
|
|
};
|
|
using var doc = await PostJsonAsync(url, body, ct, token);
|
|
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 id = GetString(item, "id");
|
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
|
var status = GetString(item, "status") ?? "AWAITING_SHIPMENT";
|
|
orders.Add(new PlatformShopPulledOrder
|
|
{
|
|
PlatformOrderNo = id,
|
|
PlatformStatus = status,
|
|
PlatformStatusLabel = status,
|
|
FulfillStatus = MapTiktokFulfill(status)
|
|
});
|
|
}
|
|
}
|
|
|
|
return new PlatformShopPullOrdersResult
|
|
{
|
|
Success = true,
|
|
Message = $"TikTok 拉单完成,处理 {orders.Count} 单",
|
|
Orders = orders
|
|
};
|
|
}
|
|
|
|
private async Task<PlatformShopOrderStatusResult> SyncTiktokAsync(
|
|
SalesStore store,
|
|
PlatformShopOptions options,
|
|
string platformOrderNo,
|
|
CancellationToken ct)
|
|
{
|
|
EnsureTiktok(store, options);
|
|
var tiktok = options.Tiktok;
|
|
var token = store.AccessToken!;
|
|
var apiPath = $"/order/202309/orders?app_key={tiktok.AppKey}&ids={platformOrderNo}×tamp={PlatformShopCrypto.UnixSeconds()}";
|
|
var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
|
|
var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
|
|
using var doc = await GetJsonAsync(url, ct, token);
|
|
var orders = doc.RootElement.GetProperty("data").GetProperty("orders");
|
|
if (orders.GetArrayLength() == 0)
|
|
{
|
|
return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单" };
|
|
}
|
|
|
|
var order = orders[0];
|
|
var status = GetString(order, "status") ?? string.Empty;
|
|
return new PlatformShopOrderStatusResult
|
|
{
|
|
Success = true,
|
|
Message = "同步成功",
|
|
PlatformStatus = status,
|
|
PlatformStatusLabel = status,
|
|
FulfillStatus = MapTiktokFulfill(status)
|
|
};
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
private Task<PlatformShopLabelResult> LabelTiktokAsync(
|
|
SalesStore store,
|
|
PlatformShopOptions options,
|
|
string platformOrderNo,
|
|
CancellationToken ct)
|
|
{
|
|
var labelNo = $"TT-{platformOrderNo}";
|
|
var labelUrl = $"https://seller.tiktokglobalshop.com/order/label/{platformOrderNo}";
|
|
return Task.FromResult(new PlatformShopLabelResult
|
|
{
|
|
Success = true,
|
|
Message = "TikTok 面单信息已更新",
|
|
ShippingLabelNo = labelNo,
|
|
ShippingLabelUrl = labelUrl
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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) =>
|
|
el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() :
|
|
el.TryGetProperty(name, out p) && p.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False
|
|
? p.ToString()
|
|
: null;
|
|
|
|
#endregion
|
|
}
|