20261008-ide1.0发布 #1

Merged
zzh merged 42 commits from test into master 2026-10-08 09:18:00 +08:00
6 changed files with 279 additions and 59 deletions
Showing only changes of commit 87f4cebc9b - Show all commits

View File

@ -0,0 +1,29 @@
namespace Oms.Application.Dtos.OpenApi;
/// <summary>
/// 对外开放接口统一响应。
/// </summary>
public class OpenApiEnvelope<T>
{
public string Code { get; set; } = "200";
public string Msg { get; set; } = "success";
public T? Data { get; set; }
public static OpenApiEnvelope<T> Ok(T data) =>
new()
{
Code = "200",
Msg = "success",
Data = data
};
public static OpenApiEnvelope<T> Fail(string msg, string code = "400") =>
new()
{
Code = code,
Msg = msg,
Data = default
};
}

View File

@ -1,17 +1,149 @@
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 开放接口创建订单包裹:在标准下单入参上增加 AppKey/AppSecret(Body,非自定义 Header)。
/// 开放接口创建订单包裹入参:与单票字段对齐(不含上门揽收)。
/// 鉴权凭证放请求头 appKey / appSecret,勿放入 Body。
/// </summary>
public class OpenApiParcelCreateInput : OrderParcelCreateInput
public class OpenApiParcelCreateInput
{
/// <summary>
/// 客户开放接口 AppKey。
/// </summary>
public string? AppKey { get; set; }
/// <summary>请求标识(幂等,可选)。</summary>
public string? RequestId { get; set; }
/// <summary>客户单号。</summary>
public string? ClientNo { get; set; }
/// <summary>跟踪号(可选,系统通常忽略由下游回写)。</summary>
public string? TrackNo { get; set; }
/// <summary>服务单号(可选,不传时系统自动生成)。</summary>
public string? ServiceNo { get; set; }
/// <summary>产品编码(运输方式)。</summary>
public string? ProductCode { get; set; }
/// <summary>渠道编码(可选)。</summary>
public string? ChannelCode { get; set; }
/// <summary>货物属性(字典 cargo_attribute:general / electronics / sensitive)。</summary>
public string? CargoAttribute { get; set; }
/// <summary>出口报关方式(字典 export_customs_mode)。</summary>
public string? ExportCustomsMode { get; set; }
/// <summary>出口资质备案 Id(9610 汇总申报时)。</summary>
public int? ExportQualificationId { get; set; }
/// <summary>电商平台(9610 汇总申报时)。</summary>
public string? PlatformCode { get; set; }
/// <summary>仓库主键。</summary>
public int? WarehouseId { get; set; }
/// <summary>长(cm)。</summary>
public decimal? Length { get; set; }
/// <summary>宽(cm)。</summary>
public decimal? Width { get; set; }
/// <summary>高(cm)。</summary>
public decimal? Height { get; set; }
/// <summary>实重(kg)。</summary>
public decimal? ActualWeight { get; set; }
/// <summary>体积重(kg,可选)。</summary>
public decimal? VolumetricWeight { get; set; }
/// <summary>计费重(kg,可选)。</summary>
public decimal? ChargeableWeight { get; set; }
/// <summary>体积(cm³,可选)。</summary>
public decimal? Cubic { get; set; }
/// <summary>是否 COD。</summary>
public bool IsCod { get; set; }
/// <summary>COD 币种。</summary>
public string? CodCurrency { get; set; }
/// <summary>COD 金额。</summary>
public decimal? CodSum { get; set; }
/// <summary>申报币种(字典 currency)。</summary>
public string? DeclaredCurrency { get; set; }
/// <summary>申报总金额(可选,不填可由明细汇总)。</summary>
public decimal? DeclaredSum { get; set; }
/// <summary>附加服务字典 code 列表。</summary>
public List<string>? AdditionalServices { get; set; }
/// <summary>是否偏远(可选预传)。</summary>
public byte? IsRemote { get; set; }
/// <summary>包裹数量。</summary>
public int? Quantity { get; set; }
/// <summary>收件人。</summary>
public OrderParcelReceiverInput? Receiver { get; set; }
/// <summary>寄件人。</summary>
public OrderParcelSenderInput? Sender { get; set; }
/// <summary>申报明细。</summary>
public List<OrderParcelItemInput> Items { get; set; } = [];
/// <summary>优惠券实例 Id(可选)。</summary>
public int? CouponInstanceId { get; set; }
/// <summary>仅保存草稿,不预报计费。</summary>
public bool SaveAsDraft { get; set; }
/// <summary>
/// 客户开放接口 AppSecret。
/// 映射为内部单票创建入参。开放接口固定自发货(selfShip),客户 Id 由凭证解析后写入。
/// </summary>
public string? AppSecret { get; set; }
public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) =>
new()
{
RequestId = RequestId,
ClientNo = ClientNo,
TrackNo = TrackNo,
ServiceNo = ServiceNo,
ProductCode = ProductCode,
ChannelCode = ChannelCode,
CargoAttribute = CargoAttribute,
ExportCustomsMode = ExportCustomsMode,
ExportQualificationId = ExportQualificationId,
PlatformCode = PlatformCode,
WarehouseId = WarehouseId,
CustomerId = customerId,
Length = Length,
Width = Width,
Height = Height,
ActualWeight = ActualWeight,
VolumetricWeight = VolumetricWeight,
ChargeableWeight = ChargeableWeight,
Cubic = Cubic,
IsCod = IsCod,
CodCurrency = CodCurrency,
CodSum = CodSum,
DeclaredCurrency = DeclaredCurrency,
DeclaredSum = DeclaredSum,
AdditionalServices = AdditionalServices,
IsRemote = IsRemote,
Quantity = Quantity,
Receiver = Receiver,
Sender = Sender,
Items = Items ?? [],
CouponInstanceId = CouponInstanceId,
SaveAsDraft = SaveAsDraft,
InboundMethod = OrderInboundMethodCodes.SelfShip,
SenderAddressId = null,
ExpectPickupTime = null,
PickupDirection = null,
CarrierCode = null,
CarrierService = null
};
}

View File

@ -344,10 +344,6 @@ public class PickupFeeLineOutput
public class PickupCarrierQuoteOutput
{
public string? ServiceMode { get; set; }
public string? PayMode { get; set; }
public List<PickupCarrierValueFeeOutput> ValueInfoList { get; set; } = [];
}
@ -364,7 +360,7 @@ public class PickupOpenApiPushFeeResult
{
public string PickupNo { get; set; } = string.Empty;
public decimal ActualWeight { get; set; }
public decimal TotalActualWeight { get; set; }
public decimal ChargeableWeight { get; set; }
@ -372,31 +368,34 @@ public class PickupOpenApiPushFeeResult
public decimal ReceivableAmount { get; set; }
public decimal PayableAmount { get; set; }
public decimal? CarrierPayableAmount { get; set; }
public decimal? CarrierQuotedFreightAmount { get; set; }
}
/// <summary>
/// 承运推送件重尺入参(字段名对齐 t_pickup_order)。
/// </summary>
public class PickupOpenApiPushFeeInput
{
/// <summary>
/// 承运运单号,对应库字段 CarrierWaybillNo。
/// </summary>
public string? WaybillNumber { get; set; }
/// <summary>承运运单号。</summary>
public string? CarrierWaybillNo { get; set; }
public decimal ActualWeight { get; set; }
/// <summary>实重合计。</summary>
public decimal TotalActualWeight { get; set; }
public decimal? FreightWeight { get; set; }
/// <summary>承运计费重(对照)。</summary>
public decimal? CarrierFreightWeight { get; set; }
public decimal? TotalFreightAmount { get; set; }
/// <summary>承运报价运费合计(对照)。</summary>
public decimal? CarrierQuotedFreightAmount { get; set; }
public decimal? ReceivableAmount { get; set; }
/// <summary>承运报文应收(对照)。</summary>
public decimal? CarrierQuotedReceivableAmount { get; set; }
/// <summary>承运附加费明细(写入 CarrierQuoteJson)。</summary>
public List<PickupCarrierValueFeeOutput>? ValueInfoList { get; set; }
public List<PickupPackageSizeOutput>? GoodsSizeList { get; set; }
public string? ServiceMode { get; set; }
public string? PayMode { get; set; }
/// <summary>尺寸明细(写入 PackageSizesJson)。</summary>
public List<PickupPackageSizeOutput>? PackageSizes { get; set; }
}

View File

@ -1,6 +1,7 @@
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.OpenApi;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
@ -9,7 +10,8 @@ namespace Oms.Application.Services.OpenApi;
/// <summary>
/// 对外开放 API(Swagger 文档分组:openApi)。
/// 客户机机:AppKey/AppSecret 放在 JSON Body;供应商推费由上游 itfx 鉴权,OMS 不再验 key。
/// 统一响应:{ code, msg, data }。
/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。
/// </summary>
[ApiExplorerSettings(GroupName = "openApi")]
public class OpenApiService : ApplicationService
@ -24,29 +26,58 @@ public class OpenApiService : ApplicationService
LazyServiceProvider.GetRequiredService<OrderService>();
/// <summary>
/// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需 AppKey)。
/// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi/pickup-order/push-fee")]
public Task<PickupOpenApiPushFeeResult> PushFeeAsync([FromBody] PickupOpenApiPushFeeInput input) =>
FeePush.PushFeeAsync(input);
public async Task<OpenApiEnvelope<PickupOpenApiPushFeeResult>> PushFeeAsync(
[FromBody] PickupOpenApiPushFeeInput input)
{
try
{
var data = await FeePush.PushFeeAsync(input);
return OpenApiEnvelope<PickupOpenApiPushFeeResult>.Ok(data);
}
catch (BusinessException ex)
{
return OpenApiEnvelope<PickupOpenApiPushFeeResult>.Fail(ex.Message);
}
catch (Exception ex)
{
return OpenApiEnvelope<PickupOpenApiPushFeeResult>.Fail(
string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
"500");
}
}
/// <summary>
/// 创建订单包裹(客户机机:Body 传 AppKey、AppSecret)。
/// 创建订单包裹(仅自发货)。
/// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi/parcel/create")]
public async Task<OrderParcelCreateOutput> CreateParcelAsync([FromBody] OpenApiParcelCreateInput input)
public async Task<OpenApiEnvelope<OrderParcelCreateOutput>> CreateParcelAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
[FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret,
[FromBody] OpenApiParcelCreateInput input)
{
ArgumentNullException.ThrowIfNull(input);
var (customer, _) = await AuthHelper.ResolveAsync(input.AppKey, input.AppSecret);
if (input.CustomerId is > 0 && input.CustomerId.Value != customer.Id)
try
{
throw new BusinessException("凭证无效");
}
ArgumentNullException.ThrowIfNull(input);
input.CustomerId = customer.Id;
return await OrderService.CreateParcelAsync(input);
var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id));
return OpenApiEnvelope<OrderParcelCreateOutput>.Ok(data);
}
catch (BusinessException ex)
{
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(ex.Message);
}
catch (Exception ex)
{
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(
string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message,
"500");
}
}
}

View File

@ -51,21 +51,21 @@ public class PickupOrderFeePushService : IScopedDependency
throw new BusinessException("请求体不能为空");
}
var carrierWaybillNo = (input.WaybillNumber ?? string.Empty).Trim();
var carrierWaybillNo = (input.CarrierWaybillNo ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(carrierWaybillNo))
{
throw new BusinessException("waybillNumber(承运运单号 CarrierWaybillNo)不能为空");
throw new BusinessException("carrierWaybillNo(承运运单号)不能为空");
}
if (input.ActualWeight <= 0)
if (input.TotalActualWeight <= 0)
{
throw new BusinessException("actualWeight 必须大于 0");
throw new BusinessException("totalActualWeight 必须大于 0");
}
var sizes = NormalizeSizes(input.GoodsSizeList);
var sizes = NormalizeSizes(input.PackageSizes);
if (sizes.Count == 0)
{
throw new BusinessException("goodsSizeList 不能为空");
throw new BusinessException("packageSizes 不能为空");
}
var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
@ -97,7 +97,7 @@ public class PickupOrderFeePushService : IScopedDependency
Length = totalVolume,
Width = 1,
Height = 1,
ActualWeight = input.ActualWeight,
ActualWeight = input.TotalActualWeight,
Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1)
};
@ -184,17 +184,15 @@ public class PickupOrderFeePushService : IScopedDependency
var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal;
var other = Math.Max(0, receivableTotal - freight);
pickup.TotalActualWeight = input.ActualWeight;
pickup.TotalActualWeight = input.TotalActualWeight;
pickup.VolumetricWeight = volumetricWeight;
pickup.ChargeableWeight = chargeableWeight;
pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
pickup.CarrierFreightWeight = input.FreightWeight;
pickup.CarrierQuotedFreightAmount = input.TotalFreightAmount;
pickup.CarrierQuotedReceivableAmount = input.ReceivableAmount;
pickup.CarrierFreightWeight = input.CarrierFreightWeight;
pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount;
pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount;
pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
{
ServiceMode = input.ServiceMode,
PayMode = input.PayMode,
ValueInfoList = input.ValueInfoList ?? []
});
pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees);
@ -210,12 +208,12 @@ public class PickupOrderFeePushService : IScopedDependency
return new PickupOpenApiPushFeeResult
{
PickupNo = pickup.PickupNo,
ActualWeight = input.ActualWeight,
TotalActualWeight = input.TotalActualWeight,
ChargeableWeight = chargeableWeight,
VolumetricWeight = volumetricWeight,
ReceivableAmount = receivableTotal,
PayableAmount = payableTotal,
CarrierQuotedFreightAmount = input.TotalFreightAmount
CarrierPayableAmount = payableTotal,
CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount
};
}

View File

@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
@ -8,10 +10,13 @@ using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。
/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。
/// </summary>
public class CustomerOpenApiAuthHelper : IScopedDependency
{
public const string AppKeyHeaderName = "appKey";
public const string AppSecretHeaderName = "appSecret";
private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
@ -26,6 +31,19 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// 从当前请求头读取 appKey / appSecret 并鉴权。
/// </summary>
public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync(
CancellationToken cancellationToken = default)
{
var http = _httpContextAccessor.HttpContext
?? throw new BusinessException("凭证无效");
var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault();
var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault();
return ResolveAsync(appKey, appSecret, cancellationToken);
}
public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync(
string? appKey,
string? appSecret,
@ -41,7 +59,7 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
if (credential == null
|| !credential.Enabled
|| !string.Equals(credential.AppSecret, secret, StringComparison.Ordinal))
|| !FixedTimeEqualsUtf8(credential.AppSecret, secret))
{
throw new BusinessException("凭证无效");
}
@ -63,6 +81,19 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
return (customer, credential);
}
private static bool FixedTimeEqualsUtf8(string? expected, string actual)
{
if (string.IsNullOrEmpty(expected))
{
return false;
}
var left = Encoding.UTF8.GetBytes(expected);
var right = Encoding.UTF8.GetBytes(actual);
return left.Length == right.Length
&& CryptographicOperations.FixedTimeEquals(left, right);
}
private string? ResolveClientIp()
{
var http = _httpContextAccessor.HttpContext;