660 lines
21 KiB
C#
660 lines
21 KiB
C#
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);
|
||
}
|