Fix downstream forecast: pass auth + cache itfx app secret

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-27 18:08:09 +08:00
parent 28d14034bd
commit fffecb5964
11 changed files with 220 additions and 51 deletions

View File

@ -1,7 +1,7 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 服务渠道下游对接元数据(发布快照固化,不含 AppSecret)。
/// 服务渠道下游对接元数据(发布快照固化)。
/// </summary>
public class ServiceChannelDownstreamSnapshotDto
{
@ -25,6 +25,11 @@ public class ServiceChannelDownstreamSnapshotDto
/// </summary>
public string? AppKey { get; set; }
/// <summary>
/// itfx 应用 AppSecret。
/// </summary>
public string? AppSecret { get; set; }
/// <summary>
/// itfx 应用名称。
/// </summary>

View File

@ -0,0 +1,18 @@
using System.Threading;
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// 让后台任务也能把当前用户 Bearer 透传给 itfx 元数据/转发请求。
/// </summary>
internal static class ItfxAuthorizationContext
{
private static readonly AsyncLocal<string?> AuthorizationValue = new();
public static string? Authorization
{
get => AuthorizationValue.Value;
set => AuthorizationValue.Value = value;
}
}

View File

@ -63,7 +63,9 @@ internal sealed class ItfxForwardClient(
private void ForwardAuthorization(HttpRequestMessage request)
{
var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
var authorization =
ItfxAuthorizationContext.Authorization
?? httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization);

View File

@ -102,7 +102,9 @@ internal sealed class ItfxMetadataClient(
private void ForwardAuthorization(HttpClient client)
{
var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
var authorization =
ItfxAuthorizationContext.Authorization
?? httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorization);

View File

@ -20,6 +20,11 @@ public class OrderBatchJobIntegrationEvent : IntegrationEvent
public string? PayloadJson { get; set; }
/// <summary>
/// 调用第三方轨迹网关所需的鉴权(Bearer 等)。
/// </summary>
public string? Authorization { get; set; }
public int OperatorUserId { get; set; }
public string MessageId { get; set; } = string.Empty;
@ -33,6 +38,7 @@ public class OrderBatchJobIntegrationEvent : IntegrationEvent
OrderId = message.OrderId,
CustomerId = message.CustomerId,
PayloadJson = message.PayloadJson,
Authorization = message.Authorization,
OperatorUserId = message.OperatorUserId,
MessageId = message.MessageId,
};
@ -46,6 +52,7 @@ public class OrderBatchJobIntegrationEvent : IntegrationEvent
OrderId = OrderId,
CustomerId = CustomerId,
PayloadJson = PayloadJson,
Authorization = Authorization,
OperatorUserId = OperatorUserId,
MessageId = MessageId,
};

View File

@ -17,6 +17,11 @@ public class OrderBatchJobMessage
public string? PayloadJson { get; set; }
/// <summary>
/// 调用第三方轨迹网关所需的鉴权(Bearer 等)。
/// </summary>
public string? Authorization { get; set; }
public int OperatorUserId { get; set; }
public string MessageId { get; set; } = string.Empty;

View File

@ -83,6 +83,12 @@ public sealed class OrderBatchActionExecutor(
request = await smallBagTrackUpsertRequestBuilder.BuildAsync(order, cancellationToken: cancellationToken);
}
if (string.IsNullOrWhiteSpace(request.Authorization)
&& !string.IsNullOrWhiteSpace(message.Authorization))
{
request.Authorization = message.Authorization.Trim();
}
var trackResult = await smallBagTrackOrderClient.UpsertDebugAsync(request, cancellationToken);
var serviceNo = request.OrderNo?.Trim();
@ -124,61 +130,74 @@ public sealed class OrderBatchActionExecutor(
OrderBatchJobMessage message,
CancellationToken cancellationToken)
{
OrderParcelCreateInput body;
Order order;
if (!string.IsNullOrWhiteSpace(message.PayloadJson))
var authorization = message.Authorization;
if (!string.IsNullOrWhiteSpace(authorization))
{
body = JsonConvert.DeserializeObject<OrderParcelCreateInput>(message.PayloadJson!)
?? throw new BusinessException("下游预报消息体无效");
// 凭证解析仍需订单上的渠道/下游绑定字段;有 payload 时只查主表一次
var existing = await orderRepository.FirstOrDefaultAsync(x => x.Id == message.OrderId, cancellationToken);
if (existing == null)
{
return OrderBatchActionExecuteResult.BusinessFail("订单不存在");
}
order = existing;
ItfxAuthorizationContext.Authorization = authorization.Trim();
}
else
try
{
var bundles = await orderRepository.GetDownstreamCreateOneBundlesAsync([message.OrderId], cancellationToken);
if (!bundles.TryGetValue(message.OrderId, out var bundle))
OrderParcelCreateInput body;
Order order;
if (!string.IsNullOrWhiteSpace(message.PayloadJson))
{
return OrderBatchActionExecuteResult.BusinessFail("订单不存在");
body = JsonConvert.DeserializeObject<OrderParcelCreateInput>(message.PayloadJson!)
?? throw new BusinessException("下游预报消息体无效");
// 凭证解析仍需订单上的渠道/下游绑定字段;有 payload 时只查主表一次
var existing = await orderRepository.FirstOrDefaultAsync(x => x.Id == message.OrderId, cancellationToken);
if (existing == null)
{
return OrderBatchActionExecuteResult.BusinessFail("订单不存在");
}
order = existing;
}
else
{
var bundles = await orderRepository.GetDownstreamCreateOneBundlesAsync([message.OrderId], cancellationToken);
if (!bundles.TryGetValue(message.OrderId, out var bundle))
{
return OrderBatchActionExecuteResult.BusinessFail("订单不存在");
}
order = bundle.Order;
body = await OrderDownstreamCreateOneBodyBuilder.BuildAsync(
bundle.Order,
bundle.Sender,
bundle.Recipient,
bundle.Declarations,
bundle.Weights,
countryRepository,
cancellationToken);
}
order = bundle.Order;
body = await OrderDownstreamCreateOneBodyBuilder.BuildAsync(
bundle.Order,
bundle.Sender,
bundle.Recipient,
bundle.Declarations,
bundle.Weights,
countryRepository,
if (string.IsNullOrWhiteSpace(order.ServiceNo))
{
return OrderBatchActionExecuteResult.BusinessFail("订单运单号为空,无法预报下游");
}
var creds = await downstreamCredentialResolver.ResolveAsync(order, cancellationToken);
var forward = await itfxForwardClient.ForwardCreateOrderOneAsync(
creds.SupplierCode,
creds.AppKey,
creds.AppSecret,
body,
cancellationToken);
}
if (string.IsNullOrWhiteSpace(order.ServiceNo))
if (!forward.Success)
{
return OrderBatchActionExecuteResult.BusinessFail(
$"下游预报失败: HTTP {forward.StatusCode} {forward.ResponseBody}");
}
return OrderBatchActionExecuteResult.Ok();
}
finally
{
return OrderBatchActionExecuteResult.BusinessFail("订单运单号为空,无法预报下游");
ItfxAuthorizationContext.Authorization = null;
}
var creds = await downstreamCredentialResolver.ResolveAsync(order, cancellationToken);
var forward = await itfxForwardClient.ForwardCreateOrderOneAsync(
creds.SupplierCode,
creds.AppKey,
creds.AppSecret,
body,
cancellationToken);
if (!forward.Success)
{
return OrderBatchActionExecuteResult.BusinessFail(
$"下游预报失败: HTTP {forward.StatusCode} {forward.ResponseBody}");
}
return OrderBatchActionExecuteResult.Ok();
}
}

View File

@ -34,6 +34,7 @@ public sealed class OrderBatchJobCoordinator(
public async Task<OrderBatchJobOutput> CreateAndEnqueueAsync(
OrderBatchJobCreateInput input,
int operatorUserId,
string? authorization = null,
CancellationToken cancellationToken = default)
{
if (!publisher.IsEnabled)
@ -142,6 +143,7 @@ public sealed class OrderBatchJobCoordinator(
OrderId = item.OrderId,
CustomerId = order?.CustomerId,
PayloadJson = item.PayloadJson,
Authorization = authorization,
OperatorUserId = operatorUserId,
MessageId = item.MessageId ?? $"{job.Id}:{item.OrderId}:{action}",
};

View File

@ -1,5 +1,6 @@
using Fake.Application;
using Fake.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
@ -75,7 +76,18 @@ public partial class OrderService
public Task<OrderBatchJobOutput> BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input)
{
input.Action = OrderBatchActionCodes.TrackUpsertSmallBag;
return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId<int>());
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
return BatchJobCoordinator.CreateAndEnqueueAsync(
input,
CurrentUser.GetId<int>(),
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
}
/// <summary>
@ -85,7 +97,18 @@ public partial class OrderService
public Task<OrderBatchJobOutput> BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input)
{
input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne;
return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId<int>());
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
return BatchJobCoordinator.CreateAndEnqueueAsync(
input,
CurrentUser.GetId<int>(),
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
}
private Task<OrderBatchJobOutput> EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input)

View File

@ -161,6 +161,11 @@ public class AllocationProviderSnapshotEnricher(
snapshot.AppKey = app.AppKey;
}
if (!string.IsNullOrWhiteSpace(app?.AppSecret))
{
snapshot.AppSecret = app.AppSecret;
}
snapshot.AppName = NullIfEmpty(app?.Name);
}

View File

@ -1,5 +1,6 @@
using Fake.DependencyInjection;
using Fake.Application;
using Oms.Application.Dtos.Channels;
using Oms.Application.Integrations.Itfx;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
@ -25,6 +26,12 @@ public class OrderDownstreamCredentialResolver(
{
public async Task<OrderDownstreamCredentials> ResolveAsync(Order order, CancellationToken cancellationToken = default)
{
var fromSnapshot = await TryResolveFromChannelRulesSnapshotAsync(order, cancellationToken);
if (fromSnapshot != null)
{
return fromSnapshot;
}
if (!string.IsNullOrWhiteSpace(order.DownstreamSupplierCode)
&& !string.IsNullOrWhiteSpace(order.DownstreamAppKey)
&& order.DownstreamAppId is > 0)
@ -95,4 +102,78 @@ public class OrderDownstreamCredentialResolver(
AppSecret = appInfo.AppSecret.Trim()
};
}
private async Task<OrderDownstreamCredentials?> TryResolveFromChannelRulesSnapshotAsync(
Order order,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(order.ChannelCode))
{
return null;
}
var channel = await serviceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim(), cancellationToken);
if (channel == null)
{
return null;
}
// 规则快照里固化了 itfx 应用信息(含 AppSecret)。
const string rulesOperatorType = "rules";
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
var record = await serviceChannelRepository.GetEffectivePublishRecordAsync(
channel.Id,
rulesOperatorType,
bindTime,
cancellationToken);
if (record == null || string.IsNullOrWhiteSpace(record.Content))
{
return null;
}
var snapshot = OmsDocHelper.DeserializeJson<ServiceChannelRulesPublishSnapshotDto>(record.Content);
var downstream = snapshot?.Downstream;
if (downstream == null)
{
return null;
}
// 订单绑定的 AppId 与快照不一致时,直接回退旧逻辑。
if (order.DownstreamAppId is > 0
&& downstream.AppId is > 0
&& order.DownstreamAppId.Value != downstream.AppId.Value)
{
return null;
}
var appSecret = downstream.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(appSecret))
{
return null;
}
var appKey = !string.IsNullOrWhiteSpace(order.DownstreamAppKey)
? order.DownstreamAppKey.Trim()
: downstream.AppKey?.Trim();
if (string.IsNullOrWhiteSpace(appKey))
{
return null;
}
var supplierCode = !string.IsNullOrWhiteSpace(order.DownstreamSupplierCode)
? order.DownstreamSupplierCode.Trim()
: downstream.SupplierCode?.Trim();
if (string.IsNullOrWhiteSpace(supplierCode))
{
return null;
}
return new OrderDownstreamCredentials
{
SupplierCode = supplierCode,
AppKey = appKey,
AppSecret = appSecret
};
}
}