admin/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
zzh f063c99d1b feat: OpenAPI 增加轨迹/文件查询,创单按 clientNo 幂等
客户 HMAC 通过后再查本客户订单;禁止下游面单,附件增加 POD。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:48:21 +08:00

660 lines
21 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.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Fake.Application;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.OpenApi;
namespace Oms.Application.Integrations.SmallBagTrack;
internal sealed class SmallBagTrackOrderClient(
IHttpClientFactory httpClientFactory,
ISmallBagTrackConfigProvider configProvider,
IHttpContextAccessor httpContextAccessor,
ILogger<SmallBagTrackOrderClient> logger) : ISmallBagTrackOrderClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public const string HttpClientName = nameof(SmallBagTrackOrderClient);
public async Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default)
{
var result = await UpsertDebugAsync(request, cancellationToken);
if (result.Skipped)
{
if (!string.IsNullOrWhiteSpace(result.SkipReason))
{
logger.LogDebug("UpsertSmallBag skipped for order {OrderNo}: {Reason}", request.OrderNo, result.SkipReason);
}
return;
}
if (!result.Success)
{
logger.LogWarning(
"UpsertSmallBag failed for order {OrderNo}: {StatusCode} {Body}",
request.OrderNo,
result.StatusCode,
result.ResponseBody);
return;
}
logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo);
}
public Task AppendLifecycleTrackAsync(
string orderNo,
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default) =>
AppendLifecycleTrackResultAsync(
orderNo,
trackCode,
SmallBagTrackLifecycleEvents.OrderType,
authorization,
cancellationToken);
public async Task<SmallBagTrackUpsertResult> AppendLifecycleTrackResultAsync(
string orderNo,
string trackCode,
int orderType,
string? authorization = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(orderNo))
{
logger.LogWarning("Skip lifecycle track append: orderNo empty");
return Skipped("orderNo empty");
}
if (string.IsNullOrWhiteSpace(trackCode))
{
logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo);
return Skipped("trackCode empty");
}
var settings = await configProvider.GetAsync(cancellationToken);
if (!settings.Enabled)
{
return Skipped("TrackService disabled");
}
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
return Skipped("BaseUrl empty");
}
if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
{
return Skipped("AppKey or AppSecret not configured");
}
var appKey = settings.AppKey.Trim();
var auth = ResolveAuthorization(authorization);
var trackTime = DateTime.Now;
var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
appKey,
orderNo.Trim(),
trackCode.Trim(),
trackTime,
orderType);
var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl);
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
try
{
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
auth,
cancellationToken);
var result = new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.Response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
StatusCode = (int)response.Response.StatusCode,
ResponseBody = response.ResponseBody
};
if (result.Success)
{
logger.LogInformation(
"Lifecycle track {TrackCode} appended for order {OrderNo} (orderType={OrderType}) at {TrackPlace}",
trackCode,
orderNo,
orderType,
SmallBagTrackLifecycleEvents.TrackPlace);
}
else
{
logger.LogWarning(
"Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}",
trackCode,
orderNo,
result.StatusCode,
result.ResponseBody);
}
return result;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo);
return new SmallBagTrackUpsertResult
{
Skipped = false,
Success = false,
Url = url,
RequestJson = requestJson,
ResponseBody = ex.Message
};
}
}
public async Task<SmallBagTrackUpsertResult> UpsertDebugAsync(
SmallBagTrackUpsertRequest request,
CancellationToken cancellationToken = default)
{
var settings = await configProvider.GetAsync(cancellationToken);
if (!settings.Enabled)
{
return Skipped("TrackService disabled");
}
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
return Skipped("BaseUrl empty");
}
if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
{
return Skipped("AppKey or AppSecret not configured");
}
var appKey = settings.AppKey.Trim();
var body = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request);
var url = SmallBagTrackUpsertApiRequest.BuildEndpointUrl(settings.BaseUrl);
var authorization = ResolveAuthorization(request.Authorization);
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
authorization,
cancellationToken);
var curlCommand = SmallBagTrackCurlBuilder.Build(
url,
appKey,
response.Timestamp,
response.Sign,
authorization,
body,
JsonOptions);
var result = new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.Response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
CurlCommand = curlCommand,
StatusCode = (int)response.Response.StatusCode,
ResponseBody = response.ResponseBody
};
// 创单成功后按需追加默认轨迹(A0101);批量/调试路径也走这里,避免只建订单不建轨迹。
if (result.Success && request.AppendInitialTrackOnSuccess)
{
await AppendLifecycleTrackAsync(
request.OrderNo,
SmallBagTrackLifecycleEvents.CreateTrackCode,
authorization,
cancellationToken);
}
return result;
}
public async Task<SmallBagTrackUpsertResult> UpsertBigBagAsync(
SmallBagTrackUpsertBigBagRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.BagNo))
{
return Skipped("bagNo empty");
}
var settings = await configProvider.GetAsync(cancellationToken);
if (!settings.Enabled)
{
return Skipped("TrackService disabled");
}
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
return Skipped("BaseUrl empty");
}
if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
{
return Skipped("AppKey or AppSecret not configured");
}
var appKey = settings.AppKey.Trim();
var body = new SmallBagTrackUpsertBigBagApiRequest
{
Client = request.Client?.Trim() ?? string.Empty,
BagNo = request.BagNo.Trim(),
OtherNo = request.OtherNo?.Trim() ?? string.Empty,
Channel = request.Channel?.Trim() ?? string.Empty,
Product = request.Product?.Trim() ?? string.Empty,
Country = request.Country?.Trim() ?? string.Empty,
ApiCode = request.ApiCode?.Trim() ?? string.Empty
};
var url = SmallBagTrackUpsertBigBagApiRequest.BuildEndpointUrl(settings.BaseUrl);
var authorization = ResolveAuthorization(request.Authorization);
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
try
{
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
authorization,
cancellationToken);
var result = new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.Response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
StatusCode = (int)response.Response.StatusCode,
ResponseBody = response.ResponseBody
};
if (result.Success)
{
logger.LogInformation("UpsertBigBag succeeded for bag {BagNo}", body.BagNo);
}
else
{
logger.LogWarning(
"UpsertBigBag failed for bag {BagNo}: {StatusCode} {Body}",
body.BagNo,
result.StatusCode,
result.ResponseBody);
}
return result;
}
catch (Exception ex)
{
logger.LogWarning(ex, "UpsertBigBag failed for bag {BagNo}", request.BagNo);
return new SmallBagTrackUpsertResult
{
Skipped = false,
Success = false,
Url = url,
RequestJson = requestJson,
ResponseBody = ex.Message
};
}
}
public async Task<SmallBagTrackUpsertResult> LinkOrdersAsync(
string parentOrderNo,
int parentOrderType,
IReadOnlyList<string> linkOrderNos,
int linkOrderType,
string? authorization = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(parentOrderNo))
{
return Skipped("parentOrderNo empty");
}
var nos = (linkOrderNos ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Where(x => !string.Equals(x, parentOrderNo.Trim(), StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (nos.Count == 0)
{
return Skipped("linkOrderNos empty");
}
var settings = await configProvider.GetAsync(cancellationToken);
if (!settings.Enabled)
{
return Skipped("TrackService disabled");
}
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
return Skipped("BaseUrl empty");
}
if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
{
return Skipped("AppKey or AppSecret not configured");
}
var appKey = settings.AppKey.Trim();
var body = new SmallBagTrackLinkOrderApiRequest
{
AppKey = appKey,
IsLink = true,
OrderType = parentOrderType,
OrderNo = parentOrderNo.Trim(),
LinkAppKey = appKey,
LinkOrderType = linkOrderType,
LinkOrderNos = nos
};
var url = SmallBagTrackLinkOrderApiRequest.BuildEndpointUrl(settings.BaseUrl);
var auth = ResolveAuthorization(authorization);
var requestJson = JsonSerializer.Serialize(body, JsonOptions);
try
{
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
auth,
cancellationToken);
var result = new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.Response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
StatusCode = (int)response.Response.StatusCode,
ResponseBody = response.ResponseBody
};
if (result.Success)
{
logger.LogInformation(
"LinkOrders succeeded for parent {OrderNo} -> {Count} children",
body.OrderNo,
nos.Count);
}
else
{
logger.LogWarning(
"LinkOrders failed for parent {OrderNo}: {StatusCode} {Body}",
body.OrderNo,
result.StatusCode,
result.ResponseBody);
}
return result;
}
catch (Exception ex)
{
logger.LogWarning(ex, "LinkOrders failed for parent {OrderNo}", parentOrderNo);
return new SmallBagTrackUpsertResult
{
Skipped = false,
Success = false,
Url = url,
RequestJson = requestJson,
ResponseBody = ex.Message
};
}
}
public async Task<List<OpenApiTrackEventDto>> QuerySmallBagTracksAsync(
string orderNo,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(orderNo))
{
throw new BusinessException("服务单号为空,无法查询轨迹");
}
var settings = await configProvider.GetAsync(cancellationToken);
if (!settings.Enabled)
{
throw new BusinessException("轨迹服务未启用");
}
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
{
throw new BusinessException("轨迹服务未配置");
}
if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret))
{
throw new BusinessException("轨迹服务未配置");
}
var appKey = settings.AppKey.Trim();
var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/ProviderService/Track/Query";
var body = new
{
appKey,
orderType = SmallBagTrackLifecycleEvents.OrderType,
orderNos = new[] { orderNo.Trim() }
};
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
authorization: null,
cancellationToken);
if (!response.Response.IsSuccessStatusCode)
{
throw new BusinessException("轨迹查询失败");
}
return ParseTrackEvents(response.ResponseBody);
}
private static List<OpenApiTrackEventDto> ParseTrackEvents(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var arr = root;
if (root.ValueKind == JsonValueKind.Object
&& (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data)))
{
arr = data;
}
if (arr.ValueKind != JsonValueKind.Array)
{
return [];
}
var events = new List<OpenApiTrackEventDto>();
foreach (var orderEl in arr.EnumerateArray())
{
if (orderEl.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!orderEl.TryGetProperty("trackDetails", out var details)
&& !orderEl.TryGetProperty("TrackDetails", out details))
{
continue;
}
if (details.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var el in details.EnumerateArray())
{
var content = ReadJsonString(el, "content", "Content");
if (string.IsNullOrWhiteSpace(content) || content == "--")
{
continue;
}
events.Add(new OpenApiTrackEventDto
{
TrackCode = ReadJsonString(el, "code", "Code"),
Status = ReadJsonString(el, "status", "Status"),
Content = content,
EnContent = ReadJsonString(el, "enContent", "EnContent"),
Location = ReadJsonString(el, "location", "Location"),
Time = ReadJsonTime(el),
Files = ReadJsonFiles(el)
});
}
}
return events;
}
private static List<OpenApiTrackFileDto> ReadJsonFiles(JsonElement el)
{
if (!el.TryGetProperty("files", out var files) && !el.TryGetProperty("Files", out files))
{
return [];
}
if (files.ValueKind != JsonValueKind.Array)
{
return [];
}
var list = new List<OpenApiTrackFileDto>();
foreach (var f in files.EnumerateArray())
{
list.Add(new OpenApiTrackFileDto
{
FileName = ReadJsonString(f, "fileName", "FileName"),
Url = ReadJsonString(f, "downloadUrl", "DownloadUrl", "url", "Url")
});
}
return list;
}
private static DateTime? ReadJsonTime(JsonElement el)
{
if (!el.TryGetProperty("time", out var time) && !el.TryGetProperty("Time", out time))
{
return null;
}
if (time.ValueKind == JsonValueKind.String && time.TryGetDateTime(out var dt))
{
return dt;
}
if (time.ValueKind == JsonValueKind.Number && time.TryGetInt64(out var unix))
{
return DateTimeOffset.FromUnixTimeSeconds(unix > 10_000_000_000 ? unix / 1000 : unix).UtcDateTime;
}
return null;
}
private static string? ReadJsonString(JsonElement el, params string[] names)
{
foreach (var name in names)
{
if (el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String)
{
return p.GetString();
}
}
return null;
}
private async Task<SignedTrackHttpResponse> SendSignedPostAsync<TBody>(
SmallBagTrackOptions settings,
string appKey,
string url,
TBody body,
string? authorization,
CancellationToken cancellationToken)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var sign = SmallBagTrackSignHelper.GenerateSign(appKey, timestamp, settings.AppSecret.Trim());
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
httpRequest.Headers.TryAddWithoutValidation("appKey", appKey);
httpRequest.Headers.TryAddWithoutValidation("timestamp", timestamp.ToString());
httpRequest.Headers.TryAddWithoutValidation("sign", sign);
ApplyAuthorization(httpRequest, authorization);
httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
var client = httpClientFactory.CreateClient(HttpClientName);
client.Timeout = TimeSpan.FromSeconds(Math.Max(5, settings.TimeoutSeconds));
using var response = await client.SendAsync(httpRequest, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
return new SignedTrackHttpResponse(response, responseBody, timestamp, sign);
}
private string? ResolveAuthorization(string? authorization)
{
if (!string.IsNullOrWhiteSpace(authorization))
{
return authorization.Trim();
}
return httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
}
private static void ApplyAuthorization(HttpRequestMessage httpRequest, string? authorization)
{
if (string.IsNullOrWhiteSpace(authorization))
{
return;
}
httpRequest.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization);
}
private static SmallBagTrackUpsertResult Skipped(string reason) =>
new()
{
Skipped = true,
SkipReason = reason
};
private sealed record SignedTrackHttpResponse(
HttpResponseMessage Response,
string ResponseBody,
long Timestamp,
string Sign);
}