feat(order): downstream createOrderOne API from order DB

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-29 12:29:06 +08:00
parent e642f089ba
commit eac7b84f6a
9 changed files with 512 additions and 3 deletions

View File

@ -0,0 +1,35 @@
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 下游 createOrderOne 请求体预览(由订单表组装,不含 AppSecret)。
/// </summary>
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();
}
/// <summary>
/// 下游 createOrderOne 转发结果。
/// </summary>
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;
}

View File

@ -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<ItfxForwardResult> ForwardCreateOrderOneAsync(
string supplierCode,
string appKey,
string appSecret,
object body,
CancellationToken cancellationToken = default);
}

View File

@ -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

View File

@ -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<ItfxGatewayOptions> 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<ItfxForwardResult> 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);
}
}
}

View File

@ -24,9 +24,17 @@ internal sealed class ItfxMetadataClient(
GetAsync<ItfxAppMetadata>($"/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<ItfxServiceProviderMetadata?> GetServiceProviderAsync(

View File

@ -9,7 +9,9 @@ public static class ItfxServiceExtensions
{
services.Configure<ItfxGatewayOptions>(configuration.GetSection(ItfxGatewayOptions.SectionName));
services.AddHttpClient(ItfxMetadataClient.HttpClientName);
services.AddHttpClient(ItfxForwardClient.HttpClientName);
services.AddScoped<IItfxMetadataClient, ItfxMetadataClient>();
services.AddScoped<IItfxForwardClient, ItfxForwardClient>();
return services;
}
}

View File

@ -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<OrderDownstreamCredentialResolver>();
private IItfxForwardClient ItfxForwardClient =>
LazyServiceProvider.GetRequiredService<IItfxForwardClient>();
/// <summary>
/// 按订单 Id 从数据库组装 createOrderOne 请求体(预览,不调用下游)。
/// </summary>
[HttpGet("/order/downstream-create-one-body")]
public async Task<OrderDownstreamCreateOneBodyOutput> 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
};
}
/// <summary>
/// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。
/// </summary>
[HttpPost("/order/downstream-forward-create-one")]
public async Task<OrderDownstreamForwardResult> 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);
}
}

View File

@ -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;
/// <summary>
/// 从订单表及关联子表组装 createOrderOne 标准请求体。
/// </summary>
public static class OrderDownstreamCreateOneBodyBuilder
{
public static async Task<OrderParcelCreateInput> BuildAsync(
Order order,
OrderSender? sender,
OrderRecipient? recipient,
IReadOnlyList<OrderDeclaration> declarations,
IReadOnlyList<OrderWeight> weights,
ICountryRepository countryRepository,
CancellationToken cancellationToken = default)
{
var countryIds = new List<int>();
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<int, string> 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<int, string> 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<int, string> 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
};
}

View File

@ -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;
}
/// <summary>
/// 解析订单下游 itfx 凭证(优先订单绑定字段,回退渠道分组)。
/// </summary>
public class OrderDownstreamCredentialResolver(
IServiceChannelRepository serviceChannelRepository,
IServiceChannelGroupRepository serviceChannelGroupRepository,
IItfxMetadataClient itfxMetadataClient) : IScopedDependency
{
public async Task<OrderDownstreamCredentials> 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()
};
}
}