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()
+ };
+ }
+}