From eac7b84f6a78522b52dbcd9e3ff1ccc3a42114fa Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 29 Jun 2026 12:29:06 +0800 Subject: [PATCH] feat(order): downstream createOrderOne API from order DB Co-authored-by: Cursor --- .../Dtos/Orders/OrderDownstreamForwardDtos.cs | 35 ++++ .../Integrations/Itfx/IItfxForwardClient.cs | 24 +++ .../Integrations/Itfx/IItfxMetadataClient.cs | 2 + .../Integrations/Itfx/ItfxForwardClient.cs | 72 +++++++ .../Integrations/Itfx/ItfxMetadataClient.cs | 14 +- .../Itfx/ItfxServiceExtensions.cs | 2 + .../Orders/OrderService.DownstreamForward.cs | 85 ++++++++ .../OrderDownstreamCreateOneBodyBuilder.cs | 183 ++++++++++++++++++ .../OrderDownstreamCredentialResolver.cs | 98 ++++++++++ 9 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs create mode 100644 src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs create mode 100644 src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs create mode 100644 src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs create mode 100644 src/Oms.Application/Shared/OrderDownstreamCredentialResolver.cs diff --git a/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs b/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs new file mode 100644 index 0000000..ecd872d --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs @@ -0,0 +1,35 @@ +using Oms.Application.Dtos.Orders; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 下游 createOrderOne 请求体预览(由订单表组装,不含 AppSecret)。 +/// +public class OrderDownstreamCreateOneBodyOutput +{ + public int OrderId { get; set; } + + public string? ServiceNo { get; set; } + + public string SupplierCode { get; set; } = string.Empty; + + public OrderParcelCreateInput Body { get; set; } = new(); +} + +/// +/// 下游 createOrderOne 转发结果。 +/// +public class OrderDownstreamForwardResult +{ + public bool Success { get; set; } + + public string SupplierCode { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string RequestJson { get; set; } = string.Empty; + + public int? StatusCode { get; set; } + + public string ResponseBody { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs b/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs new file mode 100644 index 0000000..c5a5ce8 --- /dev/null +++ b/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs @@ -0,0 +1,24 @@ +namespace Oms.Application.Integrations.Itfx; + +public sealed class ItfxForwardResult +{ + public bool Success { get; set; } + + public string Url { get; set; } = string.Empty; + + public string RequestJson { get; set; } = string.Empty; + + public int? StatusCode { get; set; } + + public string ResponseBody { get; set; } = string.Empty; +} + +public interface IItfxForwardClient +{ + Task ForwardCreateOrderOneAsync( + string supplierCode, + string appKey, + string appSecret, + object body, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Application/Integrations/Itfx/IItfxMetadataClient.cs b/src/Oms.Application/Integrations/Itfx/IItfxMetadataClient.cs index c960b1f..427b03a 100644 --- a/src/Oms.Application/Integrations/Itfx/IItfxMetadataClient.cs +++ b/src/Oms.Application/Integrations/Itfx/IItfxMetadataClient.cs @@ -3,6 +3,8 @@ namespace Oms.Application.Integrations.Itfx; public sealed class ItfxAppMetadata { public string AppKey { get; set; } = string.Empty; + + public string AppSecret { get; set; } = string.Empty; } public sealed class ItfxServiceProviderMetadata diff --git a/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs b/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs new file mode 100644 index 0000000..196f874 --- /dev/null +++ b/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Fake.DependencyInjection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Oms.Application.Integrations.Itfx; + +internal sealed class ItfxForwardClient( + IHttpClientFactory httpClientFactory, + IOptions options, + IHttpContextAccessor httpContextAccessor) : IItfxForwardClient, IScopedDependency +{ + public const string HttpClientName = nameof(ItfxForwardClient); + public const string CreateOrderOneStandardCode = "createOrderOne"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public async Task ForwardCreateOrderOneAsync( + string supplierCode, + string appKey, + string appSecret, + object body, + CancellationToken cancellationToken = default) + { + var settings = options.Value; + if (string.IsNullOrWhiteSpace(settings.BaseUrl)) + { + throw new InvalidOperationException("Itfx BaseUrl not configured"); + } + + var root = $"{settings.BaseUrl.Trim().TrimEnd('/')}/InterfaceForward/ForwardWithSubscribe"; + var url = + $"{root}?standardCode={CreateOrderOneStandardCode}&supplierCode={Uri.EscapeDataString(supplierCode)}"; + var requestJson = JsonSerializer.Serialize(body, JsonOptions); + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.TryAddWithoutValidation("appkey", appKey); + request.Headers.TryAddWithoutValidation("appSecret", appSecret); + request.Headers.TryAddWithoutValidation("followName", "0"); + ForwardAuthorization(request); + request.Content = JsonContent.Create(body, options: JsonOptions); + + var client = httpClientFactory.CreateClient(HttpClientName); + client.Timeout = TimeSpan.FromSeconds(Math.Max(30, settings.TimeoutSeconds)); + + using var response = await client.SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + return new ItfxForwardResult + { + Success = response.IsSuccessStatusCode, + Url = url, + RequestJson = requestJson, + StatusCode = (int)response.StatusCode, + ResponseBody = responseBody + }; + } + + private void ForwardAuthorization(HttpRequestMessage request) + { + var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + if (!string.IsNullOrWhiteSpace(authorization)) + { + request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization); + } + } +} diff --git a/src/Oms.Application/Integrations/Itfx/ItfxMetadataClient.cs b/src/Oms.Application/Integrations/Itfx/ItfxMetadataClient.cs index 79aaf7b..36b0b83 100644 --- a/src/Oms.Application/Integrations/Itfx/ItfxMetadataClient.cs +++ b/src/Oms.Application/Integrations/Itfx/ItfxMetadataClient.cs @@ -24,9 +24,17 @@ internal sealed class ItfxMetadataClient( GetAsync($"/App/GetInfo?id={appId}", payload => { var appKey = ReadString(payload, "appKey", "AppKey"); - return string.IsNullOrWhiteSpace(appKey) - ? null - : new ItfxAppMetadata { AppKey = appKey.Trim() }; + var appSecret = ReadString(payload, "appSecret", "AppSecret", "secret", "Secret"); + if (string.IsNullOrWhiteSpace(appKey)) + { + return null; + } + + return new ItfxAppMetadata + { + AppKey = appKey.Trim(), + AppSecret = appSecret?.Trim() ?? string.Empty + }; }, cancellationToken); public Task GetServiceProviderAsync( diff --git a/src/Oms.Application/Integrations/Itfx/ItfxServiceExtensions.cs b/src/Oms.Application/Integrations/Itfx/ItfxServiceExtensions.cs index f326f3d..ffe6558 100644 --- a/src/Oms.Application/Integrations/Itfx/ItfxServiceExtensions.cs +++ b/src/Oms.Application/Integrations/Itfx/ItfxServiceExtensions.cs @@ -9,7 +9,9 @@ public static class ItfxServiceExtensions { services.Configure(configuration.GetSection(ItfxGatewayOptions.SectionName)); services.AddHttpClient(ItfxMetadataClient.HttpClientName); + services.AddHttpClient(ItfxForwardClient.HttpClientName); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs new file mode 100644 index 0000000..ed1d862 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs @@ -0,0 +1,85 @@ +using Fake.Application; +using Fake.Domain.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Integrations.Itfx; +using Oms.Application.Shared; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private OrderDownstreamCredentialResolver OrderDownstreamCredentialResolver => + LazyServiceProvider.GetRequiredService(); + + private IItfxForwardClient ItfxForwardClient => + LazyServiceProvider.GetRequiredService(); + + /// + /// 按订单 Id 从数据库组装 createOrderOne 请求体(预览,不调用下游)。 + /// + [HttpGet("/order/downstream-create-one-body")] + public async Task GetDownstreamCreateOneBodyAsync([FromQuery] int id) + { + var (order, body) = await LoadDownstreamCreateOneContextAsync(id); + var creds = await OrderDownstreamCredentialResolver.ResolveAsync(order); + return new OrderDownstreamCreateOneBodyOutput + { + OrderId = order.Id, + ServiceNo = order.ServiceNo, + SupplierCode = creds.SupplierCode, + Body = body + }; + } + + /// + /// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。 + /// + [HttpPost("/order/downstream-forward-create-one")] + public async Task DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input) + { + var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id); + if (string.IsNullOrWhiteSpace(order.ServiceNo)) + { + throw new BusinessException("订单运单号为空,无法预报下游"); + } + + var creds = await OrderDownstreamCredentialResolver.ResolveAsync(order); + var forward = await ItfxForwardClient.ForwardCreateOrderOneAsync( + creds.SupplierCode, + creds.AppKey, + creds.AppSecret, + body); + + return new OrderDownstreamForwardResult + { + Success = forward.Success, + SupplierCode = creds.SupplierCode, + Url = forward.Url, + RequestJson = forward.RequestJson, + StatusCode = forward.StatusCode, + ResponseBody = forward.ResponseBody + }; + } + + private async Task<(Domain.OrdersAggregate.Entities.Order order, OrderParcelCreateInput body)> LoadDownstreamCreateOneContextAsync( + int orderId) + { + var order = await GetOrderOrThrowAsync(orderId); + var orderIds = new[] { order.Id }; + var senders = await OrderRepository.GetSendersByOrderIdsAsync(orderIds); + var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync(orderIds); + var declarations = await OrderRepository.GetDeclarationsByOrderIdsAsync(orderIds); + var weights = await OrderRepository.GetWeightsByOrderIdsAsync(orderIds); + + var body = await OrderDownstreamCreateOneBodyBuilder.BuildAsync( + order, + senders.FirstOrDefault(), + recipients.FirstOrDefault(), + declarations, + weights, + CountryRepository); + + return (order, body); + } +} diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs new file mode 100644 index 0000000..1a29df2 --- /dev/null +++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs @@ -0,0 +1,183 @@ +using Oms.Application.Dtos.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// 从订单表及关联子表组装 createOrderOne 标准请求体。 +/// +public static class OrderDownstreamCreateOneBodyBuilder +{ + public static async Task BuildAsync( + Order order, + OrderSender? sender, + OrderRecipient? recipient, + IReadOnlyList declarations, + IReadOnlyList weights, + ICountryRepository countryRepository, + CancellationToken cancellationToken = default) + { + var countryIds = new List(); + if (sender?.Country is > 0) + { + countryIds.Add(sender.Country.Value); + } + + if (recipient?.Country is > 0) + { + countryIds.Add(recipient.Country.Value); + } + + var countries = countryIds.Count == 0 + ? [] + : await countryRepository.GetListAsync(x => countryIds.Contains(x.Id)); + var idToCode = countries.ToDictionary(x => x.Id, x => x.CountryCode); + + var forecast = weights.FirstOrDefault(x => + string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)) + ?? weights.FirstOrDefault(); + + return new OrderParcelCreateInput + { + RequestId = order.RequestId, + ClientNo = order.ClientNo, + TrackNo = order.TrackNo, + ServiceNo = order.ServiceNo, + ProductCode = order.ProductCode, + ChannelCode = order.ChannelCode, + CargoAttribute = order.CargoAttribute, + ExportCustomsMode = order.ExportCustomsMode, + WarehouseId = order.WarehouseId, + CustomerId = order.CustomerId, + Length = forecast?.Length, + Width = forecast?.Width, + Height = forecast?.Height, + ActualWeight = forecast?.ActualWeight, + VolumetricWeight = forecast?.VolumetricWeight, + ChargeableWeight = forecast?.ChargeableWeight, + Cubic = forecast?.Cubic, + IsCod = order.IsCod > 0, + CodCurrency = order.CodCurrency, + CodSum = order.CodSum, + DeclaredCurrency = order.DeclaredCurrency, + DeclaredSum = order.DeclaredSum, + Quantity = order.Quantity, + Receiver = MapReceiver(recipient, order.ConsigneeCountry, idToCode), + Sender = MapSender(sender, idToCode), + Items = declarations.Select(MapItem).ToList() + }; + } + + private static string? ResolveCountryIso(int? countryId, string? orderConsigneeCountry, IReadOnlyDictionary idToCode) + { + if (!string.IsNullOrWhiteSpace(orderConsigneeCountry)) + { + var trimmed = orderConsigneeCountry.Trim(); + if (trimmed.Length == 2) + { + return trimmed.ToUpperInvariant(); + } + } + + if (countryId is > 0 && idToCode.TryGetValue(countryId.Value, out var code) && !string.IsNullOrWhiteSpace(code)) + { + return code.Trim().ToUpperInvariant(); + } + + return null; + } + + private static OrderParcelReceiverInput? MapReceiver( + OrderRecipient? recipient, + string? orderConsigneeCountry, + IReadOnlyDictionary idToCode) + { + if (recipient == null) + { + return string.IsNullOrWhiteSpace(orderConsigneeCountry) + ? null + : new OrderParcelReceiverInput { CountryCode = orderConsigneeCountry.Trim().ToUpperInvariant() }; + } + + return new OrderParcelReceiverInput + { + RecipientsName = recipient.RecipientsName, + CompanyName = recipient.CompanyName, + Phone = recipient.Phone, + TelePhone = recipient.TelePhone, + Email = recipient.Email, + CountryCode = ResolveCountryIso(recipient.Country, orderConsigneeCountry, idToCode), + Province = recipient.Province, + City = recipient.City, + StreetNumber = recipient.StreetNumber, + StreetAddress1 = recipient.StreetAddress1, + StreetAddress2 = recipient.StreetAddress2, + StreetAddress3 = recipient.StreetAddress3, + ZipCode = recipient.ZipCode, + TaxType = recipient.TaxType, + TaxNo = recipient.TaxNo, + CertificateType = recipient.CertificateType, + CertificateNo = recipient.CertificateNo, + Eori = recipient.Eori, + Vat = recipient.Vat + }; + } + + private static OrderParcelSenderInput? MapSender(OrderSender? sender, IReadOnlyDictionary idToCode) + { + if (sender == null) + { + return null; + } + + return new OrderParcelSenderInput + { + SenderName = sender.SenderName, + Company = sender.Company, + Phone = sender.Phone, + TelePhone = sender.TelePhone, + Email = sender.Email, + CountryCode = ResolveCountryIso(sender.Country, null, idToCode), + Province = sender.Province, + City = sender.City, + ZipCode = sender.ZipCode, + StreetAddress1 = sender.StreetAddress1, + StreetAddress2 = sender.StreetAddress2, + StreetAddress3 = sender.StreetAddress3, + HouseNumber = sender.HouseNumber + }; + } + + private static OrderParcelItemInput MapItem(OrderDeclaration x) => + new() + { + CnName = x.CnName, + EnName = x.EnName, + Sku = x.Sku, + Unit = x.Unit, + UnitPrice = x.UnitPrice, + UnitWeight = x.UnitWeight, + Quantity = x.Quantity, + Amount = x.Amount, + Weight = x.Weight, + ImHsCode = x.ImHsCode, + ExHsCode = x.ExHsCode ?? x.HsCode, + SkuImage = x.SkuImage, + Category = x.Category, + Brand = x.Brand, + BrandType = x.BrandType, + IsMagnetic = x.IsMagnetic > 0, + Distribution = x.Distribution, + Material = x.Material, + EnMaterial = x.EnMaterial, + Purpose = x.Purpose, + Attributes = x.Attributes, + OriginCountry = x.OriginCountry, + SalesLink = x.SalesLink, + Model = x.Model, + Specifications = x.Specifications, + Comment = x.Comment + }; +} diff --git a/src/Oms.Application/Shared/OrderDownstreamCredentialResolver.cs b/src/Oms.Application/Shared/OrderDownstreamCredentialResolver.cs new file mode 100644 index 0000000..f665651 --- /dev/null +++ b/src/Oms.Application/Shared/OrderDownstreamCredentialResolver.cs @@ -0,0 +1,98 @@ +using Fake.DependencyInjection; +using Fake.Application; +using Oms.Application.Integrations.Itfx; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Shared; + +public sealed class OrderDownstreamCredentials +{ + public string SupplierCode { get; set; } = string.Empty; + + public string AppKey { get; set; } = string.Empty; + + public string AppSecret { get; set; } = string.Empty; +} + +/// +/// 解析订单下游 itfx 凭证(优先订单绑定字段,回退渠道分组)。 +/// +public class OrderDownstreamCredentialResolver( + IServiceChannelRepository serviceChannelRepository, + IServiceChannelGroupRepository serviceChannelGroupRepository, + IItfxMetadataClient itfxMetadataClient) : IScopedDependency +{ + public async Task ResolveAsync(Order order, CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(order.DownstreamSupplierCode) + && !string.IsNullOrWhiteSpace(order.DownstreamAppKey) + && order.DownstreamAppId is > 0) + { + var app = await itfxMetadataClient.GetAppAsync(order.DownstreamAppId.Value, cancellationToken); + var appSecret = app?.AppSecret?.Trim(); + if (string.IsNullOrWhiteSpace(appSecret)) + { + throw new BusinessException("应用 AppSecret 未配置"); + } + + return new OrderDownstreamCredentials + { + SupplierCode = order.DownstreamSupplierCode.Trim(), + AppKey = order.DownstreamAppKey.Trim(), + AppSecret = appSecret + }; + } + + if (string.IsNullOrWhiteSpace(order.ChannelCode)) + { + throw new BusinessException("订单未绑定服务渠道,无法预报下游"); + } + + var channel = await serviceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim(), cancellationToken); + if (channel == null) + { + throw new BusinessException($"未找到渠道「{order.ChannelCode}」"); + } + + if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0) + { + throw new BusinessException($"渠道「{order.ChannelCode}」未关联服务商分组"); + } + + var group = await serviceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value, cancellationToken); + if (group == null) + { + throw new BusinessException("服务商分组不存在或已删除"); + } + + if (group.AppId <= 0) + { + throw new BusinessException($"服务商「{group.Name}」未配置应用"); + } + + if (group.ApiServiceProviderId <= 0) + { + throw new BusinessException($"服务商「{group.Name}」未配置 API 服务商"); + } + + var appInfo = await itfxMetadataClient.GetAppAsync(group.AppId, cancellationToken); + if (appInfo == null || string.IsNullOrWhiteSpace(appInfo.AppKey) || string.IsNullOrWhiteSpace(appInfo.AppSecret)) + { + throw new BusinessException("应用 AppKey / AppSecret 未配置"); + } + + var sp = await itfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId, cancellationToken); + if (sp == null || string.IsNullOrWhiteSpace(sp.Code)) + { + throw new BusinessException("API 服务商编码(supplierCode)未配置"); + } + + return new OrderDownstreamCredentials + { + SupplierCode = sp.Code.Trim(), + AppKey = appInfo.AppKey.Trim(), + AppSecret = appInfo.AppSecret.Trim() + }; + } +}