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 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 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 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 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 LinkOrdersAsync( string parentOrderNo, int parentOrderType, IReadOnlyList 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> 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 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(); 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 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(); 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 SendSignedPostAsync( 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); }