From 6d085d999ae867807968beffec990789c1ed763d Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Sat, 10 Oct 2026 17:36:36 +0800 Subject: [PATCH 1/4] 1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7f7b084..5ebd994 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 8.0.5.19 + 8.0.5.20 From 6c3a094126d2f1268f3d70c6c03715fa21e90679 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 10 Oct 2026 18:43:32 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E4=B8=8B=E6=B8=B8=20getFile=20?= =?UTF-8?q?=E8=BD=AC=E5=AD=98=20COS=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=9B=86=E6=88=90=E4=BA=8B=E4=BB=B6=20Outbox=20=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增获取下游文件接口;事件发布统一走事务性 UoW,避免批量预报等按钮报 Outbox 错误。 Co-authored-by: Cursor --- .../Dtos/Orders/ItfxGetFileBody.cs | 27 +++ .../Dtos/Orders/OrderDownstreamForwardDtos.cs | 55 +++++- .../Itfx/ItfxGetFileDataParser.cs | 171 ++++++++++++++++++ .../OrderAutoTriggerFireEventHandler.cs | 3 +- .../OrderLifecycleTrackAppendEventHandler.cs | 3 +- ...rderParcelCustoms9610UpsertEventHandler.cs | 3 +- .../OrderParcelLabelGenerateEventHandler.cs | 3 +- .../OrderParcelPickupAttachEventHandler.cs | 3 +- .../OrderParcelTrackUpsertEventHandler.cs | 3 +- .../Services/Orders/OrderFileManager.cs | 160 +++++++++++++++- .../Services/Orders/OrderService.BatchJob.cs | 15 ++ .../Orders/OrderService.DownstreamForward.cs | 68 +++++++ 12 files changed, 505 insertions(+), 9 deletions(-) create mode 100644 src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs create mode 100644 src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs diff --git a/src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs b/src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs new file mode 100644 index 0000000..f97311c --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs @@ -0,0 +1,27 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// itfx getFile(ForwardWithSubscribe)标准请求体。 +/// +public class ItfxGetFileBody +{ + /// + /// 客户单号。 + /// + public string? ClientNo { get; set; } + + /// + /// 跟踪号。 + /// + public string? TrackNo { get; set; } + + /// + /// 服务商单号。 + /// + public string? ServiceNo { get; set; } + + /// + /// 文件类型:下游面单=21,末端面单=31,POD=180。 + /// + public int Type { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs b/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs index c69e78c..0ace4b1 100644 --- a/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs +++ b/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs @@ -1,5 +1,3 @@ -using Oms.Application.Dtos.Orders; - namespace Oms.Application.Dtos.Orders; /// @@ -43,3 +41,56 @@ public class OrderDownstreamForwardResult /// public string? TrackNo { get; set; } } + +/// +/// 下游 getFile 入参。 +/// +public class OrderDownstreamGetFileInput +{ + /// + /// 订单 Id。 + /// + public int Id { get; set; } + + /// + /// 文件类型:下游面单=21,末端面单=31,POD=180。 + /// + public int Type { get; set; } +} + +/// +/// 下游 getFile 转发结果。 +/// +public class OrderDownstreamGetFileResult +{ + public bool Success { get; set; } + + public string SupplierCode { get; set; } = string.Empty; + + public int Type { get; set; } + + public string? FileType { 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; + + /// + /// 转存到自有 COS 后的公开地址。 + /// + public string? StoredFileUrl { get; set; } + + /// + /// 回写后的服务商单号(Data.serviceNo)。 + /// + public string? DownstreamServiceNo { get; set; } + + /// + /// 回写后的跟踪号(Data.trackNo)。 + /// + public string? TrackNo { get; set; } +} diff --git a/src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs b/src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs new file mode 100644 index 0000000..4764ab0 --- /dev/null +++ b/src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Integrations.Itfx; + +/// +/// 解析 itfx getFile 成功响应中的 Data,并回写订单单号。 +/// +internal static class ItfxGetFileDataParser +{ + public const string StandardCode = "getFile"; + + public const int TypeDownstreamLabel = 21; + public const int TypeLastMileLabel = 31; + public const int TypePod = 180; + + public sealed class GetFileData + { + public string? ServiceNo { get; init; } + + public string? TrackNo { get; init; } + + public string? ClientNo { get; init; } + + public string? Url { get; init; } + + public int? Type { get; init; } + } + + public static bool TryResolveFileType(int type, out string fileType) + { + fileType = type switch + { + TypeDownstreamLabel => OrderFileTypeCodes.DownstreamLabel, + TypeLastMileLabel => OrderFileTypeCodes.LastMileLabel, + TypePod => OrderFileTypeCodes.Pod, + _ => string.Empty + }; + return !string.IsNullOrEmpty(fileType); + } + + public static bool TryParse(string? responseBody, out GetFileData? data) + { + data = null; + if (string.IsNullOrWhiteSpace(responseBody)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!TryGetPropertyIgnoreCase(root, out var dataElement, "data", "Data") + || dataElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + data = new GetFileData + { + ServiceNo = ReadString(dataElement, "serviceNo", "ServiceNo"), + TrackNo = ReadString(dataElement, "trackNo", "TrackNo"), + ClientNo = ReadString(dataElement, "clientNo", "ClientNo"), + Url = ReadString(dataElement, "url", "Url"), + Type = ReadInt(dataElement, "type", "Type"), + }; + return !string.IsNullOrWhiteSpace(data.ServiceNo) + || !string.IsNullOrWhiteSpace(data.TrackNo) + || !string.IsNullOrWhiteSpace(data.ClientNo) + || !string.IsNullOrWhiteSpace(data.Url); + } + catch + { + return false; + } + } + + /// + /// 服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo;空值不覆盖。 + /// + public static bool ApplyToOrder(Order order, GetFileData data) + { + var changed = false; + + if (!string.IsNullOrWhiteSpace(data.ServiceNo)) + { + var value = data.ServiceNo.Trim(); + if (!string.Equals(order.DownstreamServiceNo, value, StringComparison.Ordinal)) + { + order.DownstreamServiceNo = value; + changed = true; + } + } + + if (!string.IsNullOrWhiteSpace(data.TrackNo)) + { + var value = data.TrackNo.Trim(); + if (!string.Equals(order.TrackNo, value, StringComparison.Ordinal)) + { + order.TrackNo = value; + changed = true; + } + } + + return changed; + } + + private static bool TryGetPropertyIgnoreCase(JsonElement root, out JsonElement value, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + } + + foreach (var prop in root.EnumerateObject()) + { + foreach (var name in names) + { + if (prop.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + } + + value = default; + return false; + } + + private static string? ReadString(JsonElement root, params string[] names) + { + if (!TryGetPropertyIgnoreCase(root, out var prop, names)) + { + return null; + } + + return prop.ValueKind switch + { + JsonValueKind.String => prop.GetString()?.Trim(), + JsonValueKind.Number => prop.GetRawText(), + JsonValueKind.Null => null, + _ => null + }; + } + + private static int? ReadInt(JsonElement root, params string[] names) + { + if (!TryGetPropertyIgnoreCase(root, out var prop, names)) + { + return null; + } + + return prop.ValueKind switch + { + JsonValueKind.Number when prop.TryGetInt32(out var n) => n, + JsonValueKind.String when int.TryParse(prop.GetString(), out var n) => n, + _ => null + }; + } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs index 670c52e..bebb13f 100644 --- a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs @@ -1,6 +1,7 @@ using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; +using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Services.Orders.Issues; @@ -26,7 +27,7 @@ public sealed class OrderAutoTriggerFireEventHandler( return; } - using var unitOfWork = unitOfWorkManager.Begin(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); try { await autoTriggerRuntimeService.FireByOrderIdAsync( diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs index 2114097..f963a48 100644 --- a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs @@ -1,6 +1,7 @@ using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; +using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Integrations.SmallBagTrack; @@ -24,7 +25,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler( return; } - using var unitOfWork = unitOfWorkManager.Begin(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); try { await trackClient.AppendLifecycleTrackAsync( diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs index bb292d2..5ce44e7 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs @@ -1,6 +1,7 @@ using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; +using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Services.Orders; using Oms.Domain.OrdersAggregate.Repositories; @@ -21,7 +22,7 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler( OrderParcelCustoms9610UpsertIntegrationEvent @event, CancellationToken cancellationToken) { - using var unitOfWork = unitOfWorkManager.Begin(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken); if (order == null) { diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs index 8e29720..78936ca 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs @@ -1,6 +1,7 @@ using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; +using Oms.Application.Messaging; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Oms.Application.Services.Orders; @@ -20,7 +21,7 @@ public sealed class OrderParcelLabelGenerateEventHandler( OrderParcelLabelGenerateIntegrationEvent @event, CancellationToken cancellationToken) { - using var unitOfWork = unitOfWorkManager.Begin(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); using var scope = scopeFactory.CreateScope(); var orderService = scope.ServiceProvider.GetRequiredService(); var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken); diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs index 488e95c..adcaf40 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs @@ -1,6 +1,7 @@ using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; +using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; @@ -21,7 +22,7 @@ public sealed class OrderParcelPickupAttachEventHandler( OrderParcelPickupAttachIntegrationEvent @event, CancellationToken cancellationToken) { - using var unitOfWork = unitOfWorkManager.Begin(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); try { await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs index 6347a3c..0fb3f89 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs @@ -2,6 +2,7 @@ using Fake.Application; using Fake.DependencyInjection; using Fake.EventBus; using Fake.UnitOfWork; +using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Oms.Application.Integrations.SmallBagTrack; @@ -24,7 +25,7 @@ public sealed class OrderParcelTrackUpsertEventHandler( OrderParcelTrackUpsertIntegrationEvent @event, CancellationToken cancellationToken) { - using var unitOfWork = unitOfWorkManager.Begin(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); var request = await ResolveRequestAsync(@event, cancellationToken); request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess; diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index 3cf553b..356230a 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -3,6 +3,7 @@ using Fake.DependencyInjection; using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Storage; using Oms.Application.Storage; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -15,7 +16,8 @@ namespace Oms.Application.Services.Orders; /// public sealed class OrderFileManager( IOrderFileRepository orderFileRepository, - IObjectStorage fileStorage) : IScopedDependency + IObjectStorage fileStorage, + IHttpClientFactory httpClientFactory) : IScopedDependency { private static readonly HashSet ImageExt = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" }; @@ -205,6 +207,162 @@ public sealed class OrderFileManager( return MapItem(saved); } + /// + /// 下载远端文件转存自有对象存储后写入订单附件。 + /// + public async Task AddOrReplaceFromRemoteUrlAsync( + int orderId, + string fileType, + string remoteUrl, + int operatorUserId, + bool replaceExisting = true, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(remoteUrl)) + { + throw new BusinessException("下游文件地址为空"); + } + + if (!OrderFileTypeCodes.IsValid(fileType)) + { + throw new BusinessException("不支持的附件类型"); + } + + var type = OrderFileTypeCodes.Normalize(fileType); + var sourceUrl = remoteUrl.Trim(); + var stored = await DownloadAndStoreRemoteAsync(orderId, type, sourceUrl, cancellationToken); + + if (replaceExisting) + { + await orderFileRepository.SoftDeleteByOrderAndTypeAsync(orderId, type, cancellationToken); + } + else + { + var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken); + var dup = existing.FirstOrDefault(x => + string.Equals(x.FileUrl, stored.Key, StringComparison.OrdinalIgnoreCase) + || string.Equals(x.FileUrl, sourceUrl, StringComparison.OrdinalIgnoreCase)); + if (dup != null) + { + return MapItem(dup); + } + + if (existing.Count >= OrderFileTypeCodes.GetMaxCount(type)) + { + var oldest = existing.OrderBy(x => x.Id).First(); + oldest.IsDeleted = true; + oldest.UpdateTime = DateTime.Now; + await orderFileRepository.UpdateAsync(oldest, cancellationToken); + } + } + + var existingAfter = replaceExisting + ? [] + : await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken); + var entity = new OrderFile + { + OrderId = orderId, + FileType = type, + FileName = stored.FileName, + FileUrl = stored.Key, + ContentType = stored.ContentType, + FileSize = stored.Size, + Sort = existingAfter.Count, + Source = OrderFileSourceCodes.Downstream, + Remark = sourceUrl.Length > 500 ? sourceUrl[..500] : sourceUrl, + CreateUserId = operatorUserId, + CreateTime = DateTime.Now + }; + var saved = await orderFileRepository.InsertAsync(entity, cancellationToken); + return MapItem(saved); + } + + private async Task<(string Key, string? FileName, string? ContentType, long Size)> DownloadAndStoreRemoteAsync( + int orderId, + string fileType, + string remoteUrl, + CancellationToken cancellationToken) + { + byte[] bytes; + string? contentType; + try + { + var client = httpClientFactory.CreateClient(FileStorageService.UrlFetchClientName); + using var request = new HttpRequestMessage(HttpMethod.Get, remoteUrl); + request.Headers.TryAddWithoutValidation("Accept", "*/*"); + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new BusinessException($"下载下游文件失败:HTTP {(int)response.StatusCode}"); + } + + bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + contentType = response.Content.Headers.ContentType?.MediaType; + } + catch (BusinessException) + { + throw; + } + catch (Exception ex) + { + throw new BusinessException($"下载下游文件失败:{ex.Message}"); + } + + if (bytes.Length == 0) + { + throw new BusinessException("下游文件内容为空"); + } + + if (bytes.Length > MaxPdfBytes) + { + throw new BusinessException("下游文件不能超过 10MB"); + } + + var ext = GuessExtension(remoteUrl, contentType); + var fileName = TryFileNameFromUrl(remoteUrl) ?? $"{fileType}{ext}"; + var storedName = $"{Guid.NewGuid():N}{ext}"; + var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; + await using var stream = new MemoryStream(bytes); + var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken); + return (key, fileName, contentType, bytes.Length); + } + + private static string GuessExtension(string url, string? contentType) + { + var fromUrl = Path.GetExtension( + (url.Contains('?', StringComparison.Ordinal) + ? url[..url.IndexOf('?', StringComparison.Ordinal)] + : url)); + if (!string.IsNullOrWhiteSpace(fromUrl) && fromUrl.Length <= 8) + { + return fromUrl.ToLowerInvariant(); + } + + if (!string.IsNullOrWhiteSpace(contentType)) + { + if (contentType.Contains("pdf", StringComparison.OrdinalIgnoreCase)) + { + return ".pdf"; + } + + if (contentType.Contains("png", StringComparison.OrdinalIgnoreCase)) + { + return ".png"; + } + + if (contentType.Contains("jpeg", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("jpg", StringComparison.OrdinalIgnoreCase)) + { + return ".jpg"; + } + } + + return ".pdf"; + } + public async Task> UploadAsync( int orderId, string fileType, diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs index 61120b2..13dafbf 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.Security.Claims; +using Fake.UnitOfWork; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; @@ -21,6 +22,7 @@ public partial class OrderService /// 创建订单批量异步任务并入队 RabbitMQ。 /// [HttpPost("/order/batch/jobs")] + [UnitOfWork(IsTransactional = true)] public Task CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input) { return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId()); @@ -73,6 +75,7 @@ public partial class OrderService /// 异步批量创建轨迹订单(替代前端逐条调用)。 /// [HttpPost("/order/batch/track-upsert")] + [UnitOfWork(IsTransactional = true)] public Task BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input) { input.Action = OrderBatchActionCodes.TrackUpsertSmallBag; @@ -94,6 +97,7 @@ public partial class OrderService /// 异步批量预报下游订单。 /// [HttpPost("/order/batch/downstream-forward")] + [UnitOfWork(IsTransactional = true)] public Task BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input) { input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne; @@ -111,6 +115,7 @@ public partial class OrderService string.IsNullOrWhiteSpace(authorization) ? null : authorization); } + [UnitOfWork(IsTransactional = true)] private Task EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input) { input.Action = action; @@ -121,6 +126,7 @@ public partial class OrderService /// 异步批量支付订单。 /// [HttpPost("/order/batch/pay")] + [UnitOfWork(IsTransactional = true)] public Task BatchPayAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input); @@ -128,6 +134,7 @@ public partial class OrderService /// 异步批量预报订单。 /// [HttpPost("/order/batch/pre-declare")] + [UnitOfWork(IsTransactional = true)] public Task BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input); @@ -135,6 +142,7 @@ public partial class OrderService /// 异步批量拦截订单。 /// [HttpPost("/order/batch/intercept")] + [UnitOfWork(IsTransactional = true)] public Task BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input); @@ -142,6 +150,7 @@ public partial class OrderService /// 异步批量取消拦截。 /// [HttpPost("/order/batch/cancel-intercept")] + [UnitOfWork(IsTransactional = true)] public Task BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input); @@ -149,6 +158,7 @@ public partial class OrderService /// 异步批量删除订单。 /// [HttpPost("/order/batch/delete")] + [UnitOfWork(IsTransactional = true)] public Task BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input); @@ -156,6 +166,7 @@ public partial class OrderService /// 异步批量打印面单。 /// [HttpPost("/order/batch/print")] + [UnitOfWork(IsTransactional = true)] public Task BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input); @@ -163,6 +174,7 @@ public partial class OrderService /// 异步批量标记问题件。 /// [HttpPost("/order/batch/mark-problematic")] + [UnitOfWork(IsTransactional = true)] public Task BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input); @@ -170,6 +182,7 @@ public partial class OrderService /// 异步批量重新排货。 /// [HttpPost("/order/batch/reallocate")] + [UnitOfWork(IsTransactional = true)] public Task BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input); @@ -177,6 +190,7 @@ public partial class OrderService /// 异步批量标记已认领。 /// [HttpPost("/order/batch/mark-claimed")] + [UnitOfWork(IsTransactional = true)] public Task BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input); @@ -184,6 +198,7 @@ public partial class OrderService /// 异步批量更新处理状态。 /// [HttpPost("/order/batch/handle-status-update")] + [UnitOfWork(IsTransactional = true)] public Task BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input); diff --git a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs index b8eb919..4c74d7c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs +++ b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.Domain.Exceptions; +using Fake.UnitOfWork; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.Itfx; @@ -36,6 +37,7 @@ public partial class OrderService /// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。 /// [HttpPost("/order/downstream-forward-create-one")] + [UnitOfWork(IsTransactional = true)] public async Task DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input) { var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id); @@ -82,6 +84,72 @@ public partial class OrderService }; } + /// + /// 按订单 Id 组装 getFile 并转发至 itfx 下游;成功时转存文件并回写单号。 + /// + [HttpPost("/order/downstream-forward-get-file")] + public async Task DownstreamForwardGetFileAsync( + [FromBody] OrderDownstreamGetFileInput input) + { + if (!ItfxGetFileDataParser.TryResolveFileType(input.Type, out var fileType)) + { + throw new BusinessException("不支持的文件类型,请选择下游面单/末端面单/POD"); + } + + var order = await GetOrderOrThrowAsync(input.Id); + var creds = await OrderDownstreamCredentialResolver.ResolveAsync(order); + var body = new ItfxGetFileBody + { + ClientNo = order.ClientNo?.Trim() ?? string.Empty, + TrackNo = order.TrackNo?.Trim() ?? string.Empty, + ServiceNo = order.DownstreamServiceNo?.Trim() ?? string.Empty, + Type = input.Type + }; + + var forward = await ItfxForwardClient.ForwardWithSubscribeAsync( + ItfxGetFileDataParser.StandardCode, + creds.SupplierCode, + creds.AppKey, + creds.AppSecret, + body); + + string? storedFileUrl = null; + if (forward.Success + && ItfxGetFileDataParser.TryParse(forward.ResponseBody, out var data) + && data != null) + { + if (ItfxGetFileDataParser.ApplyToOrder(order, data)) + { + await OrderRepository.UpdateAsync(order); + } + + if (!string.IsNullOrWhiteSpace(data.Url)) + { + var saved = await OrderFileManager.AddOrReplaceFromRemoteUrlAsync( + order.Id, + fileType, + data.Url!, + CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + storedFileUrl = saved.FileUrl; + } + } + + return new OrderDownstreamGetFileResult + { + Success = forward.Success, + SupplierCode = creds.SupplierCode, + Type = input.Type, + FileType = fileType, + Url = forward.Url, + RequestJson = forward.RequestJson, + StatusCode = forward.StatusCode, + ResponseBody = forward.ResponseBody, + StoredFileUrl = storedFileUrl, + DownstreamServiceNo = order.DownstreamServiceNo, + TrackNo = order.TrackNo, + }; + } + private async Task<(Domain.OrdersAggregate.Entities.Order order, ItfxCreateOrderOneBody body)> LoadDownstreamCreateOneContextAsync( int orderId) { From fa9777bd087df37ff45ac8b6ad60b4401f75a435 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 10 Oct 2026 19:01:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20SqlSugar=20=E4=B8=8E=20EF=20Outbox?= =?UTF-8?q?=20=E8=BF=9E=E6=8E=A5=E5=86=B2=E7=AA=81=E6=97=B6=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E7=9F=AD=E4=BA=8B=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发布集成事件前先挂 Outbox 上下文;连接不匹配时 RequiresNew 写 Outbox,避免预报等按钮报 transaction not associated。 Co-authored-by: Cursor --- .../Messaging/OmsIntegrationEventPublisher.cs | 73 +++++++++++++++---- .../Orders/Batch/OrderBatchJobCoordinator.cs | 8 ++ .../Services/Orders/OrderService.BatchJob.cs | 29 ++++---- ...OrderBatchJobCoordinatorGovernanceTests.cs | 4 + 4 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs index 0f12f49..956bc1e 100644 --- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs +++ b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs @@ -1,3 +1,4 @@ +using System.Data; using Fake.EntityFrameworkCore; using Fake.EventBus.Distributed; using Fake.EventBus.IntegrationEventLog; @@ -9,6 +10,7 @@ namespace Oms.Application.Messaging; /// /// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。 +/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。 /// internal static class OmsIntegrationEventPublisher { @@ -21,24 +23,41 @@ internal static class OmsIntegrationEventPublisher { if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager)) { - await eventBus.PublishAsync(@event, cancellationToken); - logger.LogDebug( - "Outbox enqueued {EventName} Id={EventId} (ambient TX)", + try + { + await EnsureOutboxDbContextAsync(unitOfWorkManager); + await eventBus.PublishAsync(@event, cancellationToken); + logger.LogDebug( + "Outbox enqueued {EventName} Id={EventId} (ambient TX)", + @event.GetType().Name, + @event.Id); + return; + } + catch (Exception ex) when (IsTxConnectionMismatch(ex)) + { + // SqlSugar 已在另一条连接上开事务时,EF 无法征用该 DbTransaction。 + logger.LogWarning( + ex, + "Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}", + @event.GetType().Name, + @event.Id); + } + } + else + { + logger.LogWarning( + "Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX", @event.GetType().Name, @event.Id); - return; } - // 无环境事务时开启短事务只写 Outbox,并告警:业务落库与事件非原子。 - logger.LogWarning( - "Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX", - @event.GetType().Name, - @event.Id); - - using var uow = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); - // 先打开 Outbox DbContext 以产生可征用的 DbTransaction - var dbProvider = uow.ServiceProvider.GetRequiredService>(); - await dbProvider.GetDbContextAsync(); + using var uow = unitOfWorkManager.Begin(new UnitOfWorkAttribute + { + IsTransactional = true, + RequiresNew = true, + IsolationLevel = IsolationLevel.ReadCommitted + }); + await EnsureOutboxDbContextAsync(unitOfWorkManager); await eventBus.PublishAsync(@event, cancellationToken); await uow.CompleteAsync(cancellationToken); } @@ -65,4 +84,30 @@ internal static class OmsIntegrationEventPublisher return false; } } + + /// + /// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。 + /// + public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager) + { + var current = unitOfWorkManager.Current + ?? throw new InvalidOperationException("No ambient unit of work"); + var dbProvider = current.ServiceProvider + .GetRequiredService>(); + return dbProvider.GetDbContextAsync(); + } + + private static bool IsTxConnectionMismatch(Exception ex) + { + for (var e = ex; e != null; e = e.InnerException!) + { + if (e.Message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase) + || e.Message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs index b73fa91..3cac21b 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.DependencyInjection; +using Fake.UnitOfWork; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -30,6 +31,7 @@ public sealed class OrderBatchJobCoordinator( IObjectStorage fileStorage, IOrderBatchJobPublisher publisher, OrderBatchJobProcessor batchJobProcessor, + IUnitOfWorkManager unitOfWorkManager, IOptions batchOptions, ILogger logger) : IScopedDependency { @@ -76,6 +78,12 @@ public sealed class OrderBatchJobCoordinator( } } + // 先打开 EF Outbox 上下文,尽量让环境事务由 EF 占连接,避免 SqlSugar 先开事务后 Outbox 征用失败。 + if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager)) + { + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); + } + var now = DateTime.Now; var job = new OrderBatchJob { diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs index 13dafbf..a467162 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs @@ -1,3 +1,4 @@ +using System.Data; using Fake.Application; using Fake.Security.Claims; using Fake.UnitOfWork; @@ -22,7 +23,7 @@ public partial class OrderService /// 创建订单批量异步任务并入队 RabbitMQ。 /// [HttpPost("/order/batch/jobs")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input) { return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId()); @@ -75,7 +76,7 @@ public partial class OrderService /// 异步批量创建轨迹订单(替代前端逐条调用)。 /// [HttpPost("/order/batch/track-upsert")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input) { input.Action = OrderBatchActionCodes.TrackUpsertSmallBag; @@ -97,7 +98,7 @@ public partial class OrderService /// 异步批量预报下游订单。 /// [HttpPost("/order/batch/downstream-forward")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input) { input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne; @@ -115,7 +116,7 @@ public partial class OrderService string.IsNullOrWhiteSpace(authorization) ? null : authorization); } - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] private Task EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input) { input.Action = action; @@ -126,7 +127,7 @@ public partial class OrderService /// 异步批量支付订单。 /// [HttpPost("/order/batch/pay")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchPayAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input); @@ -134,7 +135,7 @@ public partial class OrderService /// 异步批量预报订单。 /// [HttpPost("/order/batch/pre-declare")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input); @@ -142,7 +143,7 @@ public partial class OrderService /// 异步批量拦截订单。 /// [HttpPost("/order/batch/intercept")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input); @@ -150,7 +151,7 @@ public partial class OrderService /// 异步批量取消拦截。 /// [HttpPost("/order/batch/cancel-intercept")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input); @@ -158,7 +159,7 @@ public partial class OrderService /// 异步批量删除订单。 /// [HttpPost("/order/batch/delete")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input); @@ -166,7 +167,7 @@ public partial class OrderService /// 异步批量打印面单。 /// [HttpPost("/order/batch/print")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input); @@ -174,7 +175,7 @@ public partial class OrderService /// 异步批量标记问题件。 /// [HttpPost("/order/batch/mark-problematic")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input); @@ -182,7 +183,7 @@ public partial class OrderService /// 异步批量重新排货。 /// [HttpPost("/order/batch/reallocate")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input); @@ -190,7 +191,7 @@ public partial class OrderService /// 异步批量标记已认领。 /// [HttpPost("/order/batch/mark-claimed")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input); @@ -198,7 +199,7 @@ public partial class OrderService /// 异步批量更新处理状态。 /// [HttpPost("/order/batch/handle-status-update")] - [UnitOfWork(IsTransactional = true)] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public Task BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input); diff --git a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs index 37ecb2f..c48e471 100644 --- a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs +++ b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Fake.Application; using Fake.ObjectStorage; +using Fake.UnitOfWork; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -36,6 +37,8 @@ public class OrderBatchJobCoordinatorGovernanceTests actionExecutor.Object, batchOptions, NullLogger.Instance); + var unitOfWorkManager = new Mock(MockBehavior.Loose); + unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null); _coordinator = new OrderBatchJobCoordinator( _batchJobRepository.Object, _orderRepository.Object, @@ -44,6 +47,7 @@ public class OrderBatchJobCoordinatorGovernanceTests _fileStorage.Object, _publisher.Object, processor, + unitOfWorkManager.Object, batchOptions, NullLogger.Instance); } From 32156dcfe0e075d8398d0f75b64d853a3e707afc Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 10 Oct 2026 19:20:09 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E8=BD=A8=E8=BF=B9=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=20trackNo=20=E4=BB=85=E5=8F=96=E8=B7=9F=E8=B8=AA=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E5=8F=98=E6=9B=B4=E5=90=8E=E9=87=8D=E6=96=B0=20Upsert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../SmallBagTrackUpsertRequestBuilder.cs | 15 ++++-------- .../Orders/Batch/OrderBatchActionExecutor.cs | 24 +++++++++++++++++++ .../Orders/OrderService.DownstreamForward.cs | 4 ++++ .../Services/Orders/OrderService.OrderEdit.cs | 6 +++++ .../Orders/OrderService.ParcelBilling.cs | 19 +++++++++++++++ .../SmallBagTrackUpsertRequestBuilderTests.cs | 4 ++-- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs index 7b79ebe..19eef09 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs @@ -67,16 +67,11 @@ internal sealed class SmallBagTrackUpsertRequestBuilder( }; } - internal static string ResolveTrackNo(Order order) - { - var trackNo = order.TrackNo?.Trim(); - if (!string.IsNullOrWhiteSpace(trackNo)) - { - return trackNo; - } - - return order.ServiceNo?.Trim() ?? string.Empty; - } + /// + /// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。 + /// + internal static string ResolveTrackNo(Order order) => + order.TrackNo?.Trim() ?? string.Empty; internal static string? PickSupplierCodeFromProviders( IEnumerable providers, diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index b5c9283..1c0ef56 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -6,6 +6,7 @@ using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.Itfx; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Messaging; +using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.ChannelsAggregate.Repositories; @@ -23,6 +24,7 @@ public sealed class OrderBatchActionExecutor( IObjectStorage fileStorage, ISmallBagTrackOrderClient smallBagTrackOrderClient, ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder, + IOrderParcelCreateEventPublisher parcelCreateEventPublisher, OrderDownstreamCredentialResolver downstreamCredentialResolver, IItfxForwardClient itfxForwardClient, OrderFileManager orderFileManager, @@ -231,9 +233,31 @@ public sealed class OrderBatchActionExecutor( if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data) && data != null) { + var previousTrackNo = order.TrackNo; if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data)) { await orderRepository.UpdateAsync(order, cancellationToken); + + var nextTrackNo = order.TrackNo?.Trim() ?? string.Empty; + var prevTrackNo = previousTrackNo?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(nextTrackNo) + && !string.Equals(nextTrackNo, prevTrackNo, StringComparison.Ordinal)) + { + var trackRequest = await smallBagTrackUpsertRequestBuilder.BuildAsync( + order, + cancellationToken: cancellationToken); + trackRequest.AppendInitialTrackOnSuccess = true; + if (!string.IsNullOrWhiteSpace(authorization)) + { + trackRequest.Authorization = authorization.Trim(); + } + + await parcelCreateEventPublisher.PublishTrackUpsertAsync( + order, + trackRequest, + customerId: order.CustomerId, + cancellationToken); + } } if (!string.IsNullOrWhiteSpace(data.LabelUrl)) diff --git a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs index 4c74d7c..37934fc 100644 --- a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs +++ b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs @@ -57,9 +57,11 @@ public partial class OrderService && ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data) && data != null) { + var previousTrackNo = order.TrackNo; if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data)) { await OrderRepository.UpdateAsync(order); + await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo); } if (!string.IsNullOrWhiteSpace(data.LabelUrl)) @@ -118,9 +120,11 @@ public partial class OrderService && ItfxGetFileDataParser.TryParse(forward.ResponseBody, out var data) && data != null) { + var previousTrackNo = order.TrackNo; if (ItfxGetFileDataParser.ApplyToOrder(order, data)) { await OrderRepository.UpdateAsync(order); + await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo); } if (!string.IsNullOrWhiteSpace(data.Url)) diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 076e3f3..6b43287 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -48,6 +48,7 @@ public partial class OrderService order.ClientNo = newClientNo; } + var previousTrackNo = order.TrackNo; if (input.TrackNo != null) { order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim(); @@ -155,6 +156,11 @@ public partial class OrderService afterSnap, remark); + if (input.TrackNo != null) + { + await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo); + } + return (await MapOrderOutputsAsync([order])).First(); } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index e2e7fa6..9d3b15a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3201,6 +3201,25 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } + /// + /// 跟踪号相对旧值有变更且新值非空时,再创建/更新一次轨迹订单。 + /// + private async Task EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync( + Order order, + string? previousTrackNo, + bool appendInitialTrack = true) + { + var next = order.TrackNo?.Trim() ?? string.Empty; + var prev = previousTrackNo?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(next) + || string.Equals(next, prev, StringComparison.Ordinal)) + { + return; + } + + await EnqueueSmallBagTrackUpsertAsync(order, customer: null, receiver: null, appendInitialTrack); + } + private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode) { if (string.IsNullOrWhiteSpace(order.ServiceNo)) diff --git a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs index 4be8521..df70e79 100644 --- a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs +++ b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs @@ -20,7 +20,7 @@ public class SmallBagTrackUpsertRequestBuilderTests } [Fact] - public void ResolveTrackNo_FallsBackToServiceNoWhenTrackNoEmpty() + public void ResolveTrackNo_EmptyWhenTrackNoMissing() { var order = new Order { @@ -28,7 +28,7 @@ public class SmallBagTrackUpsertRequestBuilderTests TrackNo = null }; - Assert.Equal("IDE2607221859123456", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); + Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); } [Fact]